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 let mut out: Vec<String> = Vec::new();
23 let mut push = |raw: &str| {
24 let t = raw
25 .trim_matches(|c: char| matches!(c, '(' | ')' | '"' | '\'' | '[' | ']' | '<' | '>'))
26 .trim_end_matches(['.', ',', ';', ':', '!', '?']);
27 let t = t.strip_prefix("./").unwrap_or(t);
28 if looks_like_path(t) && !out.iter().any(|p| p == t) {
29 out.push(t.to_owned());
30 }
31 };
32 let mut rest = text;
33 while let Some(start) = rest.find('`') {
34 let Some(len) = rest[start + 1..].find('`') else {
35 break;
36 };
37 push(&rest[start + 1..start + 1 + len]);
38 rest = &rest[start + 2 + len..];
39 }
40 for token in text.split_whitespace().filter(|t| !t.contains('`')) {
41 push(token);
42 }
43 out
44}
45
46fn looks_like_path(t: &str) -> bool {
47 if t.is_empty() || t.contains("://") || t.contains(' ') || t.starts_with('/') {
48 return false;
49 }
50 if t.contains('/') {
51 return t.chars().any(char::is_alphanumeric);
52 }
53 t.rsplit_once('.').is_some_and(|(stem, ext)| {
55 !stem.is_empty()
56 && (1..=5).contains(&ext.len())
57 && ext.chars().all(|c| c.is_ascii_alphabetic())
58 })
59}
60
61#[must_use]
64pub fn finding(named: &[String], changed: &[String], exempt: &[String]) -> ScopeFinding {
65 let covers = |n: &str, p: &str| {
66 let dir = n.trim_end_matches('/');
67 p == n || p.starts_with(&format!("{dir}/")) || (n.contains('*') && glob(n, p))
68 };
69 ScopeFinding {
70 stated: !named.is_empty(),
71 named: named.to_vec(),
72 outside: if named.is_empty() {
73 Vec::new()
74 } else {
75 changed
76 .iter()
77 .filter(|p| !exempt.contains(p) && !named.iter().any(|n| covers(n, p)))
78 .cloned()
79 .collect()
80 },
81 }
82}
83
84fn glob(pattern: &str, path: &str) -> bool {
86 fn go(p: &[u8], s: &[u8]) -> bool {
87 match p.split_first() {
88 None => s.is_empty(),
89 Some((b'*', rest)) if rest.first() == Some(&b'*') => {
90 let rest = rest.get(1..).unwrap_or_default();
91 let rest = rest.strip_prefix(b"/").unwrap_or(rest);
92 (0..=s.len()).any(|i| go(rest, &s[i..]))
93 }
94 Some((b'*', rest)) => (0..=s.len())
95 .take_while(|&i| i == 0 || s[i - 1] != b'/')
96 .any(|i| go(rest, &s[i..])),
97 Some((c, rest)) => s.first() == Some(c) && go(rest, &s[1..]),
98 }
99 }
100 go(pattern.as_bytes(), path.as_bytes())
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106
107 #[test]
108 fn reads_paths_from_a_task_line() {
109 assert_eq!(
110 named_paths("[US1] Implement Service in src/services/x.py (depends on T012)"),
111 ["src/services/x.py"]
112 );
113 assert_eq!(
114 named_paths("Document `--bye` in README.md, and update `docs/`."),
115 ["docs/", "README.md"]
116 );
117 assert!(named_paths("Bump to v1.2 and say hi").is_empty());
118 assert!(named_paths("See https://example.com/x for details").is_empty());
119 }
120
121 #[test]
122 fn lists_changes_outside_the_named_paths() {
123 let named = vec![
124 "src/lib.rs".to_owned(),
125 "docs/".to_owned(),
126 "src/**/*.snap".to_owned(),
127 ];
128 let changed: Vec<String> = [
129 "src/lib.rs",
130 "docs/a/b.md",
131 "src/util.rs",
132 "src/x/y.snap",
133 "tests/acceptance_t1.rs",
134 ]
135 .map(str::to_owned)
136 .to_vec();
137 let f = finding(&named, &changed, &["tests/acceptance_t1.rs".to_owned()]);
138 assert!(f.stated);
139 assert_eq!(f.outside, ["src/util.rs"]);
140 let none = finding(&[], &changed, &[]);
141 assert!(!none.stated && none.outside.is_empty());
142 }
143}