use std::path::Path;
#[derive(Debug)]
pub enum RoadmapError {
Io(std::io::Error),
Parse(String),
}
impl std::fmt::Display for RoadmapError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(e) => write!(f, "读取 ROADMAP 失败: {}", e),
Self::Parse(e) => write!(f, "解析 ROADMAP 失败: {}", e),
}
}
}
impl std::error::Error for RoadmapError {}
impl From<std::io::Error> for RoadmapError {
fn from(e: std::io::Error) -> Self {
Self::Io(e)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct RoadmapChecklistItem {
pub description: String,
pub completed: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RoadmapProgress {
pub total: usize,
pub completed: usize,
}
impl RoadmapProgress {
pub fn percent(&self) -> f64 {
if self.total == 0 {
return 100.0;
}
(self.completed as f64 / self.total as f64) * 100.0
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct RoadmapVersion {
pub version: String,
pub status: String,
pub done: usize,
pub total: usize,
pub categories: Vec<(String, Vec<RoadmapChecklistItem>)>,
}
impl RoadmapVersion {
pub fn percent(&self) -> f64 {
if self.total == 0 {
return 100.0;
}
(self.done as f64 / self.total as f64) * 100.0
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct RoadmapIssue {
pub line: usize,
pub scope: String,
pub message: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Roadmap {
#[allow(dead_code)]
raw: String,
versions: Vec<RoadmapVersion>,
}
impl Roadmap {
pub fn from_path(path: &Path) -> Result<Self, RoadmapError> {
let raw = std::fs::read_to_string(path)?;
Self::from_str(&raw)
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Result<Self, RoadmapError> {
let lines: Vec<&str> = s.lines().collect();
if lines.is_empty() {
return Err(RoadmapError::Parse("ROADMAP 为空".into()));
}
let first = lines[0].trim();
if !first.starts_with("# ROADMAP") {
return Err(RoadmapError::Parse(format!(
"首行应包含 `# ROADMAP`,发现: {}",
first
)));
}
let mut versions: Vec<RoadmapVersion> = Vec::new();
let mut current_version: Option<RoadmapVersionBuilder> = None;
for line in &lines[1..] {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('>') {
continue;
}
if trimmed.starts_with("## ") {
if let Some(builder) = current_version.take() {
versions.push(builder.build());
}
match parse_version_header(trimmed) {
Ok((version, status)) => {
current_version = Some(RoadmapVersionBuilder::new(version, status));
}
Err(e) => {
return Err(RoadmapError::Parse(format!(
"版本标题格式无效: {} — {}",
trimmed, e
)));
}
}
} else if let Some(ref mut builder) = current_version {
if trimmed.starts_with("### ") {
let category = trimmed
.strip_prefix("### ")
.unwrap_or("")
.trim()
.to_string();
builder.add_category(category);
} else if trimmed.starts_with("- [") && trimmed.len() > 5 {
let completed = trimmed.as_bytes()[3] == b'x';
let description = trimmed[5..].trim().to_string();
builder.add_issue(completed, description);
}
}
}
if let Some(builder) = current_version.take() {
versions.push(builder.build());
}
if versions.is_empty() {
return Err(RoadmapError::Parse(
"未找到任何版本区块 (`## [x.y.z]`)".into(),
));
}
Ok(Self {
raw: s.to_string(),
versions,
})
}
}
impl Roadmap {
pub fn versions(&self) -> &[RoadmapVersion] {
&self.versions
}
pub fn total_done(&self) -> usize {
self.versions.iter().map(|v| v.done).sum()
}
pub fn total_all(&self) -> usize {
self.versions.iter().map(|v| v.total).sum()
}
}
impl Roadmap {
pub fn validate(&self, scope: &str) -> Vec<RoadmapIssue> {
let mut issues = Vec::new();
let lines: Vec<&str> = self.raw.lines().collect();
for (line_number, line) in lines.iter().enumerate() {
let trimmed = line.trim();
let line_number = line_number + 1;
if trimmed.starts_with("## [")
&& let Some(end) = trimmed.find(']')
{
let raw_version = &trimmed[4..end];
let clean = raw_version.strip_prefix('v').unwrap_or(raw_version);
let parts: Vec<&str> = clean.split('.').collect();
if parts.len() != 3
|| parts
.iter()
.any(|p| p.is_empty() || !p.chars().all(|c| c.is_ascii_digit()))
{
issues.push(RoadmapIssue {
line: line_number,
scope: scope.to_string(),
message: format!("版本号格式异常(期待 `X.Y.Z`): `{}`", raw_version),
});
}
}
if trimmed.starts_with("### ") {
let category = trimmed.strip_prefix("### ").unwrap_or("").trim();
let expected = category_expected_case(category);
if category != expected {
issues.push(RoadmapIssue {
line: line_number,
scope: scope.to_string(),
message: format!(
"分类标题大小写不标准: `### {}`,标准写法为 `### {}`",
category, expected
),
});
}
}
if trimmed.starts_with("- [") && trimmed.len() > 5 {
let third = trimmed.as_bytes().get(3);
if third != Some(&b' ') && third != Some(&b'x') {
issues.push(RoadmapIssue {
line: line_number,
scope: scope.to_string(),
message: format!(
"checkbox 格式异常: `{}`,标准为 `- [ ]` 或 `- [x]`",
trimmed
),
});
}
}
}
issues
}
}
fn category_expected_case(s: &str) -> String {
match s {
"added" | "Added" => "Added".into(),
"changed" | "Changed" => "Changed".into(),
"deprecated" | "Deprecated" => "Deprecated".into(),
"removed" | "Removed" => "Removed".into(),
"fixed" | "Fixed" => "Fixed".into(),
"security" | "Security" => "Security".into(),
_ => s.to_string(),
}
}
struct RoadmapVersionBuilder {
version: String,
status: String,
categories: Vec<(String, Vec<RoadmapChecklistItem>)>,
}
impl RoadmapVersionBuilder {
fn new(version: String, status: String) -> Self {
Self {
version,
status,
categories: Vec::new(),
}
}
fn add_category(&mut self, name: String) {
self.categories.push((name, Vec::new()));
}
fn add_issue(&mut self, completed: bool, description: String) {
if let Some(last) = self.categories.last_mut() {
last.1.push(RoadmapChecklistItem {
description,
completed,
});
}
}
fn build(self) -> RoadmapVersion {
let total: usize = self.categories.iter().map(|(_, items)| items.len()).sum();
let done: usize = self
.categories
.iter()
.flat_map(|(_, items)| items)
.filter(|i| i.completed)
.count();
RoadmapVersion {
version: self.version,
status: self.status,
done,
total,
categories: self.categories,
}
}
}
fn parse_version_header(s: &str) -> Result<(String, String), String> {
let inner = s[3..].trim(); if !inner.starts_with('[') {
return Err("版本号应以 `[` 开头".into());
}
let close_bracket = inner.find(']').ok_or("缺少 `]`".to_string())?;
let version = inner[1..close_bracket].to_string();
if version.is_empty() {
return Err("版本号为空".into());
}
let version = version.strip_prefix('v').unwrap_or(&version).to_string();
let rest = inner[close_bracket + 1..].trim();
let status = if let Some(pos) = rest.find('—') {
rest[pos + 3..].trim().to_string()
} else if let Some(pos) = rest.find('-') {
rest[pos + 1..].trim().to_string()
} else {
rest.to_string()
};
Ok((version, status))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_roadmap_error_display() {
let err = RoadmapError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"not found",
));
assert!(err.to_string().contains("读取 ROADMAP 失败"));
let err = RoadmapError::Parse("bad format".into());
assert!(err.to_string().contains("解析 ROADMAP 失败"));
}
fn sample_roadmap() -> &'static str {
"\
# ROADMAP
> 格式说明。
## [0.1.5] — 待实施
### Added
- [ ] item one
- [ ] item two
### Fixed
- [ ] bug fix
## [0.1.4] — 已发布
### Added
- [x] changelog module
- [x] CI workflow
"
}
fn mixed_roadmap() -> &'static str {
"\
# ROADMAP
## [0.2.0] — 待实施
### Added
- [ ] big feature
## [0.1.0] — 已发布
### Added
- [x] initial
- [x] second
- [ ] third
"
}
#[test]
fn test_version_percent() {
let v = RoadmapVersion {
version: "0.1.0".into(),
status: "test".into(),
done: 2,
total: 4,
categories: Vec::new(),
};
assert!((v.percent() - 50.0).abs() < f64::EPSILON);
}
#[test]
fn test_version_percent_empty() {
let v = RoadmapVersion {
version: "0.1.0".into(),
status: "test".into(),
done: 0,
total: 0,
categories: Vec::new(),
};
assert!((v.percent() - 100.0).abs() < f64::EPSILON);
}
#[test]
fn test_version_percent_all_done() {
let v = RoadmapVersion {
version: "0.1.0".into(),
status: "test".into(),
done: 3,
total: 3,
categories: Vec::new(),
};
assert!((v.percent() - 100.0).abs() < f64::EPSILON);
}
#[test]
fn test_version_percent_none_done() {
let v = RoadmapVersion {
version: "0.1.0".into(),
status: "test".into(),
done: 0,
total: 5,
categories: Vec::new(),
};
assert!((v.percent() - 0.0).abs() < f64::EPSILON);
}
#[test]
fn test_progress_percent() {
let p = RoadmapProgress {
total: 4,
completed: 2,
};
assert!((p.percent() - 50.0).abs() < f64::EPSILON);
}
#[test]
fn test_progress_empty() {
let p = RoadmapProgress {
total: 0,
completed: 0,
};
assert!((p.percent() - 100.0).abs() < f64::EPSILON);
}
#[test]
fn test_from_str_empty() {
let r = Roadmap::from_str("");
assert!(r.is_err());
assert!(r.unwrap_err().to_string().contains("为空"));
}
#[test]
fn test_from_str_no_header() {
let r = Roadmap::from_str("## [0.1.0] — test\n");
assert!(r.is_err());
assert!(r.unwrap_err().to_string().contains("首行"));
}
#[test]
fn test_from_str_header_with_suffix() {
let s = "\
# ROADMAP — cli
## [0.1.0] — test
### Added
- [ ] something
";
let r = Roadmap::from_str(s).unwrap();
assert_eq!(r.versions().len(), 1);
assert_eq!(r.versions()[0].version, "0.1.0");
}
#[test]
fn test_from_str_valid() {
let r = Roadmap::from_str(sample_roadmap()).unwrap();
let versions = r.versions();
assert_eq!(versions.len(), 2);
let v1 = &versions[0];
assert_eq!(v1.version, "0.1.5");
assert_eq!(v1.status, "待实施");
assert_eq!(v1.categories.len(), 2);
assert_eq!(v1.categories[0].0, "Added");
assert_eq!(v1.categories[0].1.len(), 2);
assert_eq!(v1.categories[1].0, "Fixed");
assert_eq!(v1.categories[1].1.len(), 1);
assert!(!v1.categories[0].1[0].completed);
assert!(!v1.categories[0].1[1].completed);
assert!(!v1.categories[1].1[0].completed);
assert_eq!(v1.total, 3);
assert_eq!(v1.done, 0);
let v2 = &versions[1];
assert_eq!(v2.version, "0.1.4");
assert_eq!(v2.status, "已发布");
assert_eq!(v2.total, 2);
assert_eq!(v2.done, 2);
assert_eq!(r.total_done(), 2);
assert_eq!(r.total_all(), 5);
}
#[test]
fn test_from_str_mixed() {
let r = Roadmap::from_str(mixed_roadmap()).unwrap();
let versions = r.versions();
assert_eq!(versions.len(), 2);
let v1 = &versions[0];
assert_eq!(v1.version, "0.2.0");
assert_eq!(v1.status, "待实施");
assert_eq!(v1.total, 1);
assert_eq!(v1.done, 0);
let v2 = &versions[1];
assert_eq!(v2.version, "0.1.0");
assert_eq!(v2.status, "已发布");
assert_eq!(v2.total, 3);
assert_eq!(v2.done, 2);
assert_eq!(r.total_done(), 2);
assert_eq!(r.total_all(), 4);
}
#[test]
fn test_from_path() {
let d = tempfile::tempdir().unwrap();
let path = d.path().join("ROADMAP.md");
std::fs::write(&path, sample_roadmap()).unwrap();
let r = Roadmap::from_path(&path).unwrap();
assert_eq!(r.versions().len(), 2);
assert_eq!(r.versions()[0].version, "0.1.5");
}
#[test]
fn test_from_path_not_found() {
let r = Roadmap::from_path(Path::new("/nonexistent/ROADMAP.md"));
assert!(r.is_err());
}
#[test]
fn test_validate_valid() {
let r = Roadmap::from_str(sample_roadmap()).unwrap();
let issues = r.validate("test-scope");
let msgs: Vec<&str> = issues.iter().map(|i| i.message.as_str()).collect();
assert!(issues.is_empty(), "预期无验证问题,发现: {:?}", msgs);
}
#[test]
fn test_validate_v_prefix_allowed() {
let s = "\
# ROADMAP
## [v0.1.0] — test
### Added
- [ ] something
";
let r = Roadmap::from_str(s).unwrap();
assert_eq!(r.versions()[0].version, "0.1.0");
let issues = r.validate("scope");
let v_issues: Vec<_> = issues
.iter()
.filter(|i| i.message.contains("v 前缀"))
.collect();
assert!(
v_issues.is_empty(),
"不应有 v 前缀相关验证问题: {:?}",
v_issues
);
}
#[test]
fn test_validate_invalid_version() {
let s = "\
# ROADMAP
## [abc] — 待实施
### Added
- [ ] something
";
let r = Roadmap::from_str(s).unwrap();
let issues = r.validate("scope");
assert!(!issues.is_empty());
assert!(issues.iter().any(|i| i.message.contains("版本号格式异常")));
}
#[test]
fn test_validate_category_case() {
let s = "\
# ROADMAP
## [0.1.0] — test
### added
- [ ] something
";
let r = Roadmap::from_str(s).unwrap();
let issues = r.validate("scope");
assert!(!issues.is_empty());
assert!(issues.iter().any(|i| i.message.contains("大小写不标准")));
}
#[test]
fn test_validate_line_numbers() {
let s = "\
# ROADMAP
## [abc] — test
### Added
- [ ] ok
";
let r = Roadmap::from_str(s).unwrap();
let issues = r.validate("scope");
assert!(!issues.is_empty());
assert_eq!(issues[0].line, 3);
assert_eq!(issues[0].scope, "scope");
}
#[test]
fn test_parse_version_header_normal() {
let (v, s) = parse_version_header("## [0.1.5] — 待实施").unwrap();
assert_eq!(v, "0.1.5");
assert_eq!(s, "待实施");
}
#[test]
fn test_parse_version_header_no_bracket() {
let r = parse_version_header("## 0.1.5 — test");
assert!(r.is_err());
}
#[test]
fn test_parse_version_header_empty_version() {
let r = parse_version_header("## [] — test");
assert!(r.is_err());
}
#[test]
fn test_parse_version_header_hyphen() {
let (v, s) = parse_version_header("## [0.1.0] - released").unwrap();
assert_eq!(v, "0.1.0");
assert_eq!(s, "released");
}
#[test]
fn test_parse_version_header_no_status() {
let (v, s) = parse_version_header("## [0.1.0]").unwrap();
assert_eq!(v, "0.1.0");
assert_eq!(s, "");
}
#[test]
fn test_from_str_parse_version_header_error() {
let s = "\
# ROADMAP
## 无方括号版本
";
let r = Roadmap::from_str(s);
assert!(r.is_err());
assert!(r.unwrap_err().to_string().contains("版本标题格式无效"));
}
#[test]
fn test_from_str_no_versions() {
let s = "\
# ROADMAP
> 只有描述,没有版本。
";
let r = Roadmap::from_str(s);
assert!(r.is_err());
assert!(r.unwrap_err().to_string().contains("未找到任何版本区块"));
}
#[test]
fn test_validate_invalid_checkbox() {
let s = "\
# ROADMAP
## [0.1.0] — test
### Added
- [X] uppercase
";
let r = Roadmap::from_str(s).unwrap();
let issues = r.validate("scope");
let checkbox_issues: Vec<_> = issues
.iter()
.filter(|i| i.message.contains("checkbox 格式异常"))
.collect();
assert_eq!(checkbox_issues.len(), 1);
assert!(checkbox_issues[0].message.contains("- [X]"));
}
#[test]
fn test_validate_unknown_category() {
let s = "\
# ROADMAP
## [0.1.0] — test
### CustomSection
- [ ] something
";
let r = Roadmap::from_str(s).unwrap();
let issues = r.validate("scope");
assert!(!issues.iter().any(|i| i.message.contains("大小写不标准")));
}
}