1mod actionable;
2
3pub use actionable::{check_actionable, check_tree_paths, extract_tree_paths};
4
5use regex::Regex;
6use std::fmt;
7use std::path::{Path, PathBuf};
8use std::sync::LazyLock;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Issue {
13 pub file: PathBuf,
14 pub line: Option<usize>,
15 pub message: String,
16 pub warning: bool,
17}
18
19impl fmt::Display for Issue {
20 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
21 let severity = if self.warning { "warning" } else { "error" };
22 match self.line {
23 Some(n) => write!(f, "{}:{}:{}: {}", self.file.display(), n, severity, self.message),
24 None => write!(f, "{}:{}: {}", self.file.display(), severity, self.message),
25 }
26 }
27}
28
29static IMPERATIVE_RE: LazyLock<Regex> = LazyLock::new(|| {
30 Regex::new(r"(?i)\b(use|add|create|run|do|don't|never|must|should|avoid|prefer|ensure|keep|set)\b")
31 .expect("imperative regex")
32});
33
34static LOCAL_PATH_RE: LazyLock<Regex> = LazyLock::new(|| {
35 Regex::new(r"(~/|/home/\w+/|/Users/\w+/|/tmp/|[A-Z]:\\Users\\)")
36 .expect("local path regex")
37});
38
39pub fn validate_actionable(file: &Path, content: &str) -> Vec<Issue> {
44 let mut issues = Vec::new();
45 for (i, line) in content.lines().enumerate() {
46 let trimmed = line.trim();
47 if trimmed.is_empty()
49 || trimmed.starts_with('#')
50 || trimmed.starts_with("//")
51 || trimmed.starts_with("<!--")
52 || trimmed.starts_with("```")
53 {
54 continue;
55 }
56 if !IMPERATIVE_RE.is_match(trimmed) {
58 issues.push(Issue {
59 file: file.to_path_buf(),
60 line: Some(i + 1),
61 message: format!("line lacks an imperative verb: {}", truncate(trimmed, 60)),
62 warning: true,
63 });
64 }
65 }
66 issues
67}
68
69pub fn validate_no_local_paths(file: &Path, content: &str) -> Vec<Issue> {
71 let mut issues = Vec::new();
72 for (i, line) in content.lines().enumerate() {
73 if LOCAL_PATH_RE.is_match(line) {
74 issues.push(Issue {
75 file: file.to_path_buf(),
76 line: Some(i + 1),
77 message: format!(
78 "machine-local path detected: {}",
79 truncate(line.trim(), 60)
80 ),
81 warning: false,
82 });
83 }
84 }
85 issues
86}
87
88pub fn validate_line_budget(file: &Path, content: &str, budget: usize) -> Option<Issue> {
90 let count = content.lines().count();
91 if count > budget {
92 Some(Issue {
93 file: file.to_path_buf(),
94 line: None,
95 message: format!("file has {} lines, exceeding budget of {}", count, budget),
96 warning: true,
97 })
98 } else {
99 None
100 }
101}
102
103pub fn validate_all(file: &Path, content: &str, line_budget: usize) -> Vec<Issue> {
105 let mut issues = Vec::new();
106 issues.extend(validate_actionable(file, content));
107 issues.extend(validate_no_local_paths(file, content));
108 if let Some(issue) = validate_line_budget(file, content, line_budget) {
109 issues.push(issue);
110 }
111 issues
112}
113
114fn truncate(s: &str, max: usize) -> String {
115 if s.len() <= max {
116 s.to_string()
117 } else {
118 format!("{}...", &s[..max])
119 }
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125 use std::path::Path;
126
127 #[test]
128 fn actionable_passes_imperative() {
129 let content = "- Use clap for CLI parsing\n- Never commit secrets\n";
130 let issues = validate_actionable(Path::new("test.md"), content);
131 assert!(issues.is_empty(), "expected no issues, got: {:?}", issues);
132 }
133
134 #[test]
135 fn actionable_flags_non_imperative() {
136 let content = "This is a description of the project.\n";
137 let issues = validate_actionable(Path::new("test.md"), content);
138 assert_eq!(issues.len(), 1);
139 assert!(issues[0].warning);
140 }
141
142 #[test]
143 fn actionable_skips_headings_and_blanks() {
144 let content = "# Rules\n\n- Use foo\n";
145 let issues = validate_actionable(Path::new("test.md"), content);
146 assert!(issues.is_empty());
147 }
148
149 #[test]
150 fn local_paths_detected() {
151 let content = "Config lives at ~/config.toml\nAlso /home/user/stuff\n";
152 let issues = validate_no_local_paths(Path::new("test.md"), content);
153 assert_eq!(issues.len(), 2);
154 assert!(!issues[0].warning);
155 }
156
157 #[test]
158 fn local_paths_clean() {
159 let content = "- Use `$HOME/config.toml` instead\n";
160 let issues = validate_no_local_paths(Path::new("test.md"), content);
161 assert!(issues.is_empty());
162 }
163
164 #[test]
165 fn line_budget_within() {
166 let content = "line\n".repeat(100);
167 assert!(validate_line_budget(Path::new("t.md"), &content, 1000).is_none());
168 }
169
170 #[test]
171 fn line_budget_exceeded() {
172 let content = "line\n".repeat(1001);
173 let issue = validate_line_budget(Path::new("t.md"), &content, 1000);
174 assert!(issue.is_some());
175 }
176
177 #[test]
178 fn validate_all_combines() {
179 let content = "This has no imperative.\nAlso ~/bad/path\n";
180 let issues = validate_all(Path::new("test.md"), content, 1000);
181 assert!(issues.len() >= 2);
182 }
183}