Skip to main content

specfold_core/import/
sources.rs

1//! Expands the map's patterns into import units: one main file with its chapters and
2//! attachments.
3
4use super::map::{ImportMap, SourceRule};
5use crate::workspace::owners::{PathMatch, matcher};
6use ignore::WalkBuilder;
7use regex::Regex;
8use std::collections::BTreeSet;
9use std::path::Path;
10
11#[derive(Debug, Clone)]
12pub struct Unit {
13    pub rule: usize,
14    /// The file or directory that matched.
15    pub source: String,
16    pub main: String,
17    pub chapters: Vec<String>,
18    pub attachments: Vec<String>,
19}
20
21const MAIN_NAMES: &[&str] = &["index.md", "README.md", "readme.md", "00-index.md", "_index.md"];
22
23/// C211: sorted by path, so records sharing a date get their seconds in file-name order
24/// on every filesystem.
25fn all_files(root: &Path) -> Vec<String> {
26    let mut files: Vec<String> = WalkBuilder::new(root)
27        .hidden(false)
28        .require_git(false)
29        .filter_entry(|e| e.file_name() != ".git")
30        .build()
31        .flatten()
32        .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
33        .filter_map(|e| e.path().strip_prefix(root).ok().map(|p| p.to_string_lossy().replace('\\', "/")))
34        .collect();
35    files.sort();
36    files
37}
38
39/// The main file, chapters and attachments of a directory, leaving out files an earlier rule
40/// already took (a nested bundle belongs to the rule that matched it first).
41fn inside<'a>(files: &'a [String], dir: &str, claimed: &BTreeSet<String>) -> Vec<&'a String> {
42    let prefix = format!("{dir}/");
43    files.iter().filter(|f| f.starts_with(&prefix) && !claimed.contains(*f)).collect()
44}
45
46fn bundle(files: &[String], dir: &str, claimed: &BTreeSet<String>) -> Option<(String, Vec<String>, Vec<String>)> {
47    let inside = inside(files, dir, claimed);
48    let main = MAIN_NAMES
49        .iter()
50        .map(|n| format!("{dir}/{n}"))
51        .find(|m| inside.contains(&m))
52        .or_else(|| inside.iter().filter(|f| f.ends_with(".md")).min().map(|s| s.to_string()))?;
53    let chapters = inside.iter().filter(|f| f.ends_with(".md") && **f != &main).map(|s| s.to_string()).collect();
54    let attachments = inside.iter().filter(|f| !f.ends_with(".md")).map(|s| s.to_string()).collect();
55    Some((main, chapters, attachments))
56}
57
58pub fn expand(root: &Path, map: &ImportMap) -> Vec<Unit> {
59    let files = all_files(root);
60    let mut claimed: BTreeSet<String> = BTreeSet::new();
61    let mut out = vec![];
62    for (ri, rule) in map.sources.iter().enumerate() {
63        out.extend(expand_rule(root, &files, ri, rule, &mut claimed));
64    }
65    out
66}
67
68/// A redirect rule claims only the files whose text is a stub, so a broad glob over a directory
69/// that mixes stubs and real documents leaves the documents to the rules after it.
70fn is_stub(root: &Path, file: &str, re: Option<&Regex>) -> bool {
71    let Some(re) = re else { return true };
72    std::fs::read_to_string(root.join(file)).is_ok_and(|t| re.is_match(&t))
73}
74
75fn expand_rule(
76    root: &Path,
77    files: &[String],
78    ri: usize,
79    rule: &SourceRule,
80    claimed: &mut BTreeSet<String>,
81) -> Vec<Unit> {
82    let dir_mode = rule.glob.ends_with('/');
83    let Some(m) = matcher(rule.glob.trim_end_matches('/')) else { return vec![] };
84    let exclude: Vec<PathMatch> = rule.exclude.iter().filter_map(|e| matcher(e)).collect();
85    let skip = |p: &str, claimed: &BTreeSet<String>| exclude.iter().any(|e| e.is_match(p)) || claimed.contains(p);
86    let mut out = vec![];
87    if dir_mode {
88        let mut dirs: BTreeSet<String> = BTreeSet::new();
89        for f in files {
90            let mut parts: Vec<&str> = f.split('/').collect();
91            parts.pop();
92            for n in 1..=parts.len() {
93                let d = parts[..n].join("/");
94                if m.is_match(&d) {
95                    dirs.insert(d);
96                }
97            }
98        }
99        for d in dirs {
100            if skip(&d, claimed) {
101                continue;
102            }
103            let unit = match bundle(files, &d, claimed) {
104                Some((main, chapters, attachments)) => Some((main, chapters, attachments)),
105                None if rule.wrap || rule.drop => {
106                    let all: Vec<String> = inside(files, &d, claimed).into_iter().cloned().collect();
107                    (!all.is_empty()).then(|| (String::new(), vec![], all))
108                }
109                None => None,
110            };
111            if let Some((main, chapters, attachments)) = unit {
112                for f in std::iter::once(&main).chain(&chapters).chain(&attachments) {
113                    claimed.insert(f.clone());
114                }
115                claimed.insert(d.clone());
116                out.push(Unit { rule: ri, source: d, main, chapters, attachments });
117            }
118        }
119        return out;
120    }
121    let stub_re = rule.redirect.as_deref().and_then(|r| Regex::new(r).ok());
122    for f in files {
123        if !m.is_match(f) || skip(f, claimed) {
124            continue;
125        }
126        if rule.redirect.is_some() && !is_stub(root, f, stub_re.as_ref()) {
127            continue;
128        }
129        claimed.insert(f.clone());
130        if rule.wrap && !f.ends_with(".md") {
131            out.push(Unit {
132                rule: ri,
133                source: f.clone(),
134                main: String::new(),
135                chapters: vec![],
136                attachments: vec![f.clone()],
137            });
138            continue;
139        }
140        let (mut chapters, mut attachments) = (vec![], vec![]);
141        if rule.sibling_dir {
142            let dir = f.trim_end_matches(".md");
143            let taken: Vec<String> = inside(files, dir, claimed).into_iter().cloned().collect();
144            for g in taken {
145                if g.ends_with(".md") {
146                    chapters.push(g.clone())
147                } else {
148                    attachments.push(g.clone())
149                }
150                claimed.insert(g);
151            }
152        }
153        out.push(Unit { rule: ri, source: f.clone(), main: f.clone(), chapters, attachments });
154    }
155    out
156}
157
158#[cfg(test)]
159mod tests {
160    #[test]
161    fn c211_sources_come_in_path_order() {
162        let dir = tempfile::tempdir().expect("tempdir");
163        let names = ["0003-c.md", "0001-a.md", "0010-j.md", "0002-b.md", "0005-e.md", "0004-d.md"];
164        for n in names {
165            std::fs::write(dir.path().join(n), "x").expect("write");
166        }
167        let got = super::all_files(dir.path());
168        let mut want: Vec<String> = names.iter().map(|s| s.to_string()).collect();
169        want.sort();
170        assert_eq!(got, want);
171    }
172}