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 #[allow(clippy::should_implement_trait)]
131 pub fn from_str(s: &str) -> Result<Self, RoadmapError> {
132 let lines: Vec<&str> = s.lines().collect();
133 if lines.is_empty() {
134 return Err(RoadmapError::Parse("ROADMAP 为空".into()));
135 }
136
137 let first = lines[0].trim();
139 if first != "# ROADMAP" {
140 return Err(RoadmapError::Parse(format!(
141 "首行应包含 `# ROADMAP`,发现: {}",
142 first
143 )));
144 }
145
146 let mut versions: Vec<RoadmapVersion> = Vec::new();
147 let mut current_version: Option<RoadmapVersionBuilder> = None;
148
149 for line in &lines[1..] {
150 let trimmed = line.trim();
151
152 if trimmed.is_empty() || trimmed.starts_with('>') {
154 continue;
155 }
156
157 if trimmed.starts_with("## ") {
158 if let Some(builder) = current_version.take() {
160 versions.push(builder.build());
161 }
162 match parse_version_header(trimmed) {
163 Ok((version, status)) => {
164 current_version = Some(RoadmapVersionBuilder::new(version, status));
165 }
166 Err(e) => {
167 return Err(RoadmapError::Parse(format!(
168 "版本标题格式无效: {} — {}",
169 trimmed, e
170 )));
171 }
172 }
173 } else if let Some(ref mut builder) = current_version {
174 if trimmed.starts_with("### ") {
175 let category = trimmed
177 .strip_prefix("### ")
178 .unwrap_or("")
179 .trim()
180 .to_string();
181 builder.add_category(category);
182 } else if trimmed.starts_with("- [") && trimmed.len() > 5 {
183 let completed = trimmed.as_bytes()[3] == b'x';
185 let description = trimmed[5..].trim().to_string();
186 builder.add_issue(completed, description);
187 }
188 }
190 }
191
192 if let Some(builder) = current_version.take() {
194 versions.push(builder.build());
195 }
196
197 if versions.is_empty() {
198 return Err(RoadmapError::Parse(
199 "未找到任何版本区块 (`## [x.y.z]`)".into(),
200 ));
201 }
202
203 Ok(Self {
204 raw: s.to_string(),
205 versions,
206 })
207 }
208}
209
210impl Roadmap {
215 pub fn versions(&self) -> &[RoadmapVersion] {
217 &self.versions
218 }
219
220 pub fn total_done(&self) -> usize {
222 self.versions.iter().map(|v| v.done).sum()
223 }
224
225 pub fn total_all(&self) -> usize {
227 self.versions.iter().map(|v| v.total).sum()
228 }
229}
230
231impl Roadmap {
236 pub fn validate(&self, scope: &str) -> Vec<RoadmapIssue> {
245 let mut issues = Vec::new();
246 let lines: Vec<&str> = self.raw.lines().collect();
247
248 for (line_number, line) in lines.iter().enumerate() {
249 let trimmed = line.trim();
250 let line_number = line_number + 1;
251
252 if trimmed.starts_with("## [")
254 && let Some(end) = trimmed.find(']')
255 {
256 let raw_version = &trimmed[4..end];
257 let clean = raw_version.strip_prefix('v').unwrap_or(raw_version);
259 let parts: Vec<&str> = clean.split('.').collect();
260 if parts.len() != 3
261 || parts
262 .iter()
263 .any(|p| p.is_empty() || !p.chars().all(|c| c.is_ascii_digit()))
264 {
265 issues.push(RoadmapIssue {
266 line: line_number,
267 scope: scope.to_string(),
268 message: format!("版本号格式异常(期待 `X.Y.Z`): `{}`", raw_version),
269 });
270 }
271 }
272
273 if trimmed.starts_with("### ") {
275 let category = trimmed.strip_prefix("### ").unwrap_or("").trim();
276 let expected = category_expected_case(category);
277 if category != expected {
278 issues.push(RoadmapIssue {
279 line: line_number,
280 scope: scope.to_string(),
281 message: format!(
282 "分类标题大小写不标准: `### {}`,标准写法为 `### {}`",
283 category, expected
284 ),
285 });
286 }
287 }
288
289 if trimmed.starts_with("- [") && trimmed.len() > 5 {
291 let third = trimmed.as_bytes().get(3);
292 if third != Some(&b' ') && third != Some(&b'x') {
293 issues.push(RoadmapIssue {
294 line: line_number,
295 scope: scope.to_string(),
296 message: format!(
297 "checkbox 格式异常: `{}`,标准为 `- [ ]` 或 `- [x]`",
298 trimmed
299 ),
300 });
301 }
302 }
303 }
304
305 issues
306 }
307}
308
309fn category_expected_case(s: &str) -> String {
311 match s {
312 "added" | "Added" => "Added".into(),
313 "changed" | "Changed" => "Changed".into(),
314 "deprecated" | "Deprecated" => "Deprecated".into(),
315 "removed" | "Removed" => "Removed".into(),
316 "fixed" | "Fixed" => "Fixed".into(),
317 "security" | "Security" => "Security".into(),
318 _ => s.to_string(),
319 }
320}
321
322struct RoadmapVersionBuilder {
327 version: String,
328 status: String,
329 categories: Vec<(String, Vec<RoadmapChecklistItem>)>,
330}
331
332impl RoadmapVersionBuilder {
333 fn new(version: String, status: String) -> Self {
334 Self {
335 version,
336 status,
337 categories: Vec::new(),
338 }
339 }
340
341 fn add_category(&mut self, name: String) {
342 self.categories.push((name, Vec::new()));
343 }
344
345 fn add_issue(&mut self, completed: bool, description: String) {
346 if let Some(last) = self.categories.last_mut() {
347 last.1.push(RoadmapChecklistItem {
348 description,
349 completed,
350 });
351 }
352 }
354
355 fn build(self) -> RoadmapVersion {
356 let total: usize = self.categories.iter().map(|(_, items)| items.len()).sum();
357 let done: usize = self
358 .categories
359 .iter()
360 .flat_map(|(_, items)| items)
361 .filter(|i| i.completed)
362 .count();
363
364 RoadmapVersion {
365 version: self.version,
366 status: self.status,
367 done,
368 total,
369 categories: self.categories,
370 }
371 }
372}
373
374fn parse_version_header(s: &str) -> Result<(String, String), String> {
380 let inner = s[3..].trim(); if !inner.starts_with('[') {
383 return Err("版本号应以 `[` 开头".into());
384 }
385 let close_bracket = inner.find(']').ok_or("缺少 `]`".to_string())?;
386 let version = inner[1..close_bracket].to_string();
387 if version.is_empty() {
388 return Err("版本号为空".into());
389 }
390 let version = version.strip_prefix('v').unwrap_or(&version).to_string();
392 let rest = inner[close_bracket + 1..].trim();
393 let status = if let Some(pos) = rest.find('—') {
395 rest[pos + 3..].trim().to_string()
396 } else if let Some(pos) = rest.find('-') {
397 rest[pos + 1..].trim().to_string()
398 } else {
399 rest.to_string()
400 };
401
402 Ok((version, status))
403}
404
405#[cfg(test)]
410mod tests {
411 use super::*;
412
413 #[test]
416 fn test_roadmap_error_display() {
417 let err = RoadmapError::Io(std::io::Error::new(
418 std::io::ErrorKind::NotFound,
419 "not found",
420 ));
421 assert!(err.to_string().contains("读取 ROADMAP 失败"));
422
423 let err = RoadmapError::Parse("bad format".into());
424 assert!(err.to_string().contains("解析 ROADMAP 失败"));
425 }
426
427 fn sample_roadmap() -> &'static str {
428 "\
429# ROADMAP
430
431> 格式说明。
432
433## [0.1.5] — 待实施
434
435### Added
436- [ ] item one
437- [ ] item two
438
439### Fixed
440- [ ] bug fix
441
442## [0.1.4] — 已发布
443
444### Added
445- [x] changelog module
446- [x] CI workflow
447"
448 }
449
450 fn mixed_roadmap() -> &'static str {
451 "\
452# ROADMAP
453
454## [0.2.0] — 待实施
455
456### Added
457- [ ] big feature
458
459## [0.1.0] — 已发布
460
461### Added
462- [x] initial
463- [x] second
464- [ ] third
465"
466 }
467
468 #[test]
471 fn test_version_percent() {
472 let v = RoadmapVersion {
473 version: "0.1.0".into(),
474 status: "test".into(),
475 done: 2,
476 total: 4,
477 categories: Vec::new(),
478 };
479 assert!((v.percent() - 50.0).abs() < f64::EPSILON);
480 }
481
482 #[test]
483 fn test_version_percent_empty() {
484 let v = RoadmapVersion {
485 version: "0.1.0".into(),
486 status: "test".into(),
487 done: 0,
488 total: 0,
489 categories: Vec::new(),
490 };
491 assert!((v.percent() - 100.0).abs() < f64::EPSILON);
492 }
493
494 #[test]
495 fn test_version_percent_all_done() {
496 let v = RoadmapVersion {
497 version: "0.1.0".into(),
498 status: "test".into(),
499 done: 3,
500 total: 3,
501 categories: Vec::new(),
502 };
503 assert!((v.percent() - 100.0).abs() < f64::EPSILON);
504 }
505
506 #[test]
507 fn test_version_percent_none_done() {
508 let v = RoadmapVersion {
509 version: "0.1.0".into(),
510 status: "test".into(),
511 done: 0,
512 total: 5,
513 categories: Vec::new(),
514 };
515 assert!((v.percent() - 0.0).abs() < f64::EPSILON);
516 }
517
518 #[test]
521 fn test_progress_percent() {
522 let p = RoadmapProgress {
523 total: 4,
524 completed: 2,
525 };
526 assert!((p.percent() - 50.0).abs() < f64::EPSILON);
527 }
528
529 #[test]
530 fn test_progress_empty() {
531 let p = RoadmapProgress {
532 total: 0,
533 completed: 0,
534 };
535 assert!((p.percent() - 100.0).abs() < f64::EPSILON);
536 }
537
538 #[test]
541 fn test_from_str_empty() {
542 let r = Roadmap::from_str("");
543 assert!(r.is_err());
544 assert!(r.unwrap_err().to_string().contains("为空"));
545 }
546
547 #[test]
548 fn test_from_str_no_header() {
549 let r = Roadmap::from_str("## [0.1.0] — test\n");
550 assert!(r.is_err());
551 assert!(r.unwrap_err().to_string().contains("首行"));
552 }
553
554 #[test]
555 fn test_from_str_valid() {
556 let r = Roadmap::from_str(sample_roadmap()).unwrap();
557 let versions = r.versions();
558 assert_eq!(versions.len(), 2);
559
560 let v1 = &versions[0];
562 assert_eq!(v1.version, "0.1.5");
563 assert_eq!(v1.status, "待实施");
564 assert_eq!(v1.categories.len(), 2);
565 assert_eq!(v1.categories[0].0, "Added");
566 assert_eq!(v1.categories[0].1.len(), 2);
567 assert_eq!(v1.categories[1].0, "Fixed");
568 assert_eq!(v1.categories[1].1.len(), 1);
569
570 assert!(!v1.categories[0].1[0].completed);
572 assert!(!v1.categories[0].1[1].completed);
573 assert!(!v1.categories[1].1[0].completed);
574
575 assert_eq!(v1.total, 3);
577 assert_eq!(v1.done, 0);
578
579 let v2 = &versions[1];
581 assert_eq!(v2.version, "0.1.4");
582 assert_eq!(v2.status, "已发布");
583 assert_eq!(v2.total, 2);
584 assert_eq!(v2.done, 2);
585
586 assert_eq!(r.total_done(), 2);
588 assert_eq!(r.total_all(), 5);
589 }
590
591 #[test]
592 fn test_from_str_mixed() {
593 let r = Roadmap::from_str(mixed_roadmap()).unwrap();
594 let versions = r.versions();
595 assert_eq!(versions.len(), 2);
596
597 let v1 = &versions[0];
598 assert_eq!(v1.version, "0.2.0");
599 assert_eq!(v1.status, "待实施");
600 assert_eq!(v1.total, 1);
601 assert_eq!(v1.done, 0);
602
603 let v2 = &versions[1];
604 assert_eq!(v2.version, "0.1.0");
605 assert_eq!(v2.status, "已发布");
606 assert_eq!(v2.total, 3);
607 assert_eq!(v2.done, 2);
608
609 assert_eq!(r.total_done(), 2);
610 assert_eq!(r.total_all(), 4);
611 }
612
613 #[test]
614 fn test_from_path() {
615 let d = tempfile::tempdir().unwrap();
616 let path = d.path().join("ROADMAP.md");
617 std::fs::write(&path, sample_roadmap()).unwrap();
618 let r = Roadmap::from_path(&path).unwrap();
619 assert_eq!(r.versions().len(), 2);
620 assert_eq!(r.versions()[0].version, "0.1.5");
621 }
622
623 #[test]
624 fn test_from_path_not_found() {
625 let r = Roadmap::from_path(Path::new("/nonexistent/ROADMAP.md"));
626 assert!(r.is_err());
627 }
628
629 #[test]
632 fn test_validate_valid() {
633 let r = Roadmap::from_str(sample_roadmap()).unwrap();
634 let issues = r.validate("test-scope");
635 let msgs: Vec<&str> = issues.iter().map(|i| i.message.as_str()).collect();
636 assert!(issues.is_empty(), "预期无验证问题,发现: {:?}", msgs);
637 }
638
639 #[test]
640 fn test_validate_v_prefix_allowed() {
641 let s = "\
643# ROADMAP
644
645## [v0.1.0] — test
646
647### Added
648- [ ] something
649";
650 let r = Roadmap::from_str(s).unwrap();
651 assert_eq!(r.versions()[0].version, "0.1.0");
653 let issues = r.validate("scope");
655 let v_issues: Vec<_> = issues
656 .iter()
657 .filter(|i| i.message.contains("v 前缀"))
658 .collect();
659 assert!(
660 v_issues.is_empty(),
661 "不应有 v 前缀相关验证问题: {:?}",
662 v_issues
663 );
664 }
665
666 #[test]
667 fn test_validate_invalid_version() {
668 let s = "\
669# ROADMAP
670
671## [abc] — 待实施
672
673### Added
674- [ ] something
675";
676 let r = Roadmap::from_str(s).unwrap();
677 let issues = r.validate("scope");
678 assert!(!issues.is_empty());
679 assert!(issues.iter().any(|i| i.message.contains("版本号格式异常")));
680 }
681
682 #[test]
683 fn test_validate_category_case() {
684 let s = "\
685# ROADMAP
686
687## [0.1.0] — test
688
689### added
690- [ ] something
691";
692 let r = Roadmap::from_str(s).unwrap();
693 let issues = r.validate("scope");
694 assert!(!issues.is_empty());
695 assert!(issues.iter().any(|i| i.message.contains("大小写不标准")));
696 }
697
698 #[test]
699 fn test_validate_line_numbers() {
700 let s = "\
702# ROADMAP
703
704## [abc] — test
705
706### Added
707- [ ] ok
708";
709 let r = Roadmap::from_str(s).unwrap();
710 let issues = r.validate("scope");
711 assert!(!issues.is_empty());
712 assert_eq!(issues[0].line, 3);
714 assert_eq!(issues[0].scope, "scope");
715 }
716
717 #[test]
720 fn test_parse_version_header_normal() {
721 let (v, s) = parse_version_header("## [0.1.5] — 待实施").unwrap();
722 assert_eq!(v, "0.1.5");
723 assert_eq!(s, "待实施");
724 }
725
726 #[test]
727 fn test_parse_version_header_no_bracket() {
728 let r = parse_version_header("## 0.1.5 — test");
729 assert!(r.is_err());
730 }
731
732 #[test]
733 fn test_parse_version_header_empty_version() {
734 let r = parse_version_header("## [] — test");
735 assert!(r.is_err());
736 }
737
738 #[test]
739 fn test_parse_version_header_hyphen() {
740 let (v, s) = parse_version_header("## [0.1.0] - released").unwrap();
741 assert_eq!(v, "0.1.0");
742 assert_eq!(s, "released");
743 }
744
745 #[test]
746 fn test_parse_version_header_no_status() {
747 let (v, s) = parse_version_header("## [0.1.0]").unwrap();
748 assert_eq!(v, "0.1.0");
749 assert_eq!(s, "");
750 }
751
752 #[test]
755 fn test_from_str_parse_version_header_error() {
756 let s = "\
758# ROADMAP
759
760## 无方括号版本
761";
762 let r = Roadmap::from_str(s);
763 assert!(r.is_err());
764 assert!(r.unwrap_err().to_string().contains("版本标题格式无效"));
765 }
766
767 #[test]
768 fn test_from_str_no_versions() {
769 let s = "\
771# ROADMAP
772
773> 只有描述,没有版本。
774";
775 let r = Roadmap::from_str(s);
776 assert!(r.is_err());
777 assert!(r.unwrap_err().to_string().contains("未找到任何版本区块"));
778 }
779
780 #[test]
781 fn test_validate_invalid_checkbox() {
782 let s = "\
784# ROADMAP
785
786## [0.1.0] — test
787
788### Added
789- [X] uppercase
790";
791 let r = Roadmap::from_str(s).unwrap();
792 let issues = r.validate("scope");
793 let checkbox_issues: Vec<_> = issues
794 .iter()
795 .filter(|i| i.message.contains("checkbox 格式异常"))
796 .collect();
797 assert_eq!(checkbox_issues.len(), 1);
798 assert!(checkbox_issues[0].message.contains("- [X]"));
799 }
800
801 #[test]
802 fn test_validate_unknown_category() {
803 let s = "\
805# ROADMAP
806
807## [0.1.0] — test
808
809### CustomSection
810- [ ] something
811";
812 let r = Roadmap::from_str(s).unwrap();
813 let issues = r.validate("scope");
815 assert!(!issues.iter().any(|i| i.message.contains("大小写不标准")));
817 }
818}