specfold_core/import/
sources.rs1use 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 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
23fn 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
39fn 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
68fn 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}