Skip to main content

switchback_traits/
companion.rs

1//! Shared companion discovery helpers and nav metadata parsing.
2
3use std::collections::BTreeMap;
4use std::path::{Component, Path, PathBuf};
5
6use crate::traits::CompanionStrategy;
7use crate::{CompanionFile, Result, SwitchbackError};
8
9/// Dot-separated output filename for a companion under `source_dir` segments.
10pub 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
18/// Dot-separated output filename from a relative directory path and stem.
19pub 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
25/// Normal path components for a relative directory (drops `.` and `..`).
26pub 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
36/// Normalize a relative directory path to normal components only.
37pub fn normalize_rel_dir(path: &Path) -> PathBuf {
38    PathBuf::from(path_segments(path).join("/"))
39}
40
41/// Slash-separated source directory string for wire nav metadata.
42pub fn source_dir_string(dir: &Path) -> String {
43    path_segments(dir).join("/")
44}
45
46/// Title from the first `#` heading in markdown, else humanized stem.
47pub 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
68/// Dot-separated module path implied by a companion output filename.
69///
70/// `acme.example.v1.README.md` with stem `README` → `acme.example.v1`.
71pub 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
77/// Inverse of dot-encoded output name for legacy artifacts missing `source_dir`.
78pub 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
84/// Discover companion markdown by walking ancestor directories from each anchor.
85pub 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}