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