Skip to main content

agent_rules/
lib.rs

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/// A validation issue found in a rules file.
11#[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
39/// Check that rules have imperative verbs indicating actionable directives.
40///
41/// Returns an issue for each non-empty, non-heading, non-comment line that
42/// lacks a recognised imperative verb.
43pub 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        // Skip blanks, headings, comments, code fences, and list-continuation indented lines
48        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        // Only check top-level list items and bare sentences
57        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
69/// Check for machine-local paths that should not appear in shared rules.
70pub 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
88/// Check that the file does not exceed a line budget.
89pub 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
103/// Run all validations on a single file's content.
104pub 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}