Skip to main content

specfold_core/workspace/owners/
patterns.rs

1//! Directory patterns: `services/*`-style kinds and path matchers for public and retired lists.
2
3use globset::{GlobBuilder, GlobMatcher};
4use std::path::Path;
5
6/// A path or glob; a plain path also matches everything under it.
7#[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 {
15        match self {
16            PathMatch::Prefix(p) => rel == p || rel.starts_with(&format!("{p}/")),
17            PathMatch::Glob(g) => g.is_match(rel),
18        }
19    }
20}
21
22/// Globs follow gitignore conventions: `*` stays within one path segment, `**` crosses them, and a
23/// pattern without a slash matches a file name at any depth.
24pub fn matcher(pattern: &str) -> Option<PathMatch> {
25    let p = pattern.trim_end_matches('/');
26    if p.contains(['*', '?', '[', '{']) {
27        let full = if p.contains('/') { p.to_string() } else { format!("**/{p}") };
28        GlobBuilder::new(&full).literal_separator(true).build().ok().map(|g| PathMatch::Glob(g.compile_matcher()))
29    } else {
30        Some(PathMatch::Prefix(p.to_string()))
31    }
32}
33
34/// Expands a pattern of literal segments and `*` into existing directories, returning each with
35/// the values its `*` segments took.
36pub fn expand(root: &Path, pattern: &str) -> Vec<(String, Vec<String>)> {
37    let mut acc: Vec<(String, Vec<String>)> = vec![(String::new(), vec![])];
38    for seg in pattern.trim_matches('/').split('/') {
39        let mut next = vec![];
40        for (base, caps) in &acc {
41            let dir = if base.is_empty() { root.to_path_buf() } else { root.join(base) };
42            if seg == "*" {
43                for name in subdirs(&dir) {
44                    let mut c = caps.clone();
45                    c.push(name.clone());
46                    next.push((join(base, &name), c));
47                }
48            } else if dir.join(seg).is_dir() {
49                next.push((join(base, seg), caps.clone()));
50            }
51        }
52        acc = next;
53    }
54    acc
55}
56
57fn subdirs(dir: &Path) -> Vec<String> {
58    let Ok(rd) = std::fs::read_dir(dir) else { return vec![] };
59    let mut names: Vec<String> = rd
60        .flatten()
61        .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
62        .map(|e| e.file_name().to_string_lossy().into_owned())
63        .filter(|n| !n.starts_with('.') && n != "node_modules" && n != "target")
64        .collect();
65    names.sort();
66    names
67}
68
69fn join(base: &str, seg: &str) -> String {
70    if base.is_empty() { seg.to_string() } else { format!("{base}/{seg}") }
71}
72
73/// Fills `{1}`, `{2}` … in an owner id template.
74pub fn render_id(template: &str, caps: &[String]) -> String {
75    caps.iter().enumerate().fold(template.to_string(), |s, (i, c)| s.replace(&format!("{{{}}}", i + 1), c))
76}
77
78pub fn has_manifest(dir: &Path, manifests: &[String]) -> bool {
79    manifests.iter().any(|m| match m.strip_prefix("*.") {
80        Some(ext) => std::fs::read_dir(dir)
81            .map(|rd| rd.flatten().any(|e| e.path().extension().is_some_and(|x| x == ext)))
82            .unwrap_or(false),
83        None => dir.join(m).is_file(),
84    })
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    #[test]
92    fn globs_follow_gitignore_conventions() {
93        let m = |p: &str, rel: &str| matcher(p).unwrap().is_match(rel);
94        assert!(m("docs/plans/*.md", "docs/plans/a.md"));
95        assert!(!m("docs/plans/*.md", "docs/plans/a/10-steps.md"));
96        assert!(m("docs/**/*.md", "docs/plans/a/10-steps.md"));
97        assert!(m("*.md", "docs/plans/a.md"));
98        assert!(m("services/shop", "services/shop/src/main.rs"));
99    }
100}