mod actionable;
pub use actionable::{check_actionable, check_tree_paths, extract_tree_paths};
use regex::Regex;
use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Issue {
pub file: PathBuf,
pub line: Option<usize>,
pub message: String,
pub warning: bool,
}
impl fmt::Display for Issue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let severity = if self.warning { "warning" } else { "error" };
match self.line {
Some(n) => write!(f, "{}:{}:{}: {}", self.file.display(), n, severity, self.message),
None => write!(f, "{}:{}: {}", self.file.display(), severity, self.message),
}
}
}
static IMPERATIVE_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)\b(use|add|create|run|do|don't|never|must|should|avoid|prefer|ensure|keep|set)\b")
.expect("imperative regex")
});
static LOCAL_PATH_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(~/|/home/\w+/|/Users/\w+/|/tmp/|[A-Z]:\\Users\\)")
.expect("local path regex")
});
pub fn validate_actionable(file: &Path, content: &str) -> Vec<Issue> {
let mut issues = Vec::new();
for (i, line) in content.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty()
|| trimmed.starts_with('#')
|| trimmed.starts_with("//")
|| trimmed.starts_with("<!--")
|| trimmed.starts_with("```")
{
continue;
}
if !IMPERATIVE_RE.is_match(trimmed) {
issues.push(Issue {
file: file.to_path_buf(),
line: Some(i + 1),
message: format!("line lacks an imperative verb: {}", truncate(trimmed, 60)),
warning: true,
});
}
}
issues
}
pub fn validate_no_local_paths(file: &Path, content: &str) -> Vec<Issue> {
let mut issues = Vec::new();
for (i, line) in content.lines().enumerate() {
if LOCAL_PATH_RE.is_match(line) {
issues.push(Issue {
file: file.to_path_buf(),
line: Some(i + 1),
message: format!(
"machine-local path detected: {}",
truncate(line.trim(), 60)
),
warning: false,
});
}
}
issues
}
pub fn validate_line_budget(file: &Path, content: &str, budget: usize) -> Option<Issue> {
let count = content.lines().count();
if count > budget {
Some(Issue {
file: file.to_path_buf(),
line: None,
message: format!("file has {} lines, exceeding budget of {}", count, budget),
warning: true,
})
} else {
None
}
}
pub fn validate_all(file: &Path, content: &str, line_budget: usize) -> Vec<Issue> {
let mut issues = Vec::new();
issues.extend(validate_actionable(file, content));
issues.extend(validate_no_local_paths(file, content));
if let Some(issue) = validate_line_budget(file, content, line_budget) {
issues.push(issue);
}
issues
}
fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
format!("{}...", &s[..max])
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn actionable_passes_imperative() {
let content = "- Use clap for CLI parsing\n- Never commit secrets\n";
let issues = validate_actionable(Path::new("test.md"), content);
assert!(issues.is_empty(), "expected no issues, got: {:?}", issues);
}
#[test]
fn actionable_flags_non_imperative() {
let content = "This is a description of the project.\n";
let issues = validate_actionable(Path::new("test.md"), content);
assert_eq!(issues.len(), 1);
assert!(issues[0].warning);
}
#[test]
fn actionable_skips_headings_and_blanks() {
let content = "# Rules\n\n- Use foo\n";
let issues = validate_actionable(Path::new("test.md"), content);
assert!(issues.is_empty());
}
#[test]
fn local_paths_detected() {
let content = "Config lives at ~/config.toml\nAlso /home/user/stuff\n";
let issues = validate_no_local_paths(Path::new("test.md"), content);
assert_eq!(issues.len(), 2);
assert!(!issues[0].warning);
}
#[test]
fn local_paths_clean() {
let content = "- Use `$HOME/config.toml` instead\n";
let issues = validate_no_local_paths(Path::new("test.md"), content);
assert!(issues.is_empty());
}
#[test]
fn line_budget_within() {
let content = "line\n".repeat(100);
assert!(validate_line_budget(Path::new("t.md"), &content, 1000).is_none());
}
#[test]
fn line_budget_exceeded() {
let content = "line\n".repeat(1001);
let issue = validate_line_budget(Path::new("t.md"), &content, 1000);
assert!(issue.is_some());
}
#[test]
fn validate_all_combines() {
let content = "This has no imperative.\nAlso ~/bad/path\n";
let issues = validate_all(Path::new("test.md"), content, 1000);
assert!(issues.len() >= 2);
}
}