1#![allow(clippy::unused_unit, deprecated)]
22
23use dioxus::prelude::*;
24
25#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
33#[serde(rename_all = "lowercase")]
34pub enum ChangeCategory {
35 Added,
36 Changed,
37 Fixed,
38 Deprecated,
39 Removed,
40 Security,
41 Internal,
42}
43
44impl ChangeCategory {
45 pub fn label(&self) -> &'static str {
47 match self {
48 Self::Added => "新增",
49 Self::Changed => "改进",
50 Self::Fixed => "修复",
51 Self::Deprecated => "弃用",
52 Self::Removed => "移除",
53 Self::Security => "安全",
54 Self::Internal => "内部",
55 }
56 }
57
58 pub fn css_class(&self) -> &'static str {
60 match self {
61 Self::Added => "added",
62 Self::Changed => "changed",
63 Self::Fixed => "fixed",
64 Self::Deprecated => "deprecated",
65 Self::Removed => "removed",
66 Self::Security => "security",
67 Self::Internal => "internal",
68 }
69 }
70
71 #[cfg(feature = "server")]
73 fn from_name(name: &str) -> Self {
74 match name.trim() {
75 "Added" => Self::Added,
76 "Changed" => Self::Changed,
77 "Fixed" => Self::Fixed,
78 "Deprecated" => Self::Deprecated,
79 "Removed" => Self::Removed,
80 "Security" => Self::Security,
81 _ => Self::Internal,
82 }
83 }
84}
85
86#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
88pub struct ChangeGroup {
89 pub category: ChangeCategory,
91 pub items_html: String,
93}
94
95#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
97pub struct VersionEntry {
98 pub version: String,
100 pub date: Option<String>,
102 pub is_latest: bool,
104 pub intro_html: String,
106 pub groups: Vec<ChangeGroup>,
108}
109
110#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
112pub struct ChangelogData {
113 pub versions: Vec<VersionEntry>,
115}
116
117#[cfg(feature = "server")]
123const CHANGELOG_MD: &str = include_str!("../../CHANGELOG.md");
124
125#[cfg(feature = "server")]
127static CHANGELOG_PARSED: std::sync::LazyLock<ChangelogData> =
128 std::sync::LazyLock::new(|| parse_changelog(CHANGELOG_MD));
129
130#[cfg(feature = "server")]
133fn changelog_body(full: &str) -> &str {
134 match full.find("\n## ") {
135 Some(i) => &full[i + 1..],
136 None => full,
137 }
138}
139
140#[cfg(feature = "server")]
142struct RawVersion {
143 version: String,
144 date: Option<String>,
145 intro_md: String,
146 categories: Vec<(ChangeCategory, String)>,
147}
148
149#[cfg(feature = "server")]
155fn parse_changelog(full_md: &str) -> ChangelogData {
156 let body = changelog_body(full_md);
157
158 let mut raw_versions: Vec<RawVersion> = Vec::new();
159 let mut current: Option<RawVersion> = None;
160 let mut current_cat: Option<(ChangeCategory, String)> = None;
161
162 for line in body.lines() {
163 if line.starts_with("## ") {
164 flush_category(&mut current, &mut current_cat);
165 flush_version(&mut current, &mut raw_versions);
166 let (version, date) = parse_version_header(line);
167 current = Some(RawVersion {
168 version,
169 date,
170 intro_md: String::new(),
171 categories: Vec::new(),
172 });
173 } else if line.starts_with("### ") {
174 flush_category(&mut current, &mut current_cat);
175 let cat_name = line.trim_start_matches("### ").trim();
176 current_cat = Some((ChangeCategory::from_name(cat_name), String::new()));
177 } else if let Some((_, md)) = current_cat.as_mut() {
178 md.push_str(line);
179 md.push('\n');
180 } else if let Some(v) = current.as_mut() {
181 v.intro_md.push_str(line);
182 v.intro_md.push('\n');
183 }
184 }
185 flush_category(&mut current, &mut current_cat);
186 flush_version(&mut current, &mut raw_versions);
187
188 let mut versions: Vec<VersionEntry> = raw_versions
190 .into_iter()
191 .map(|rv| VersionEntry {
192 version: rv.version,
193 date: rv.date,
194 is_latest: false,
195 intro_html: render_section(&rv.intro_md),
196 groups: rv
197 .categories
198 .into_iter()
199 .map(|(cat, md)| ChangeGroup {
200 category: cat,
201 items_html: render_section(&md),
202 })
203 .collect(),
204 })
205 .collect();
206
207 for v in versions.iter_mut() {
209 if v.version != "Unreleased" {
210 v.is_latest = true;
211 break;
212 }
213 }
214
215 ChangelogData { versions }
216}
217
218#[cfg(feature = "server")]
220fn flush_category(
221 current: &mut Option<RawVersion>,
222 current_cat: &mut Option<(ChangeCategory, String)>,
223) {
224 if let Some((cat, md)) = current_cat.take() {
225 if let Some(v) = current.as_mut() {
226 v.categories.push((cat, md));
227 }
228 }
229}
230
231#[cfg(feature = "server")]
233fn flush_version(current: &mut Option<RawVersion>, raw_versions: &mut Vec<RawVersion>) {
234 if let Some(v) = current.take() {
235 raw_versions.push(v);
236 }
237}
238
239#[cfg(feature = "server")]
244fn parse_version_header(line: &str) -> (String, Option<String>) {
245 let header = line.trim_start_matches("## ").trim();
246 if header.starts_with('[') {
247 if let Some(end) = header.find(']') {
248 let version = header[1..end].to_string();
249 let rest = header[end + 1..].trim();
250 let date = rest
251 .strip_prefix('-')
252 .map(|s| s.trim().to_string())
253 .filter(|s| !s.is_empty());
254 return (version, date);
255 }
256 }
257 (header.to_string(), None)
258}
259
260#[cfg(feature = "server")]
265fn render_section(md: &str) -> String {
266 let trimmed = md.trim();
267 if trimmed.is_empty() {
268 return String::new();
269 }
270 crate::api::markdown::render_markdown_enhanced(trimmed).html
271}
272
273#[server(GetChangelog, "/api")]
281pub async fn get_changelog() -> Result<ChangelogData, ServerFnError> {
282 #[cfg(feature = "server")]
283 {
284 let data = tokio::task::spawn_blocking(|| CHANGELOG_PARSED.clone())
285 .await
286 .map_err(|_| crate::api::error::AppError::Internal("更新日志解析任务失败"))?;
287 Ok(data)
288 }
289
290 #[cfg(not(feature = "server"))]
291 {
292 Ok(ChangelogData {
293 versions: Vec::new(),
294 })
295 }
296}
297
298#[cfg(all(test, feature = "server"))]
303mod tests {
304 use super::*;
305
306 #[test]
307 fn changelog_body_strips_preamble() {
308 let md = "# Changelog\n\n基于 Keep a Changelog。\n\n## [Unreleased]\n\n### Added\n- x\n\n## [0.1.0] - 2026-01-01\n";
309 let body = changelog_body(md);
310 assert!(body.starts_with("## [Unreleased]"));
311 assert!(!body.contains("# Changelog"));
312 assert!(!body.contains("Keep a Changelog"));
313 }
314
315 #[test]
316 fn changelog_body_falls_back_to_full_text_without_version_section() {
317 let md = "# Changelog\n\n这里还没有任何版本段。\n";
318 assert_eq!(changelog_body(md), md);
319 }
320
321 #[test]
322 fn parse_version_header_with_date() {
323 let (ver, date) = parse_version_header("## [0.8.3] - 2026-08-03");
324 assert_eq!(ver, "0.8.3");
325 assert_eq!(date.as_deref(), Some("2026-08-03"));
326 }
327
328 #[test]
329 fn parse_version_header_unreleased() {
330 let (ver, date) = parse_version_header("## [Unreleased]");
331 assert_eq!(ver, "Unreleased");
332 assert!(date.is_none());
333 }
334
335 #[test]
336 fn parse_version_header_no_brackets() {
337 let (ver, date) = parse_version_header("## Some Title");
338 assert_eq!(ver, "Some Title");
339 assert!(date.is_none());
340 }
341
342 #[test]
343 fn parse_changelog_basic_structure() {
344 let md = "\
345# Changelog
346
347基于 Keep a Changelog。
348
349## [Unreleased]
350
351_暂无未发布改动。_
352
353## [0.1.0] - 2026-01-01
354
355### Added
356
357- **功能 A**:描述。
358- 功能 B。
359
360### Fixed
361
362- 修复 X。
363";
364 let data = parse_changelog(md);
365 assert_eq!(data.versions.len(), 2, "应解析出 2 个版本");
366
367 let unreleased = &data.versions[0];
369 assert_eq!(unreleased.version, "Unreleased");
370 assert!(unreleased.date.is_none());
371 assert!(!unreleased.is_latest, "Unreleased 不是最新版");
372 assert!(
373 unreleased.intro_html.contains("暂无"),
374 "intro 应包含占位文字"
375 );
376 assert!(unreleased.groups.is_empty(), "Unreleased 无分类组");
377
378 let v010 = &data.versions[1];
380 assert_eq!(v010.version, "0.1.0");
381 assert_eq!(v010.date.as_deref(), Some("2026-01-01"));
382 assert!(v010.is_latest, "第一个正式版应标记为最新");
383 assert_eq!(v010.groups.len(), 2, "应有 Added + Fixed 两个组");
384
385 let added = &v010.groups[0];
387 assert_eq!(added.category, ChangeCategory::Added);
388 assert!(added.items_html.contains("<strong>功能 A</strong>"));
389 assert!(added.items_html.contains("功能 B"));
390
391 let fixed = &v010.groups[1];
393 assert_eq!(fixed.category, ChangeCategory::Fixed);
394 assert!(fixed.items_html.contains("修复 X"));
395 }
396
397 #[test]
398 fn parse_changelog_nested_items() {
399 let md = "\
400## [0.1.0] - 2026-01-01
401
402### Added
403
404- **父条目**:描述。
405 - **子条目 A**:细节。
406 - **子条目 B**:细节。
407";
408 let data = parse_changelog(md);
409 let added = &data.versions[0].groups[0];
410 assert!(added.items_html.contains("父条目"), "应包含父条目");
411 assert!(added.items_html.contains("子条目 A"), "应包含嵌套子条目");
412 }
413
414 #[test]
415 fn parse_changelog_unknown_category_maps_to_internal() {
416 let md = "\
417## [0.1.0] - 2026-01-01
418
419### SomeNewCategory
420
421- 测试条目。
422";
423 let data = parse_changelog(md);
424 assert_eq!(
425 data.versions[0].groups[0].category,
426 ChangeCategory::Internal,
427 "未知分类应回退为 Internal"
428 );
429 }
430
431 #[test]
432 fn parse_changelog_empty_input() {
433 let data = parse_changelog("# Changelog\n\n没有版本段。\n");
434 assert!(data.versions.is_empty(), "无版本段时应返回空版本列表");
435 }
436
437 #[test]
439 fn changelog_parses_real_file() {
440 let data = &*CHANGELOG_PARSED;
441 assert!(
442 !data.versions.is_empty(),
443 "真实 CHANGELOG 应解析出至少一个版本"
444 );
445 assert!(
446 data.versions.iter().any(|v| v.version == "0.8.3"),
447 "正文应包含 0.8.3 版本"
448 );
449 let latest_count = data.versions.iter().filter(|v| v.is_latest).count();
451 assert_eq!(latest_count, 1, "应恰好有一个版本标记为最新");
452 for v in &data.versions {
454 if v.version != "Unreleased" {
455 assert!(
456 !v.groups.is_empty() || !v.intro_html.is_empty(),
457 "版本 {} 应有内容",
458 v.version
459 );
460 }
461 }
462 for v in &data.versions {
464 assert!(
465 !v.intro_html.contains("keepachangelog.com"),
466 "preamble 不应出现在任何版本中"
467 );
468 }
469 }
470
471 #[test]
472 fn category_label_and_css() {
473 assert_eq!(ChangeCategory::Added.label(), "新增");
474 assert_eq!(ChangeCategory::Fixed.label(), "修复");
475 assert_eq!(ChangeCategory::Security.label(), "安全");
476 assert_eq!(ChangeCategory::Added.css_class(), "added");
477 assert_eq!(ChangeCategory::Security.css_class(), "security");
478 }
479}