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.starts_with("# 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_header_with_suffix() {
556 let s = "\
557# ROADMAP — cli
558
559## [0.1.0] — test
560
561### Added
562- [ ] something
563";
564 let r = Roadmap::from_str(s).unwrap();
565 assert_eq!(r.versions().len(), 1);
566 assert_eq!(r.versions()[0].version, "0.1.0");
567 }
568
569 #[test]
570 fn test_from_str_valid() {
571 let r = Roadmap::from_str(sample_roadmap()).unwrap();
572 let versions = r.versions();
573 assert_eq!(versions.len(), 2);
574
575 let v1 = &versions[0];
577 assert_eq!(v1.version, "0.1.5");
578 assert_eq!(v1.status, "待实施");
579 assert_eq!(v1.categories.len(), 2);
580 assert_eq!(v1.categories[0].0, "Added");
581 assert_eq!(v1.categories[0].1.len(), 2);
582 assert_eq!(v1.categories[1].0, "Fixed");
583 assert_eq!(v1.categories[1].1.len(), 1);
584
585 assert!(!v1.categories[0].1[0].completed);
587 assert!(!v1.categories[0].1[1].completed);
588 assert!(!v1.categories[1].1[0].completed);
589
590 assert_eq!(v1.total, 3);
592 assert_eq!(v1.done, 0);
593
594 let v2 = &versions[1];
596 assert_eq!(v2.version, "0.1.4");
597 assert_eq!(v2.status, "已发布");
598 assert_eq!(v2.total, 2);
599 assert_eq!(v2.done, 2);
600
601 assert_eq!(r.total_done(), 2);
603 assert_eq!(r.total_all(), 5);
604 }
605
606 #[test]
607 fn test_from_str_mixed() {
608 let r = Roadmap::from_str(mixed_roadmap()).unwrap();
609 let versions = r.versions();
610 assert_eq!(versions.len(), 2);
611
612 let v1 = &versions[0];
613 assert_eq!(v1.version, "0.2.0");
614 assert_eq!(v1.status, "待实施");
615 assert_eq!(v1.total, 1);
616 assert_eq!(v1.done, 0);
617
618 let v2 = &versions[1];
619 assert_eq!(v2.version, "0.1.0");
620 assert_eq!(v2.status, "已发布");
621 assert_eq!(v2.total, 3);
622 assert_eq!(v2.done, 2);
623
624 assert_eq!(r.total_done(), 2);
625 assert_eq!(r.total_all(), 4);
626 }
627
628 #[test]
629 fn test_from_path() {
630 let d = tempfile::tempdir().unwrap();
631 let path = d.path().join("ROADMAP.md");
632 std::fs::write(&path, sample_roadmap()).unwrap();
633 let r = Roadmap::from_path(&path).unwrap();
634 assert_eq!(r.versions().len(), 2);
635 assert_eq!(r.versions()[0].version, "0.1.5");
636 }
637
638 #[test]
639 fn test_from_path_not_found() {
640 let r = Roadmap::from_path(Path::new("/nonexistent/ROADMAP.md"));
641 assert!(r.is_err());
642 }
643
644 #[test]
647 fn test_validate_valid() {
648 let r = Roadmap::from_str(sample_roadmap()).unwrap();
649 let issues = r.validate("test-scope");
650 let msgs: Vec<&str> = issues.iter().map(|i| i.message.as_str()).collect();
651 assert!(issues.is_empty(), "预期无验证问题,发现: {:?}", msgs);
652 }
653
654 #[test]
655 fn test_validate_v_prefix_allowed() {
656 let s = "\
658# ROADMAP
659
660## [v0.1.0] — test
661
662### Added
663- [ ] something
664";
665 let r = Roadmap::from_str(s).unwrap();
666 assert_eq!(r.versions()[0].version, "0.1.0");
668 let issues = r.validate("scope");
670 let v_issues: Vec<_> = issues
671 .iter()
672 .filter(|i| i.message.contains("v 前缀"))
673 .collect();
674 assert!(
675 v_issues.is_empty(),
676 "不应有 v 前缀相关验证问题: {:?}",
677 v_issues
678 );
679 }
680
681 #[test]
682 fn test_validate_invalid_version() {
683 let s = "\
684# ROADMAP
685
686## [abc] — 待实施
687
688### Added
689- [ ] something
690";
691 let r = Roadmap::from_str(s).unwrap();
692 let issues = r.validate("scope");
693 assert!(!issues.is_empty());
694 assert!(issues.iter().any(|i| i.message.contains("版本号格式异常")));
695 }
696
697 #[test]
698 fn test_validate_category_case() {
699 let s = "\
700# ROADMAP
701
702## [0.1.0] — test
703
704### added
705- [ ] something
706";
707 let r = Roadmap::from_str(s).unwrap();
708 let issues = r.validate("scope");
709 assert!(!issues.is_empty());
710 assert!(issues.iter().any(|i| i.message.contains("大小写不标准")));
711 }
712
713 #[test]
714 fn test_validate_line_numbers() {
715 let s = "\
717# ROADMAP
718
719## [abc] — test
720
721### Added
722- [ ] ok
723";
724 let r = Roadmap::from_str(s).unwrap();
725 let issues = r.validate("scope");
726 assert!(!issues.is_empty());
727 assert_eq!(issues[0].line, 3);
729 assert_eq!(issues[0].scope, "scope");
730 }
731
732 #[test]
735 fn test_parse_version_header_normal() {
736 let (v, s) = parse_version_header("## [0.1.5] — 待实施").unwrap();
737 assert_eq!(v, "0.1.5");
738 assert_eq!(s, "待实施");
739 }
740
741 #[test]
742 fn test_parse_version_header_no_bracket() {
743 let r = parse_version_header("## 0.1.5 — test");
744 assert!(r.is_err());
745 }
746
747 #[test]
748 fn test_parse_version_header_empty_version() {
749 let r = parse_version_header("## [] — test");
750 assert!(r.is_err());
751 }
752
753 #[test]
754 fn test_parse_version_header_hyphen() {
755 let (v, s) = parse_version_header("## [0.1.0] - released").unwrap();
756 assert_eq!(v, "0.1.0");
757 assert_eq!(s, "released");
758 }
759
760 #[test]
761 fn test_parse_version_header_no_status() {
762 let (v, s) = parse_version_header("## [0.1.0]").unwrap();
763 assert_eq!(v, "0.1.0");
764 assert_eq!(s, "");
765 }
766
767 #[test]
770 fn test_from_str_parse_version_header_error() {
771 let s = "\
773# ROADMAP
774
775## 无方括号版本
776";
777 let r = Roadmap::from_str(s);
778 assert!(r.is_err());
779 assert!(r.unwrap_err().to_string().contains("版本标题格式无效"));
780 }
781
782 #[test]
783 fn test_from_str_no_versions() {
784 let s = "\
786# ROADMAP
787
788> 只有描述,没有版本。
789";
790 let r = Roadmap::from_str(s);
791 assert!(r.is_err());
792 assert!(r.unwrap_err().to_string().contains("未找到任何版本区块"));
793 }
794
795 #[test]
796 fn test_validate_invalid_checkbox() {
797 let s = "\
799# ROADMAP
800
801## [0.1.0] — test
802
803### Added
804- [X] uppercase
805";
806 let r = Roadmap::from_str(s).unwrap();
807 let issues = r.validate("scope");
808 let checkbox_issues: Vec<_> = issues
809 .iter()
810 .filter(|i| i.message.contains("checkbox 格式异常"))
811 .collect();
812 assert_eq!(checkbox_issues.len(), 1);
813 assert!(checkbox_issues[0].message.contains("- [X]"));
814 }
815
816 #[test]
817 fn test_validate_unknown_category() {
818 let s = "\
820# ROADMAP
821
822## [0.1.0] — test
823
824### CustomSection
825- [ ] something
826";
827 let r = Roadmap::from_str(s).unwrap();
828 let issues = r.validate("scope");
830 assert!(!issues.iter().any(|i| i.message.contains("大小写不标准")));
832 }
833}