1use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
9pub struct ScopeFinding {
10 pub stated: bool,
12 pub named: Vec<String>,
14 pub outside: Vec<String>,
16}
17
18#[must_use]
21pub fn named_paths(text: &str) -> Vec<String> {
22 paths_in(text, true)
23}
24
25#[must_use]
28pub fn quoted_paths(text: &str) -> Vec<String> {
29 paths_in(text, false)
30}
31
32const 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 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 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#[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
137fn 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}