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> {
24 WalkBuilder::new(root)
25 .hidden(false)
26 .require_git(false)
27 .filter_entry(|e| e.file_name() != ".git")
28 .build()
29 .flatten()
30 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
31 .filter_map(|e| e.path().strip_prefix(root).ok().map(|p| p.to_string_lossy().replace('\\', "/")))
32 .collect()
33}
34
35fn inside<'a>(files: &'a [String], dir: &str, claimed: &BTreeSet<String>) -> Vec<&'a String> {
38 let prefix = format!("{dir}/");
39 files.iter().filter(|f| f.starts_with(&prefix) && !claimed.contains(*f)).collect()
40}
41
42fn bundle(files: &[String], dir: &str, claimed: &BTreeSet<String>) -> Option<(String, Vec<String>, Vec<String>)> {
43 let inside = inside(files, dir, claimed);
44 let main = MAIN_NAMES
45 .iter()
46 .map(|n| format!("{dir}/{n}"))
47 .find(|m| inside.contains(&m))
48 .or_else(|| inside.iter().filter(|f| f.ends_with(".md")).min().map(|s| s.to_string()))?;
49 let chapters = inside.iter().filter(|f| f.ends_with(".md") && **f != &main).map(|s| s.to_string()).collect();
50 let attachments = inside.iter().filter(|f| !f.ends_with(".md")).map(|s| s.to_string()).collect();
51 Some((main, chapters, attachments))
52}
53
54pub fn expand(root: &Path, map: &ImportMap) -> Vec<Unit> {
55 let files = all_files(root);
56 let mut claimed: BTreeSet<String> = BTreeSet::new();
57 let mut out = vec![];
58 for (ri, rule) in map.sources.iter().enumerate() {
59 out.extend(expand_rule(root, &files, ri, rule, &mut claimed));
60 }
61 out
62}
63
64fn is_stub(root: &Path, file: &str, re: Option<&Regex>) -> bool {
67 let Some(re) = re else { return true };
68 std::fs::read_to_string(root.join(file)).is_ok_and(|t| re.is_match(&t))
69}
70
71fn expand_rule(
72 root: &Path,
73 files: &[String],
74 ri: usize,
75 rule: &SourceRule,
76 claimed: &mut BTreeSet<String>,
77) -> Vec<Unit> {
78 let dir_mode = rule.glob.ends_with('/');
79 let Some(m) = matcher(rule.glob.trim_end_matches('/')) else { return vec![] };
80 let exclude: Vec<PathMatch> = rule.exclude.iter().filter_map(|e| matcher(e)).collect();
81 let skip = |p: &str, claimed: &BTreeSet<String>| exclude.iter().any(|e| e.is_match(p)) || claimed.contains(p);
82 let mut out = vec![];
83 if dir_mode {
84 let mut dirs: BTreeSet<String> = BTreeSet::new();
85 for f in files {
86 let mut parts: Vec<&str> = f.split('/').collect();
87 parts.pop();
88 for n in 1..=parts.len() {
89 let d = parts[..n].join("/");
90 if m.is_match(&d) {
91 dirs.insert(d);
92 }
93 }
94 }
95 for d in dirs {
96 if skip(&d, claimed) {
97 continue;
98 }
99 let unit = match bundle(files, &d, claimed) {
100 Some((main, chapters, attachments)) => Some((main, chapters, attachments)),
101 None if rule.wrap || rule.drop => {
102 let all: Vec<String> = inside(files, &d, claimed).into_iter().cloned().collect();
103 (!all.is_empty()).then(|| (String::new(), vec![], all))
104 }
105 None => None,
106 };
107 if let Some((main, chapters, attachments)) = unit {
108 for f in std::iter::once(&main).chain(&chapters).chain(&attachments) {
109 claimed.insert(f.clone());
110 }
111 claimed.insert(d.clone());
112 out.push(Unit { rule: ri, source: d, main, chapters, attachments });
113 }
114 }
115 return out;
116 }
117 let stub_re = rule.redirect.as_deref().and_then(|r| Regex::new(r).ok());
118 for f in files {
119 if !m.is_match(f) || skip(f, claimed) {
120 continue;
121 }
122 if rule.redirect.is_some() && !is_stub(root, f, stub_re.as_ref()) {
123 continue;
124 }
125 claimed.insert(f.clone());
126 if rule.wrap && !f.ends_with(".md") {
127 out.push(Unit {
128 rule: ri,
129 source: f.clone(),
130 main: String::new(),
131 chapters: vec![],
132 attachments: vec![f.clone()],
133 });
134 continue;
135 }
136 let (mut chapters, mut attachments) = (vec![], vec![]);
137 if rule.sibling_dir {
138 let dir = f.trim_end_matches(".md");
139 let taken: Vec<String> = inside(files, dir, claimed).into_iter().cloned().collect();
140 for g in taken {
141 if g.ends_with(".md") {
142 chapters.push(g.clone())
143 } else {
144 attachments.push(g.clone())
145 }
146 claimed.insert(g);
147 }
148 }
149 out.push(Unit { rule: ri, source: f.clone(), main: f.clone(), chapters, attachments });
150 }
151 out
152}