switchback_traits/
companion.rs1use std::collections::BTreeMap;
4use std::path::{Component, Path, PathBuf};
5
6use crate::traits::CompanionStrategy;
7use crate::{CompanionFile, Result, SwitchbackError};
8
9pub fn companion_output_name_from_segments(source_dir: &[&str], stem: &str) -> String {
11 if source_dir.is_empty() {
12 format!("{stem}.md")
13 } else {
14 format!("{}.{}.md", source_dir.join("."), stem)
15 }
16}
17
18pub fn companion_output_name_from_path(rel_dir: &Path, stem: &str) -> String {
20 let segments: Vec<String> = path_segments(rel_dir);
21 let segment_refs: Vec<&str> = segments.iter().map(String::as_str).collect();
22 companion_output_name_from_segments(&segment_refs, stem)
23}
24
25pub fn path_segments(rel_dir: &Path) -> Vec<String> {
27 rel_dir
28 .components()
29 .filter_map(|c| match c {
30 Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
31 _ => None,
32 })
33 .collect()
34}
35
36pub fn normalize_rel_dir(path: &Path) -> PathBuf {
38 PathBuf::from(path_segments(path).join("/"))
39}
40
41pub fn source_dir_string(dir: &Path) -> String {
43 path_segments(dir).join("/")
44}
45
46pub fn title_from_markdown(stem: &str, content: &[u8]) -> String {
48 let text = String::from_utf8_lossy(content);
49 for line in text.lines() {
50 let line = line.trim();
51 if let Some(rest) = line.strip_prefix('#') {
52 let title = rest.trim_start_matches('#').trim();
53 if !title.is_empty() {
54 return title.to_string();
55 }
56 }
57 }
58 humanize_stem(stem)
59}
60
61fn humanize_stem(stem: &str) -> String {
62 if stem.eq_ignore_ascii_case("readme") {
63 return "README".to_string();
64 }
65 stem.replace(['-', '_'], " ")
66}
67
68pub fn module_path_from_output(output_rel: &str, stem: &str) -> Option<String> {
72 let base = output_rel.strip_suffix(".md")?;
73 let suffix = format!(".{stem}");
74 base.strip_suffix(&suffix).map(str::to_string)
75}
76
77pub fn source_dir_from_output(output_rel: &str, stem: &str) -> String {
79 module_path_from_output(output_rel, stem)
80 .map(|p| p.replace('.', "/"))
81 .unwrap_or_default()
82}
83
84pub fn discover_ancestors_companions<S: CompanionStrategy>(
86 strategy: &S,
87 companion_extensions: &[&str],
88 anchor_dirs: &[PathBuf],
89 search_roots: &[PathBuf],
90) -> Result<Vec<CompanionFile>> {
91 let roots = if search_roots.is_empty() {
92 vec![PathBuf::from(".")]
93 } else {
94 search_roots.to_vec()
95 };
96
97 let mut seen = BTreeMap::new();
98 for anchor in anchor_dirs {
99 let mut dir = normalize_rel_dir(anchor);
100 loop {
101 if !dir.as_os_str().is_empty() {
102 collect_md_in_dir(strategy, companion_extensions, &dir, &roots, &mut seen)?;
103 }
104 if dir.as_os_str().is_empty() {
105 break;
106 }
107 if !dir.pop() {
108 break;
109 }
110 }
111 }
112
113 Ok(seen.into_values().collect())
114}
115
116fn collect_md_in_dir<S: CompanionStrategy>(
117 strategy: &S,
118 companion_extensions: &[&str],
119 dir: &Path,
120 search_roots: &[PathBuf],
121 seen: &mut BTreeMap<String, CompanionFile>,
122) -> Result<()> {
123 let fs_dir = search_roots
124 .iter()
125 .map(|r| r.join(dir))
126 .find(|p| p.is_dir());
127 let Some(fs_dir) = fs_dir else {
128 return Ok(());
129 };
130
131 for entry in std::fs::read_dir(&fs_dir).map_err(|e| SwitchbackError::load(e.to_string()))? {
132 let entry = entry.map_err(|e| SwitchbackError::load(e.to_string()))?;
133 let path = entry.path();
134 if !path.is_file() {
135 continue;
136 }
137 let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
138 continue;
139 };
140 if !companion_extensions
141 .iter()
142 .any(|allowed| ext.eq_ignore_ascii_case(allowed.trim_start_matches('.')))
143 {
144 continue;
145 }
146 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
147 continue;
148 };
149 if stem.starts_with('.') {
150 continue;
151 }
152 let stem = stem.to_string();
153 let rel_dir = normalize_rel_dir(dir);
154 let segments = path_segments(&rel_dir);
155 let segment_refs: Vec<&str> = segments.iter().map(String::as_str).collect();
156 let output_name = strategy.output_name(&segment_refs, &stem);
157 if seen.contains_key(&output_name) {
158 continue;
159 }
160 let bytes = std::fs::read(&path).map_err(|e| SwitchbackError::load(e.to_string()))?;
161 let title = strategy.companion_title(&stem, &bytes);
162 seen.insert(
163 output_name.clone(),
164 CompanionFile {
165 output_name,
166 bytes,
167 source_path: path,
168 title,
169 source_dir: source_dir_string(&rel_dir),
170 stem,
171 },
172 );
173 }
174 Ok(())
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180 use crate::{CompanionDiscovery, CompanionStrategy};
181 use std::fs;
182 use tempfile::TempDir;
183
184 struct TestCompanion;
185
186 impl CompanionStrategy for TestCompanion {
187 fn discovery(&self) -> CompanionDiscovery {
188 CompanionDiscovery::Ancestors
189 }
190
191 fn output_name(&self, source_dir: &[&str], stem: &str) -> String {
192 companion_output_name_from_segments(source_dir, stem)
193 }
194
195 fn companion_media_types(&self) -> &'static [&'static str] {
196 &["text/markdown"]
197 }
198 }
199
200 #[test]
201 fn module_path_from_output_parses() {
202 assert_eq!(
203 module_path_from_output("acme.example.v1.README.md", "README"),
204 Some("acme.example.v1".into())
205 );
206 assert_eq!(
207 module_path_from_output("acme.README.md", "README"),
208 Some("acme".into())
209 );
210 }
211
212 #[test]
213 fn title_from_markdown_uses_heading() {
214 assert_eq!(title_from_markdown("README", b"# Acme APIs\n"), "Acme APIs");
215 assert_eq!(
216 title_from_markdown("MOVING-TO-V2", b"# Moving to v2\n"),
217 "Moving to v2"
218 );
219 }
220
221 #[test]
222 fn discovers_intermediate_and_leaf_companions() {
223 let tmp = TempDir::new().unwrap();
224 let root = tmp.path();
225 fs::create_dir_all(root.join("acme/example/v1")).unwrap();
226 fs::create_dir_all(root.join("acme/example/v2")).unwrap();
227 fs::write(root.join("acme/README.md"), "# Acme\n").unwrap();
228 fs::write(root.join("acme/example/README.md"), "# Example\n").unwrap();
229 fs::write(root.join("acme/example/v1/README.md"), "# V1\n").unwrap();
230 fs::write(root.join("acme/example/v1/MOVING-TO-V2.md"), "# Moving\n").unwrap();
231
232 let anchors = vec![
233 PathBuf::from("acme/example/v1"),
234 PathBuf::from("acme/example/v2"),
235 ];
236 let docs =
237 discover_ancestors_companions(&TestCompanion, &["md"], &anchors, &[root.to_path_buf()])
238 .unwrap();
239 let names: Vec<_> = docs.iter().map(|d| d.output_name.as_str()).collect();
240 assert!(names.contains(&"acme.README.md"));
241 assert!(names.contains(&"acme.example.README.md"));
242 assert!(names.contains(&"acme.example.v1.README.md"));
243 assert!(names.contains(&"acme.example.v1.MOVING-TO-V2.md"));
244 let acme = docs
245 .iter()
246 .find(|d| d.output_name == "acme.README.md")
247 .unwrap();
248 assert_eq!(acme.title, "Acme");
249 assert_eq!(acme.source_dir, "acme");
250 assert_eq!(acme.stem, "README");
251 }
252
253 #[test]
254 fn partial_inputs_skip_other_branch() {
255 let tmp = TempDir::new().unwrap();
256 let root = tmp.path();
257 fs::create_dir_all(root.join("a/b/c/d/e/f/g/h/v1")).unwrap();
258 fs::create_dir_all(root.join("a/b/c/d/e/f/g/h/v2")).unwrap();
259 fs::write(root.join("a/b/NOTES.md"), "# Notes\n").unwrap();
260 fs::write(root.join("a/b/c/d/e/f/g/h/v2/more-notes.md"), "# More\n").unwrap();
261
262 let anchors = vec![PathBuf::from("a/b/c/d/e/f/g/h/v1")];
263 let docs =
264 discover_ancestors_companions(&TestCompanion, &["md"], &anchors, &[root.to_path_buf()])
265 .unwrap();
266 let names: Vec<_> = docs.iter().map(|d| d.output_name.as_str()).collect();
267 assert!(names.contains(&"a.b.NOTES.md"));
268 assert!(!names.iter().any(|n| n.contains("more-notes")));
269 }
270}