1use std::path::Path;
2
3#[derive(Debug)]
9pub enum RoadmapError {
10 Io(std::io::Error),
12 Parse(String),
14}
15
16impl std::fmt::Display for RoadmapError {
17 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18 match self {
19 Self::Io(e) => write!(f, "读取 ROADMAP 失败: {}", e),
20 Self::Parse(e) => write!(f, "解析 ROADMAP 失败: {}", e),
21 }
22 }
23}
24
25impl std::error::Error for RoadmapError {}
26
27impl From<std::io::Error> for RoadmapError {
28 fn from(e: std::io::Error) -> Self {
29 Self::Io(e)
30 }
31}
32
33#[derive(Debug, Clone, PartialEq)]
39pub struct RoadmapChecklistItem {
40 pub description: String,
42 pub completed: bool,
44}
45
46#[derive(Debug, Clone, PartialEq)]
48pub struct RoadmapProgress {
49 pub total: usize,
51 pub completed: usize,
53}
54
55impl RoadmapProgress {
56 pub fn percent(&self) -> f64 {
58 if self.total == 0 {
59 return 100.0;
60 }
61 (self.completed as f64 / self.total as f64) * 100.0
62 }
63}
64
65#[derive(Debug, Clone, PartialEq)]
67pub struct RoadmapVersion {
68 pub version: String,
70 pub status: String,
72 pub done: usize,
74 pub total: usize,
76 pub categories: Vec<(String, Vec<RoadmapChecklistItem>)>,
79}
80
81impl RoadmapVersion {
82 pub fn percent(&self) -> f64 {
84 if self.total == 0 {
85 return 100.0;
86 }
87 (self.done as f64 / self.total as f64) * 100.0
88 }
89}
90
91#[derive(Debug, Clone, PartialEq)]
93pub struct RoadmapIssue {
94 pub line: usize,
96 pub scope: String,
98 pub message: String,
100}
101
102#[derive(Debug, Clone, PartialEq)]
104pub struct Roadmap {
105 #[allow(dead_code)]
107 raw: String,
108 versions: Vec<RoadmapVersion>,
110}
111
112impl Roadmap {
117 pub fn from_path(path: &Path) -> Result<Self, RoadmapError> {
119 let raw = std::fs::read_to_string(path)?;
120 Self::from_str(&raw)
121 }
122
123 pub fn from_str(s: &str) -> Result<Self, RoadmapError> {
131 let lines: Vec<&str> = s.lines().collect();
132 if lines.is_empty() {
133 return Err(RoadmapError::Parse("ROADMAP 为空".into()));
134 }
135
136 let first = lines[0].trim();
138 if first != "# ROADMAP" {
139 return Err(RoadmapError::Parse(format!(
140 "首行应包含 `# ROADMAP`,发现: {}",
141 first
142 )));
143 }
144
145 let mut versions: Vec<RoadmapVersion> = Vec::new();
146 let mut current_version: Option<RoadmapVersionBuilder> = None;
147
148 for line in &lines[1..] {
149 let trimmed = line.trim();
150
151 if trimmed.is_empty() || trimmed.starts_with('>') {
153 continue;
154 }
155
156 if trimmed.starts_with("## ") {
157 if let Some(builder) = current_version.take() {
159 versions.push(builder.build());
160 }
161 match parse_version_header(trimmed) {
162 Ok((version, status)) => {
163 current_version = Some(RoadmapVersionBuilder::new(version, status));
164 }
165 Err(e) => {
166 return Err(RoadmapError::Parse(format!(
167 "版本标题格式无效: {} — {}",
168 trimmed, e
169 )));
170 }
171 }
172 } else if let Some(ref mut builder) = current_version {
173 if trimmed.starts_with("### ") {
174 let category = trimmed[4..].trim().to_string();
176 builder.add_category(category);
177 } else if trimmed.starts_with("- [") && trimmed.len() > 5 {
178 let completed = trimmed.as_bytes()[3] == b'x';
180 let description = trimmed[5..].trim().to_string();
181 builder.add_issue(completed, description);
182 }
183 }
185 }
186
187 if let Some(builder) = current_version.take() {
189 versions.push(builder.build());
190 }
191
192 if versions.is_empty() {
193 return Err(RoadmapError::Parse(
194 "未找到任何版本区块 (`## [x.y.z]`)".into(),
195 ));
196 }
197
198 Ok(Self {
199 raw: s.to_string(),
200 versions,
201 })
202 }
203}
204
205impl Roadmap {
210 pub fn versions(&self) -> &[RoadmapVersion] {
212 &self.versions
213 }
214
215 pub fn total_done(&self) -> usize {
217 self.versions.iter().map(|v| v.done).sum()
218 }
219
220 pub fn total_all(&self) -> usize {
222 self.versions.iter().map(|v| v.total).sum()
223 }
224}
225
226impl Roadmap {
231 pub fn validate(&self, scope: &str) -> Vec<RoadmapIssue> {
240 let mut issues = Vec::new();
241 let mut line_number: usize = 1;
242 let lines: Vec<&str> = self.raw.lines().collect();
243
244 for line in &lines {
245 let trimmed = line.trim();
246
247 if trimmed.starts_with("## [") {
249 if let Some(end) = trimmed.find(']') {
250 let raw_version = &trimmed[4..end];
251 let clean = raw_version.strip_prefix('v').unwrap_or(raw_version);
253 let parts: Vec<&str> = clean.split('.').collect();
254 if parts.len() != 3
255 || parts
256 .iter()
257 .any(|p| p.is_empty() || !p.chars().all(|c| c.is_ascii_digit()))
258 {
259 issues.push(RoadmapIssue {
260 line: line_number,
261 scope: scope.to_string(),
262 message: format!("版本号格式异常(期待 `X.Y.Z`): `{}`", raw_version),
263 });
264 }
265 }
266 }
267
268 if trimmed.starts_with("### ") {
270 let category = trimmed[4..].trim();
271 let expected = category_expected_case(category);
272 if category != expected {
273 issues.push(RoadmapIssue {
274 line: line_number,
275 scope: scope.to_string(),
276 message: format!(
277 "分类标题大小写不标准: `### {}`,标准写法为 `### {}`",
278 category, expected
279 ),
280 });
281 }
282 }
283
284 if trimmed.starts_with("- [") && trimmed.len() > 5 {
286 let third = trimmed.as_bytes().get(3);
287 if third != Some(&b' ') && third != Some(&b'x') {
288 issues.push(RoadmapIssue {
289 line: line_number,
290 scope: scope.to_string(),
291 message: format!(
292 "checkbox 格式异常: `{}`,标准为 `- [ ]` 或 `- [x]`",
293 trimmed
294 ),
295 });
296 }
297 }
298
299 line_number += 1;
300 }
301
302 issues
303 }
304}
305
306fn category_expected_case(s: &str) -> String {
308 match s {
309 "added" | "Added" => "Added".into(),
310 "changed" | "Changed" => "Changed".into(),
311 "deprecated" | "Deprecated" => "Deprecated".into(),
312 "removed" | "Removed" => "Removed".into(),
313 "fixed" | "Fixed" => "Fixed".into(),
314 "security" | "Security" => "Security".into(),
315 _ => s.to_string(),
316 }
317}
318
319struct RoadmapVersionBuilder {
324 version: String,
325 status: String,
326 categories: Vec<(String, Vec<RoadmapChecklistItem>)>,
327}
328
329impl RoadmapVersionBuilder {
330 fn new(version: String, status: String) -> Self {
331 Self {
332 version,
333 status,
334 categories: Vec::new(),
335 }
336 }
337
338 fn add_category(&mut self, name: String) {
339 self.categories.push((name, Vec::new()));
340 }
341
342 fn add_issue(&mut self, completed: bool, description: String) {
343 if let Some(last) = self.categories.last_mut() {
344 last.1.push(RoadmapChecklistItem {
345 description,
346 completed,
347 });
348 }
349 }
351
352 fn build(self) -> RoadmapVersion {
353 let total: usize = self.categories.iter().map(|(_, items)| items.len()).sum();
354 let done: usize = self
355 .categories
356 .iter()
357 .flat_map(|(_, items)| items)
358 .filter(|i| i.completed)
359 .count();
360
361 RoadmapVersion {
362 version: self.version,
363 status: self.status,
364 done,
365 total,
366 categories: self.categories,
367 }
368 }
369}
370
371fn parse_version_header(s: &str) -> Result<(String, String), String> {
377 let inner = s[3..].trim(); if !inner.starts_with('[') {
380 return Err("版本号应以 `[` 开头".into());
381 }
382 let close_bracket = inner.find(']').ok_or("缺少 `]`".to_string())?;
383 let version = inner[1..close_bracket].to_string();
384 if version.is_empty() {
385 return Err("版本号为空".into());
386 }
387 let version = version.strip_prefix('v').unwrap_or(&version).to_string();
389 let rest = inner[close_bracket + 1..].trim();
390 let status = if let Some(pos) = rest.find('—') {
392 rest[pos + 3..].trim().to_string()
393 } else if let Some(pos) = rest.find('-') {
394 rest[pos + 1..].trim().to_string()
395 } else {
396 rest.to_string()
397 };
398
399 Ok((version, status))
400}
401
402#[cfg(test)]
407mod tests {
408 use super::*;
409
410 fn sample_roadmap() -> &'static str {
411 "\
412# ROADMAP
413
414> 格式说明。
415
416## [0.1.5] — 待实施
417
418### Added
419- [ ] item one
420- [ ] item two
421
422### Fixed
423- [ ] bug fix
424
425## [0.1.4] — 已发布
426
427### Added
428- [x] changelog module
429- [x] CI workflow
430"
431 }
432
433 fn mixed_roadmap() -> &'static str {
434 "\
435# ROADMAP
436
437## [0.2.0] — 待实施
438
439### Added
440- [ ] big feature
441
442## [0.1.0] — 已发布
443
444### Added
445- [x] initial
446- [x] second
447- [ ] third
448"
449 }
450
451 #[test]
454 fn test_version_percent() {
455 let v = RoadmapVersion {
456 version: "0.1.0".into(),
457 status: "test".into(),
458 done: 2,
459 total: 4,
460 categories: Vec::new(),
461 };
462 assert!((v.percent() - 50.0).abs() < f64::EPSILON);
463 }
464
465 #[test]
466 fn test_version_percent_empty() {
467 let v = RoadmapVersion {
468 version: "0.1.0".into(),
469 status: "test".into(),
470 done: 0,
471 total: 0,
472 categories: Vec::new(),
473 };
474 assert!((v.percent() - 100.0).abs() < f64::EPSILON);
475 }
476
477 #[test]
478 fn test_version_percent_all_done() {
479 let v = RoadmapVersion {
480 version: "0.1.0".into(),
481 status: "test".into(),
482 done: 3,
483 total: 3,
484 categories: Vec::new(),
485 };
486 assert!((v.percent() - 100.0).abs() < f64::EPSILON);
487 }
488
489 #[test]
490 fn test_version_percent_none_done() {
491 let v = RoadmapVersion {
492 version: "0.1.0".into(),
493 status: "test".into(),
494 done: 0,
495 total: 5,
496 categories: Vec::new(),
497 };
498 assert!((v.percent() - 0.0).abs() < f64::EPSILON);
499 }
500
501 #[test]
504 fn test_progress_percent() {
505 let p = RoadmapProgress {
506 total: 4,
507 completed: 2,
508 };
509 assert!((p.percent() - 50.0).abs() < f64::EPSILON);
510 }
511
512 #[test]
513 fn test_progress_empty() {
514 let p = RoadmapProgress {
515 total: 0,
516 completed: 0,
517 };
518 assert!((p.percent() - 100.0).abs() < f64::EPSILON);
519 }
520
521 #[test]
524 fn test_from_str_empty() {
525 let r = Roadmap::from_str("");
526 assert!(r.is_err());
527 assert!(r.unwrap_err().to_string().contains("为空"));
528 }
529
530 #[test]
531 fn test_from_str_no_header() {
532 let r = Roadmap::from_str("## [0.1.0] — test\n");
533 assert!(r.is_err());
534 assert!(r.unwrap_err().to_string().contains("首行"));
535 }
536
537 #[test]
538 fn test_from_str_valid() {
539 let r = Roadmap::from_str(sample_roadmap()).unwrap();
540 let versions = r.versions();
541 assert_eq!(versions.len(), 2);
542
543 let v1 = &versions[0];
545 assert_eq!(v1.version, "0.1.5");
546 assert_eq!(v1.status, "待实施");
547 assert_eq!(v1.categories.len(), 2);
548 assert_eq!(v1.categories[0].0, "Added");
549 assert_eq!(v1.categories[0].1.len(), 2);
550 assert_eq!(v1.categories[1].0, "Fixed");
551 assert_eq!(v1.categories[1].1.len(), 1);
552
553 assert!(!v1.categories[0].1[0].completed);
555 assert!(!v1.categories[0].1[1].completed);
556 assert!(!v1.categories[1].1[0].completed);
557
558 assert_eq!(v1.total, 3);
560 assert_eq!(v1.done, 0);
561
562 let v2 = &versions[1];
564 assert_eq!(v2.version, "0.1.4");
565 assert_eq!(v2.status, "已发布");
566 assert_eq!(v2.total, 2);
567 assert_eq!(v2.done, 2);
568
569 assert_eq!(r.total_done(), 2);
571 assert_eq!(r.total_all(), 5);
572 }
573
574 #[test]
575 fn test_from_str_mixed() {
576 let r = Roadmap::from_str(mixed_roadmap()).unwrap();
577 let versions = r.versions();
578 assert_eq!(versions.len(), 2);
579
580 let v1 = &versions[0];
581 assert_eq!(v1.version, "0.2.0");
582 assert_eq!(v1.status, "待实施");
583 assert_eq!(v1.total, 1);
584 assert_eq!(v1.done, 0);
585
586 let v2 = &versions[1];
587 assert_eq!(v2.version, "0.1.0");
588 assert_eq!(v2.status, "已发布");
589 assert_eq!(v2.total, 3);
590 assert_eq!(v2.done, 2);
591
592 assert_eq!(r.total_done(), 2);
593 assert_eq!(r.total_all(), 4);
594 }
595
596 #[test]
597 fn test_from_path() {
598 let d = tempfile::tempdir().unwrap();
599 let path = d.path().join("ROADMAP.md");
600 std::fs::write(&path, sample_roadmap()).unwrap();
601 let r = Roadmap::from_path(&path).unwrap();
602 assert_eq!(r.versions().len(), 2);
603 assert_eq!(r.versions()[0].version, "0.1.5");
604 }
605
606 #[test]
607 fn test_from_path_not_found() {
608 let r = Roadmap::from_path(Path::new("/nonexistent/ROADMAP.md"));
609 assert!(r.is_err());
610 }
611
612 #[test]
615 fn test_validate_valid() {
616 let r = Roadmap::from_str(sample_roadmap()).unwrap();
617 let issues = r.validate("test-scope");
618 let msgs: Vec<&str> = issues.iter().map(|i| i.message.as_str()).collect();
619 assert!(issues.is_empty(), "预期无验证问题,发现: {:?}", msgs);
620 }
621
622 #[test]
623 fn test_validate_v_prefix_allowed() {
624 let s = "\
626# ROADMAP
627
628## [v0.1.0] — test
629
630### Added
631- [ ] something
632";
633 let r = Roadmap::from_str(s).unwrap();
634 assert_eq!(r.versions()[0].version, "0.1.0");
636 let issues = r.validate("scope");
638 let v_issues: Vec<_> = issues
639 .iter()
640 .filter(|i| i.message.contains("v 前缀"))
641 .collect();
642 assert!(
643 v_issues.is_empty(),
644 "不应有 v 前缀相关验证问题: {:?}",
645 v_issues
646 );
647 }
648
649 #[test]
650 fn test_validate_invalid_version() {
651 let s = "\
652# ROADMAP
653
654## [abc] — 待实施
655
656### Added
657- [ ] something
658";
659 let r = Roadmap::from_str(s).unwrap();
660 let issues = r.validate("scope");
661 assert!(!issues.is_empty());
662 assert!(issues.iter().any(|i| i.message.contains("版本号格式异常")));
663 }
664
665 #[test]
666 fn test_validate_category_case() {
667 let s = "\
668# ROADMAP
669
670## [0.1.0] — test
671
672### added
673- [ ] something
674";
675 let r = Roadmap::from_str(s).unwrap();
676 let issues = r.validate("scope");
677 assert!(!issues.is_empty());
678 assert!(issues.iter().any(|i| i.message.contains("大小写不标准")));
679 }
680
681 #[test]
682 fn test_validate_line_numbers() {
683 let s = "\
685# ROADMAP
686
687## [abc] — test
688
689### Added
690- [ ] ok
691";
692 let r = Roadmap::from_str(s).unwrap();
693 let issues = r.validate("scope");
694 assert!(!issues.is_empty());
695 assert_eq!(issues[0].line, 3);
697 assert_eq!(issues[0].scope, "scope");
698 }
699
700 #[test]
703 fn test_parse_version_header_normal() {
704 let (v, s) = parse_version_header("## [0.1.5] — 待实施").unwrap();
705 assert_eq!(v, "0.1.5");
706 assert_eq!(s, "待实施");
707 }
708
709 #[test]
710 fn test_parse_version_header_no_bracket() {
711 let r = parse_version_header("## 0.1.5 — test");
712 assert!(r.is_err());
713 }
714
715 #[test]
716 fn test_parse_version_header_empty_version() {
717 let r = parse_version_header("## [] — test");
718 assert!(r.is_err());
719 }
720
721 #[test]
722 fn test_parse_version_header_hyphen() {
723 let (v, s) = parse_version_header("## [0.1.0] - released").unwrap();
724 assert_eq!(v, "0.1.0");
725 assert_eq!(s, "released");
726 }
727
728 #[test]
729 fn test_parse_version_header_no_status() {
730 let (v, s) = parse_version_header("## [0.1.0]").unwrap();
731 assert_eq!(v, "0.1.0");
732 assert_eq!(s, "");
733 }
734
735 #[test]
738 fn test_from_str_parse_version_header_error() {
739 let s = "\
741# ROADMAP
742
743## 无方括号版本
744";
745 let r = Roadmap::from_str(s);
746 assert!(r.is_err());
747 assert!(r.unwrap_err().to_string().contains("版本标题格式无效"));
748 }
749
750 #[test]
751 fn test_from_str_no_versions() {
752 let s = "\
754# ROADMAP
755
756> 只有描述,没有版本。
757";
758 let r = Roadmap::from_str(s);
759 assert!(r.is_err());
760 assert!(r.unwrap_err().to_string().contains("未找到任何版本区块"));
761 }
762
763 #[test]
764 fn test_validate_invalid_checkbox() {
765 let s = "\
767# ROADMAP
768
769## [0.1.0] — test
770
771### Added
772- [X] uppercase
773";
774 let r = Roadmap::from_str(s).unwrap();
775 let issues = r.validate("scope");
776 let checkbox_issues: Vec<_> = issues
777 .iter()
778 .filter(|i| i.message.contains("checkbox 格式异常"))
779 .collect();
780 assert_eq!(checkbox_issues.len(), 1);
781 assert!(checkbox_issues[0].message.contains("- [X]"));
782 }
783
784 #[test]
785 fn test_validate_unknown_category() {
786 let s = "\
788# ROADMAP
789
790## [0.1.0] — test
791
792### CustomSection
793- [ ] something
794";
795 let r = Roadmap::from_str(s).unwrap();
796 let issues = r.validate("scope");
798 let category_issues: Vec<_> = issues
799 .iter()
800 .filter(|i| i.message.contains("自定义分类"))
801 .collect();
802 assert!(!issues.iter().any(|i| i.message.contains("大小写不标准")));
804 }
805}