use std::collections::BTreeMap;
use std::path::{Component, Path, PathBuf};
use crate::traits::CompanionStrategy;
use crate::{CompanionFile, Result, SwitchbackError};
pub fn companion_output_name_from_segments(source_dir: &[&str], stem: &str) -> String {
if source_dir.is_empty() {
format!("{stem}.md")
} else {
format!("{}.{}.md", source_dir.join("."), stem)
}
}
pub fn companion_output_name_from_path(rel_dir: &Path, stem: &str) -> String {
let segments: Vec<String> = path_segments(rel_dir);
let segment_refs: Vec<&str> = segments.iter().map(String::as_str).collect();
companion_output_name_from_segments(&segment_refs, stem)
}
pub fn path_segments(rel_dir: &Path) -> Vec<String> {
rel_dir
.components()
.filter_map(|c| match c {
Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
_ => None,
})
.collect()
}
pub fn normalize_rel_dir(path: &Path) -> PathBuf {
PathBuf::from(path_segments(path).join("/"))
}
pub fn source_dir_string(dir: &Path) -> String {
path_segments(dir).join("/")
}
pub fn title_from_markdown(stem: &str, content: &[u8]) -> String {
let text = String::from_utf8_lossy(content);
for line in text.lines() {
let line = line.trim();
if let Some(rest) = line.strip_prefix('#') {
let title = rest.trim_start_matches('#').trim();
if !title.is_empty() {
return title.to_string();
}
}
}
humanize_stem(stem)
}
fn humanize_stem(stem: &str) -> String {
if stem.eq_ignore_ascii_case("readme") {
return "README".to_string();
}
stem.replace(['-', '_'], " ")
}
pub fn module_path_from_output(output_rel: &str, stem: &str) -> Option<String> {
let base = output_rel.strip_suffix(".md")?;
let suffix = format!(".{stem}");
base.strip_suffix(&suffix).map(str::to_string)
}
pub fn source_dir_from_output(output_rel: &str, stem: &str) -> String {
module_path_from_output(output_rel, stem)
.map(|p| p.replace('.', "/"))
.unwrap_or_default()
}
pub fn discover_ancestors_companions<S: CompanionStrategy>(
strategy: &S,
companion_extensions: &[&str],
anchor_dirs: &[PathBuf],
search_roots: &[PathBuf],
) -> Result<Vec<CompanionFile>> {
let roots = if search_roots.is_empty() {
vec![PathBuf::from(".")]
} else {
search_roots.to_vec()
};
let mut seen = BTreeMap::new();
for anchor in anchor_dirs {
let mut dir = normalize_rel_dir(anchor);
loop {
if !dir.as_os_str().is_empty() {
collect_md_in_dir(strategy, companion_extensions, &dir, &roots, &mut seen)?;
}
if dir.as_os_str().is_empty() {
break;
}
if !dir.pop() {
break;
}
}
}
Ok(seen.into_values().collect())
}
fn collect_md_in_dir<S: CompanionStrategy>(
strategy: &S,
companion_extensions: &[&str],
dir: &Path,
search_roots: &[PathBuf],
seen: &mut BTreeMap<String, CompanionFile>,
) -> Result<()> {
let fs_dir = search_roots
.iter()
.map(|r| r.join(dir))
.find(|p| p.is_dir());
let Some(fs_dir) = fs_dir else {
return Ok(());
};
for entry in std::fs::read_dir(&fs_dir).map_err(|e| SwitchbackError::load(e.to_string()))? {
let entry = entry.map_err(|e| SwitchbackError::load(e.to_string()))?;
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
continue;
};
if !companion_extensions
.iter()
.any(|allowed| ext.eq_ignore_ascii_case(allowed.trim_start_matches('.')))
{
continue;
}
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
if stem.starts_with('.') {
continue;
}
let stem = stem.to_string();
let rel_dir = normalize_rel_dir(dir);
let segments = path_segments(&rel_dir);
let segment_refs: Vec<&str> = segments.iter().map(String::as_str).collect();
let output_name = strategy.output_name(&segment_refs, &stem);
if seen.contains_key(&output_name) {
continue;
}
let bytes = std::fs::read(&path).map_err(|e| SwitchbackError::load(e.to_string()))?;
let title = strategy.companion_title(&stem, &bytes);
seen.insert(
output_name.clone(),
CompanionFile {
output_name,
bytes,
source_path: path,
title,
source_dir: source_dir_string(&rel_dir),
stem,
},
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{CompanionDiscovery, CompanionStrategy};
use std::fs;
use tempfile::TempDir;
struct TestCompanion;
impl CompanionStrategy for TestCompanion {
fn discovery(&self) -> CompanionDiscovery {
CompanionDiscovery::Ancestors
}
fn output_name(&self, source_dir: &[&str], stem: &str) -> String {
companion_output_name_from_segments(source_dir, stem)
}
fn companion_media_types(&self) -> &'static [&'static str] {
&["text/markdown"]
}
}
#[test]
fn module_path_from_output_parses() {
assert_eq!(
module_path_from_output("acme.example.v1.README.md", "README"),
Some("acme.example.v1".into())
);
assert_eq!(
module_path_from_output("acme.README.md", "README"),
Some("acme".into())
);
}
#[test]
fn title_from_markdown_uses_heading() {
assert_eq!(title_from_markdown("README", b"# Acme APIs\n"), "Acme APIs");
assert_eq!(
title_from_markdown("MOVING-TO-V2", b"# Moving to v2\n"),
"Moving to v2"
);
}
#[test]
fn discovers_intermediate_and_leaf_companions() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join("acme/example/v1")).unwrap();
fs::create_dir_all(root.join("acme/example/v2")).unwrap();
fs::write(root.join("acme/README.md"), "# Acme\n").unwrap();
fs::write(root.join("acme/example/README.md"), "# Example\n").unwrap();
fs::write(root.join("acme/example/v1/README.md"), "# V1\n").unwrap();
fs::write(root.join("acme/example/v1/MOVING-TO-V2.md"), "# Moving\n").unwrap();
let anchors = vec![
PathBuf::from("acme/example/v1"),
PathBuf::from("acme/example/v2"),
];
let docs =
discover_ancestors_companions(&TestCompanion, &["md"], &anchors, &[root.to_path_buf()])
.unwrap();
let names: Vec<_> = docs.iter().map(|d| d.output_name.as_str()).collect();
assert!(names.contains(&"acme.README.md"));
assert!(names.contains(&"acme.example.README.md"));
assert!(names.contains(&"acme.example.v1.README.md"));
assert!(names.contains(&"acme.example.v1.MOVING-TO-V2.md"));
let acme = docs
.iter()
.find(|d| d.output_name == "acme.README.md")
.unwrap();
assert_eq!(acme.title, "Acme");
assert_eq!(acme.source_dir, "acme");
assert_eq!(acme.stem, "README");
}
#[test]
fn partial_inputs_skip_other_branch() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join("a/b/c/d/e/f/g/h/v1")).unwrap();
fs::create_dir_all(root.join("a/b/c/d/e/f/g/h/v2")).unwrap();
fs::write(root.join("a/b/NOTES.md"), "# Notes\n").unwrap();
fs::write(root.join("a/b/c/d/e/f/g/h/v2/more-notes.md"), "# More\n").unwrap();
let anchors = vec![PathBuf::from("a/b/c/d/e/f/g/h/v1")];
let docs =
discover_ancestors_companions(&TestCompanion, &["md"], &anchors, &[root.to_path_buf()])
.unwrap();
let names: Vec<_> = docs.iter().map(|d| d.output_name.as_str()).collect();
assert!(names.contains(&"a.b.NOTES.md"));
assert!(!names.iter().any(|n| n.contains("more-notes")));
}
}