Skip to main content

aiproof_cli/
discovery.rs

1use aiproof_config::Config;
2use ignore::WalkBuilder;
3use std::path::{Path, PathBuf};
4
5pub struct Discovered {
6    pub path: PathBuf,
7}
8
9pub fn discover(paths: &[PathBuf], config: &Config) -> anyhow::Result<Vec<Discovered>> {
10    let mut out = Vec::new();
11    for root in paths {
12        for result in WalkBuilder::new(root).build() {
13            let entry = result?;
14            if !entry.file_type().is_some_and(|t| t.is_file()) {
15                continue;
16            }
17            let path = entry.into_path();
18            if !should_scan(&path, config) {
19                continue;
20            }
21            out.push(Discovered { path });
22        }
23    }
24    Ok(out)
25}
26
27fn should_scan(path: &Path, config: &Config) -> bool {
28    // Tier 1: declared `include` globs — if set, require a match.
29    if !config.include.is_empty() {
30        let s = path.to_string_lossy();
31        if !config.include.iter().any(|pat| glob_match(pat, &s)) {
32            return false;
33        }
34    }
35    // `exclude` always applies.
36    let s = path.to_string_lossy();
37    if config.exclude.iter().any(|pat| glob_match(pat, &s)) {
38        return false;
39    }
40
41    // Tier 2: known-safe extensions/dirs.
42    if is_known_prompt_file(path) {
43        return true;
44    }
45
46    // Tier 3: .py/.ts/.tsx for SDK extraction.
47    if matches!(
48        path.extension().and_then(|e| e.to_str()),
49        Some("py" | "ts" | "tsx")
50    ) {
51        return true;
52    }
53
54    // If include globs matched (tier 1 positive), we already returned early above —
55    // so if we're here with no include list, apply default extensions only.
56    false
57}
58
59fn is_known_prompt_file(path: &Path) -> bool {
60    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
61    // .prompt.md / .prompt
62    if name.ends_with(".prompt.md") || name.ends_with(".prompt") {
63        return true;
64    }
65    // Jinja-ish extensions
66    if matches!(
67        path.extension().and_then(|e| e.to_str()),
68        Some("j2" | "jinja" | "jinja2" | "mustache")
69    ) {
70        return true;
71    }
72    // Markdown files
73    if matches!(
74        path.extension().and_then(|e| e.to_str()),
75        Some("md" | "yaml" | "yml")
76    ) {
77        return true;
78    }
79    // Directories named prompts, templates, system_prompts
80    let components: Vec<_> = path
81        .components()
82        .filter_map(|c| c.as_os_str().to_str())
83        .collect();
84    components
85        .iter()
86        .any(|c| matches!(*c, "prompts" | "templates" | "system_prompts"))
87}
88
89fn glob_match(pattern: &str, path: &str) -> bool {
90    glob::Pattern::new(pattern)
91        .map(|p| p.matches(path))
92        .unwrap_or(false)
93}