Skip to main content

speccoder_core/
scope.rs

1//! Task scope as a reported finding: which changed paths a task's line did
2//! not name. Never a gate — a task line is prose, and a list of paths in
3//! it is a hint, not a contract.
4
5use serde::{Deserialize, Serialize};
6
7/// What an attempt changed beyond the paths its task names.
8#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
9pub struct ScopeFinding {
10    /// The task names at least one path.
11    pub stated: bool,
12    /// The paths the task names.
13    pub named: Vec<String>,
14    /// Changed paths outside them.
15    pub outside: Vec<String>,
16}
17
18/// Paths a task line names: backticked spans, and bare tokens with a `/`
19/// or a file extension (`src/lib.rs`, `README.md`, `tests/`).
20#[must_use]
21pub fn named_paths(text: &str) -> Vec<String> {
22    paths_in(text, true)
23}
24
25/// The backticked paths of a task line only: what it names on purpose
26/// (`input/output` in its prose is no path).
27#[must_use]
28pub fn quoted_paths(text: &str) -> Vec<String> {
29    paths_in(text, false)
30}
31
32/// Top directories of a machine, not of a repository.
33const SYSTEM_ROOTS: &[&str] = &[
34    "tmp",
35    "dev",
36    "usr",
37    "etc",
38    "var",
39    "bin",
40    "sbin",
41    "opt",
42    "home",
43    "Users",
44    "proc",
45    "sys",
46    "private",
47    "root",
48    "lib",
49    "Library",
50    "System",
51    "Applications",
52    "Volumes",
53    "mnt",
54    "srv",
55];
56
57fn paths_in(text: &str, bare: bool) -> Vec<String> {
58    let mut out: Vec<String> = Vec::new();
59    let mut push = |raw: &str| {
60        let t = raw
61            .trim_matches(|c: char| matches!(c, '(' | ')' | '"' | '\'' | '[' | ']' | '<' | '>'))
62            .trim_end_matches(['.', ',', ';', ':', '!', '?']);
63        let t = t.strip_prefix("./").unwrap_or(t);
64        // `/specs/001-x/plan.md`: Spec Kit writes repository paths from the
65        // root; `/tmp/…` or `/dev/null` is the machine's, never named.
66        let t = t
67            .strip_prefix('/')
68            .filter(|r| {
69                !r.starts_with('/')
70                    && !SYSTEM_ROOTS.contains(&r.split('/').next().unwrap_or_default())
71            })
72            .unwrap_or(t);
73        if looks_like_path(t) && !out.iter().any(|p| p == t) {
74            out.push(t.to_owned());
75        }
76    };
77    let mut rest = text;
78    while let Some(start) = rest.find('`') {
79        let Some(len) = rest[start + 1..].find('`') else {
80            break;
81        };
82        push(&rest[start + 1..start + 1 + len]);
83        rest = &rest[start + 2 + len..];
84    }
85    if bare {
86        for token in text.split_whitespace().filter(|t| !t.contains('`')) {
87            push(token);
88        }
89    }
90    out
91}
92
93fn looks_like_path(t: &str) -> bool {
94    if t.is_empty() || t.contains("://") || t.contains(' ') || t.starts_with('/') {
95        return false;
96    }
97    if t.contains('/') {
98        return t.chars().any(char::is_alphanumeric);
99    }
100    // `name.ext`: an extension of letters, as files have (`v1.2` is not one).
101    t.rsplit_once('.').is_some_and(|(stem, ext)| {
102        !stem.is_empty()
103            && (1..=5).contains(&ext.len())
104            && ext.chars().all(|c| c.is_ascii_alphabetic())
105    })
106}
107
108/// Changed paths not covered by `named` (exact path, a directory prefix, a
109/// module named without its extension — `src/parser` covers
110/// `src/parser.py` — or a `*` glob), except `exempt` ones (speccoder's own
111/// acceptance tests).
112#[must_use]
113pub fn finding(named: &[String], changed: &[String], exempt: &[String]) -> ScopeFinding {
114    let covers = |n: &str, p: &str| {
115        let dir = n.trim_end_matches('/');
116        let module = !n.rsplit('/').next().unwrap_or(n).contains('.')
117            && p.strip_prefix(n)
118                .and_then(|r| r.strip_prefix('.'))
119                .is_some_and(|ext| !ext.is_empty() && !ext.contains(['/', '.']));
120        p == n || p.starts_with(&format!("{dir}/")) || module || (n.contains('*') && glob(n, p))
121    };
122    ScopeFinding {
123        stated: !named.is_empty(),
124        named: named.to_vec(),
125        outside: if named.is_empty() {
126            Vec::new()
127        } else {
128            changed
129                .iter()
130                .filter(|p| !exempt.contains(p) && !named.iter().any(|n| covers(n, p)))
131                .cloned()
132                .collect()
133        },
134    }
135}
136
137/// `*` matches within a path segment, `**` across segments.
138fn glob(pattern: &str, path: &str) -> bool {
139    fn go(p: &[u8], s: &[u8]) -> bool {
140        match p.split_first() {
141            None => s.is_empty(),
142            Some((b'*', rest)) if rest.first() == Some(&b'*') => {
143                let rest = rest.get(1..).unwrap_or_default();
144                let rest = rest.strip_prefix(b"/").unwrap_or(rest);
145                (0..=s.len()).any(|i| go(rest, &s[i..]))
146            }
147            Some((b'*', rest)) => (0..=s.len())
148                .take_while(|&i| i == 0 || s[i - 1] != b'/')
149                .any(|i| go(rest, &s[i..])),
150            Some((c, rest)) => s.first() == Some(c) && go(rest, &s[1..]),
151        }
152    }
153    go(pattern.as_bytes(), path.as_bytes())
154}
155
156#[cfg(test)]
157mod tests {
158    #[test]
159    fn a_module_named_without_its_extension_covers_its_file() {
160        let named = vec!["src/services/table_parser".to_owned()];
161        let changed: Vec<String> = [
162            "src/services/table_parser.py",
163            "src/services/table_parser_extra.py",
164            "src/services/table_parser.tar.gz",
165        ]
166        .map(str::to_owned)
167        .to_vec();
168        let f = super::finding(&named, &changed, &[]);
169        assert_eq!(
170            f.outside,
171            [
172                "src/services/table_parser_extra.py",
173                "src/services/table_parser.tar.gz"
174            ]
175        );
176    }
177
178    use super::*;
179
180    #[test]
181    fn reads_paths_from_a_task_line() {
182        assert_eq!(
183            named_paths("[US1] Implement Service in src/services/x.py (depends on T012)"),
184            ["src/services/x.py"]
185        );
186        assert_eq!(
187            named_paths("Document `--bye` in README.md, and update `docs/`."),
188            ["docs/", "README.md"]
189        );
190        assert_eq!(
191            named_paths("Scaffold `/specs/001-x/contracts/` from /specs/001-x/plan.md"),
192            ["specs/001-x/contracts/", "specs/001-x/plan.md"]
193        );
194        assert!(named_paths("Write the log to /tmp/run.log, not `/dev/null`").is_empty());
195        assert!(named_paths("Bump to v1.2 and say hi").is_empty());
196        assert!(named_paths("See https://example.com/x for details").is_empty());
197    }
198
199    #[test]
200    fn lists_changes_outside_the_named_paths() {
201        let named = vec![
202            "src/lib.rs".to_owned(),
203            "docs/".to_owned(),
204            "src/**/*.snap".to_owned(),
205        ];
206        let changed: Vec<String> = [
207            "src/lib.rs",
208            "docs/a/b.md",
209            "src/util.rs",
210            "src/x/y.snap",
211            "tests/acceptance_t1.rs",
212        ]
213        .map(str::to_owned)
214        .to_vec();
215        let f = finding(&named, &changed, &["tests/acceptance_t1.rs".to_owned()]);
216        assert!(f.stated);
217        assert_eq!(f.outside, ["src/util.rs"]);
218        let none = finding(&[], &changed, &[]);
219        assert!(!none.stated && none.outside.is_empty());
220    }
221}