specfold_core/workspace/owners/
patterns.rs1use globset::{GlobBuilder, GlobMatcher};
4use std::path::Path;
5
6#[derive(Debug, Clone)]
8pub enum PathMatch {
9 Prefix(String),
10 Glob(GlobMatcher),
11}
12
13impl PathMatch {
14 pub fn is_match(&self, rel: &str) -> bool {
16 match self {
17 PathMatch::Prefix(p) => rel == p || rel.starts_with(&format!("{p}/")),
18 PathMatch::Glob(g) => g.is_match(rel),
19 }
20 }
21}
22
23pub fn matcher(pattern: &str) -> Option<PathMatch> {
26 let p = pattern.trim_end_matches('/');
27 if p.contains(['*', '?', '[', '{']) {
28 let full = if p.contains('/') { p.to_string() } else { format!("**/{p}") };
29 GlobBuilder::new(&full).literal_separator(true).build().ok().map(|g| PathMatch::Glob(g.compile_matcher()))
30 } else {
31 Some(PathMatch::Prefix(p.to_string()))
32 }
33}
34
35pub fn escape_glob(path: &str) -> String {
38 let mut out = String::with_capacity(path.len());
39 for ch in path.chars() {
40 if matches!(ch, '[' | '*' | '?' | '{') {
41 out.push('[');
42 out.push(ch);
43 out.push(']');
44 } else {
45 out.push(ch);
46 }
47 }
48 out
49}
50
51pub fn literal_prefix(glob: &str) -> (String, bool) {
54 let b: Vec<char> = glob.chars().collect();
55 let mut out = String::new();
56 let mut i = 0;
57 while i < b.len() {
58 match b[i] {
59 '[' if b.len() > i + 2 && matches!(b[i + 1], '[' | '*' | '?' | '{') && b[i + 2] == ']' => {
60 out.push(b[i + 1]);
61 i += 3;
62 }
63 '[' | '*' | '?' | '{' => return (out, true),
64 c => {
65 out.push(c);
66 i += 1;
67 }
68 }
69 }
70 (out, false)
71}
72
73pub fn kind_pattern_problem(pattern: &str) -> Option<String> {
78 let bad = pattern.trim_matches('/').split('/').find(|seg| *seg != "*" && seg.contains(['*', '?', '[', '{']))?;
79 Some(format!(
80 "owner pattern '{pattern}' has the segment '{bad}'; owner patterns take literal folder names and whole '*' segments, e.g. services/*"
81 ))
82}
83
84pub fn kind_stars(pattern: &str) -> usize {
86 pattern.trim_matches('/').split('/').filter(|s| *s == "*").count()
87}
88
89pub fn expand(root: &Path, pattern: &str) -> Vec<(String, Vec<String>)> {
91 let mut acc: Vec<(String, Vec<String>)> = vec![(String::new(), vec![])];
92 for seg in pattern.trim_matches('/').split('/') {
93 let mut next = vec![];
94 for (base, caps) in &acc {
95 let dir = if base.is_empty() { root.to_path_buf() } else { root.join(base) };
96 if seg == "*" {
97 for name in subdirs(&dir) {
98 let mut c = caps.clone();
99 c.push(name.clone());
100 next.push((join(base, &name), c));
101 }
102 } else if dir.join(seg).is_dir() {
103 next.push((join(base, seg), caps.clone()));
104 }
105 }
106 acc = next;
107 }
108 acc
109}
110
111fn subdirs(dir: &Path) -> Vec<String> {
112 let Ok(rd) = std::fs::read_dir(dir) else { return vec![] };
113 let mut names: Vec<String> = rd
114 .flatten()
115 .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
116 .map(|e| e.file_name().to_string_lossy().into_owned())
117 .filter(|n| !n.starts_with('.') && n != "node_modules" && n != "target")
118 .collect();
119 names.sort();
120 names
121}
122
123fn join(base: &str, seg: &str) -> String {
124 if base.is_empty() { seg.to_string() } else { format!("{base}/{seg}") }
125}
126
127pub fn render_id(template: &str, caps: &[String]) -> String {
129 caps.iter().enumerate().fold(template.to_string(), |s, (i, c)| s.replace(&format!("{{{}}}", i + 1), c))
130}
131
132pub fn has_manifest(dir: &Path, manifests: &[String]) -> bool {
134 manifests.iter().any(|m| match m.strip_prefix("*.") {
135 Some(ext) => std::fs::read_dir(dir)
136 .map(|rd| rd.flatten().any(|e| e.path().extension().is_some_and(|x| x == ext)))
137 .unwrap_or(false),
138 None => dir.join(m).is_file(),
139 })
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 #[test]
147 fn globs_follow_gitignore_conventions() {
148 let m = |p: &str, rel: &str| matcher(p).unwrap().is_match(rel);
149 assert!(m("docs/plans/*.md", "docs/plans/a.md"));
150 assert!(!m("docs/plans/*.md", "docs/plans/a/10-steps.md"));
151 assert!(m("docs/**/*.md", "docs/plans/a/10-steps.md"));
152 assert!(m("*.md", "docs/plans/a.md"));
153 assert!(m("services/shop", "services/shop/src/main.rs"));
154 }
155}