1#![allow(clippy::unused_unit, deprecated)]
22
23use crate::api::error::ApiError;
24use leptos::prelude::*;
25
26#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
34#[serde(rename_all = "lowercase")]
35pub enum ChangeCategory {
36 Added,
37 Changed,
38 Fixed,
39 Deprecated,
40 Removed,
41 Security,
42 Internal,
43}
44
45impl ChangeCategory {
46 pub fn label(&self) -> &'static str {
48 match self {
49 Self::Added => "新增",
50 Self::Changed => "改进",
51 Self::Fixed => "修复",
52 Self::Deprecated => "弃用",
53 Self::Removed => "移除",
54 Self::Security => "安全",
55 Self::Internal => "内部",
56 }
57 }
58
59 pub fn css_class(&self) -> &'static str {
61 match self {
62 Self::Added => "added",
63 Self::Changed => "changed",
64 Self::Fixed => "fixed",
65 Self::Deprecated => "deprecated",
66 Self::Removed => "removed",
67 Self::Security => "security",
68 Self::Internal => "internal",
69 }
70 }
71
72 #[cfg(feature = "server")]
74 fn from_name(name: &str) -> Self {
75 match name.trim() {
76 "Added" => Self::Added,
77 "Changed" => Self::Changed,
78 "Fixed" => Self::Fixed,
79 "Deprecated" => Self::Deprecated,
80 "Removed" => Self::Removed,
81 "Security" => Self::Security,
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 raw_versions.retain(|v| v.version != "Unreleased");
191
192 let mut versions: Vec<VersionEntry> = raw_versions
194 .into_iter()
195 .map(|rv| VersionEntry {
196 version: rv.version,
197 date: rv.date,
198 is_latest: false,
199 intro_html: render_section(&rv.intro_md),
200 groups: rv
201 .categories
202 .into_iter()
203 .map(|(cat, md)| ChangeGroup {
204 category: cat,
205 items_html: render_section(&md),
206 })
207 .collect(),
208 })
209 .collect();
210
211 if let Some(latest) = versions.first_mut() {
212 latest.is_latest = true;
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, endpoint = "get_changelog", input = leptos::server_fn::codec::Json)]
281pub async fn get_changelog() -> Result<ChangelogData, ApiError> {
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#[server(LatestRelease, endpoint = "latest_release", input = leptos::server_fn::codec::Json)]
300pub async fn latest_release() -> Result<Option<(String, Option<String>)>, ApiError> {
301 let data = get_changelog().await?;
302 Ok(data
303 .versions
304 .into_iter()
305 .next()
306 .map(|v| (v.version, v.date)))
307}
308
309#[cfg(all(test, feature = "server"))]
314mod tests {
315 use super::*;
316
317 #[test]
318 fn changelog_body_strips_preamble() {
319 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";
320 let body = changelog_body(md);
321 assert!(body.starts_with("## [Unreleased]"));
322 assert!(!body.contains("# Changelog"));
323 assert!(!body.contains("Keep a Changelog"));
324 }
325
326 #[test]
327 fn changelog_body_falls_back_to_full_text_without_version_section() {
328 let md = "# Changelog\n\n这里还没有任何版本段。\n";
329 assert_eq!(changelog_body(md), md);
330 }
331
332 #[test]
333 fn parse_version_header_with_date() {
334 let (ver, date) = parse_version_header("## [0.8.3] - 2026-08-03");
335 assert_eq!(ver, "0.8.3");
336 assert_eq!(date.as_deref(), Some("2026-08-03"));
337 }
338
339 #[test]
340 fn parse_version_header_unreleased() {
341 let (ver, date) = parse_version_header("## [Unreleased]");
342 assert_eq!(ver, "Unreleased");
343 assert!(date.is_none());
344 }
345
346 #[test]
347 fn parse_version_header_no_brackets() {
348 let (ver, date) = parse_version_header("## Some Title");
349 assert_eq!(ver, "Some Title");
350 assert!(date.is_none());
351 }
352
353 #[test]
354 fn parse_changelog_basic_structure() {
355 let md = "\
356# Changelog
357
358基于 Keep a Changelog。
359
360## [Unreleased]
361
362_暂无未发布改动。_
363
364### Added
365
366- 不应出现在页面数据里。
367
368## [0.1.0] - 2026-01-01
369
370### Added
371
372- **功能 A**:描述。
373- 功能 B。
374
375### Fixed
376
377- 修复 X。
378";
379 let data = parse_changelog(md);
380 assert_eq!(data.versions.len(), 1, "Unreleased 不应进入页面数据");
381 assert!(
382 data.versions.iter().all(|v| v.version != "Unreleased"
383 && !v.intro_html.contains("暂无")
384 && v.groups.iter().all(|g| !g.items_html.contains("不应出现"))),
385 "Unreleased 的导语和条目都不应出现"
386 );
387
388 let v010 = &data.versions[0];
389 assert_eq!(v010.version, "0.1.0");
390 assert_eq!(v010.date.as_deref(), Some("2026-01-01"));
391 assert!(v010.is_latest, "第一个已发布版本应标记为最新");
392 assert_eq!(v010.groups.len(), 2, "应有 Added + Fixed 两个组");
393
394 let added = &v010.groups[0];
396 assert_eq!(added.category, ChangeCategory::Added);
397 assert!(added.items_html.contains("<strong>功能 A</strong>"));
398 assert!(added.items_html.contains("功能 B"));
399
400 let fixed = &v010.groups[1];
402 assert_eq!(fixed.category, ChangeCategory::Fixed);
403 assert!(fixed.items_html.contains("修复 X"));
404 }
405
406 #[test]
407 fn parse_changelog_nested_items() {
408 let md = "\
409## [0.1.0] - 2026-01-01
410
411### Added
412
413- **父条目**:描述。
414 - **子条目 A**:细节。
415 - **子条目 B**:细节。
416";
417 let data = parse_changelog(md);
418 let added = &data.versions[0].groups[0];
419 assert!(added.items_html.contains("父条目"), "应包含父条目");
420 assert!(added.items_html.contains("子条目 A"), "应包含嵌套子条目");
421 }
422
423 #[test]
424 fn parse_changelog_unknown_category_maps_to_internal() {
425 let md = "\
426## [0.1.0] - 2026-01-01
427
428### SomeNewCategory
429
430- 测试条目。
431";
432 let data = parse_changelog(md);
433 assert_eq!(
434 data.versions[0].groups[0].category,
435 ChangeCategory::Internal,
436 "未知分类应回退为 Internal"
437 );
438 }
439
440 #[test]
441 fn parse_changelog_empty_input() {
442 let data = parse_changelog("# Changelog\n\n没有版本段。\n");
443 assert!(data.versions.is_empty(), "无版本段时应返回空版本列表");
444 }
445
446 #[test]
448 fn changelog_parses_real_file() {
449 let data = &*CHANGELOG_PARSED;
450 assert!(
451 !data.versions.is_empty(),
452 "真实 CHANGELOG 应解析出至少一个版本"
453 );
454 assert!(
455 data.versions.iter().any(|v| v.version == "0.8.3"),
456 "正文应包含 0.8.3 版本"
457 );
458 let latest_count = data.versions.iter().filter(|v| v.is_latest).count();
460 assert_eq!(latest_count, 1, "应恰好有一个版本标记为最新");
461 for v in &data.versions {
463 assert_ne!(v.version, "Unreleased", "页面数据不应包含 Unreleased");
464 assert!(
465 !v.groups.is_empty() || !v.intro_html.is_empty(),
466 "版本 {} 应有内容",
467 v.version
468 );
469 }
470 for v in &data.versions {
472 assert!(
473 !v.intro_html.contains("keepachangelog.com"),
474 "preamble 不应出现在任何版本中"
475 );
476 }
477 }
478
479 #[test]
480 fn category_label_and_css() {
481 assert_eq!(ChangeCategory::Added.label(), "新增");
482 assert_eq!(ChangeCategory::Fixed.label(), "修复");
483 assert_eq!(ChangeCategory::Security.label(), "安全");
484 assert_eq!(ChangeCategory::Added.css_class(), "added");
485 assert_eq!(ChangeCategory::Security.css_class(), "security");
486 }
487}