use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct ScopeFinding {
pub stated: bool,
pub named: Vec<String>,
pub outside: Vec<String>,
}
#[must_use]
pub fn named_paths(text: &str) -> Vec<String> {
paths_in(text, true)
}
#[must_use]
pub fn quoted_paths(text: &str) -> Vec<String> {
paths_in(text, false)
}
const SYSTEM_ROOTS: &[&str] = &[
"tmp",
"dev",
"usr",
"etc",
"var",
"bin",
"sbin",
"opt",
"home",
"Users",
"proc",
"sys",
"private",
"root",
"lib",
"Library",
"System",
"Applications",
"Volumes",
"mnt",
"srv",
];
fn paths_in(text: &str, bare: bool) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut push = |raw: &str| {
let t = raw
.trim_matches(|c: char| matches!(c, '(' | ')' | '"' | '\'' | '[' | ']' | '<' | '>'))
.trim_end_matches(['.', ',', ';', ':', '!', '?']);
let t = t.strip_prefix("./").unwrap_or(t);
let t = t
.strip_prefix('/')
.filter(|r| {
!r.starts_with('/')
&& !SYSTEM_ROOTS.contains(&r.split('/').next().unwrap_or_default())
})
.unwrap_or(t);
if looks_like_path(t) && !out.iter().any(|p| p == t) {
out.push(t.to_owned());
}
};
let mut rest = text;
while let Some(start) = rest.find('`') {
let Some(len) = rest[start + 1..].find('`') else {
break;
};
push(&rest[start + 1..start + 1 + len]);
rest = &rest[start + 2 + len..];
}
if bare {
for token in text.split_whitespace().filter(|t| !t.contains('`')) {
push(token);
}
}
out
}
fn looks_like_path(t: &str) -> bool {
if t.is_empty() || t.contains("://") || t.contains(' ') || t.starts_with('/') {
return false;
}
if t.contains('/') {
return t.chars().any(char::is_alphanumeric);
}
t.rsplit_once('.').is_some_and(|(stem, ext)| {
!stem.is_empty()
&& (1..=5).contains(&ext.len())
&& ext.chars().all(|c| c.is_ascii_alphabetic())
})
}
#[must_use]
pub fn finding(named: &[String], changed: &[String], exempt: &[String]) -> ScopeFinding {
let covers = |n: &str, p: &str| {
let dir = n.trim_end_matches('/');
p == n || p.starts_with(&format!("{dir}/")) || (n.contains('*') && glob(n, p))
};
ScopeFinding {
stated: !named.is_empty(),
named: named.to_vec(),
outside: if named.is_empty() {
Vec::new()
} else {
changed
.iter()
.filter(|p| !exempt.contains(p) && !named.iter().any(|n| covers(n, p)))
.cloned()
.collect()
},
}
}
fn glob(pattern: &str, path: &str) -> bool {
fn go(p: &[u8], s: &[u8]) -> bool {
match p.split_first() {
None => s.is_empty(),
Some((b'*', rest)) if rest.first() == Some(&b'*') => {
let rest = rest.get(1..).unwrap_or_default();
let rest = rest.strip_prefix(b"/").unwrap_or(rest);
(0..=s.len()).any(|i| go(rest, &s[i..]))
}
Some((b'*', rest)) => (0..=s.len())
.take_while(|&i| i == 0 || s[i - 1] != b'/')
.any(|i| go(rest, &s[i..])),
Some((c, rest)) => s.first() == Some(c) && go(rest, &s[1..]),
}
}
go(pattern.as_bytes(), path.as_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_paths_from_a_task_line() {
assert_eq!(
named_paths("[US1] Implement Service in src/services/x.py (depends on T012)"),
["src/services/x.py"]
);
assert_eq!(
named_paths("Document `--bye` in README.md, and update `docs/`."),
["docs/", "README.md"]
);
assert_eq!(
named_paths("Scaffold `/specs/001-x/contracts/` from /specs/001-x/plan.md"),
["specs/001-x/contracts/", "specs/001-x/plan.md"]
);
assert!(named_paths("Write the log to /tmp/run.log, not `/dev/null`").is_empty());
assert!(named_paths("Bump to v1.2 and say hi").is_empty());
assert!(named_paths("See https://example.com/x for details").is_empty());
}
#[test]
fn lists_changes_outside_the_named_paths() {
let named = vec![
"src/lib.rs".to_owned(),
"docs/".to_owned(),
"src/**/*.snap".to_owned(),
];
let changed: Vec<String> = [
"src/lib.rs",
"docs/a/b.md",
"src/util.rs",
"src/x/y.snap",
"tests/acceptance_t1.rs",
]
.map(str::to_owned)
.to_vec();
let f = finding(&named, &changed, &["tests/acceptance_t1.rs".to_owned()]);
assert!(f.stated);
assert_eq!(f.outside, ["src/util.rs"]);
let none = finding(&[], &changed, &[]);
assert!(!none.stated && none.outside.is_empty());
}
}