use wsb::prelude::*;
use ignore::WalkBuilder;
use std::time::UNIX_EPOCH;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PathNote {
pub path: String,
pub title: String,
pub links: Vec<String>,
pub updated_at: String,
}
pub fn extract_link_targets(content: &str) -> Vec<String> {
let bytes = content.as_bytes();
let mut targets = Vec::new();
let mut i = 0;
while i < bytes.len() {
if i + 1 < bytes.len() && bytes[i] == b']' && bytes[i + 1] == b'(' {
i += 2;
let start = i;
while i < bytes.len()
&& bytes[i] != b')'
&& bytes[i] != b'"'
&& bytes[i] != b' '
&& bytes[i] != b'\n'
{
i += 1;
}
let target = &content[start..i];
if !target.is_empty()
&& !target.starts_with("http://")
&& !target.starts_with("https://")
&& !target.starts_with('#')
{
let normalised = target.strip_prefix('/').unwrap_or(target);
if !normalised.is_empty() {
targets.push(normalised.to_string());
}
}
} else {
i += 1;
}
}
targets
}
pub fn note_mentions_path(content: &str, needle: &str) -> bool {
for prefix in [format!("]({needle}"), format!("](/{needle}")] {
let mut start = 0;
while let Some(pos) = content[start..].find(&prefix) {
let abs = start + pos + prefix.len();
match content.as_bytes().get(abs) {
Some(b')') | Some(b'#') | Some(b' ') | Some(b'"') | None => return true,
_ => {}
}
start += pos + prefix.len();
}
}
false
}
pub fn extract_title(content: &str, path: &str) -> String {
for line in content.lines() {
if line.starts_with('#') {
let trimmed = line.trim_start_matches('#').trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
}
}
Path::new(path)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(path)
.to_string()
}
fn mtime_iso(meta: &std::fs::Metadata) -> String {
meta.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.and_then(|d| chrono::DateTime::from_timestamp(d.as_secs() as i64, 0))
.map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string())
.unwrap_or_default()
}
pub fn implicit_owner(note_path: &str) -> Option<String> {
let p = Path::new(note_path);
let mut components: Vec<_> = p.components().collect();
if components.len() < 3 {
return None;
}
let docs_component = components[components.len() - 2];
if docs_component.as_os_str() != "docs" {
return None;
}
components.truncate(components.len() - 2);
let owner: PathBuf = components.iter().collect();
let s = wsb::platform::slash_path(&owner);
if s.is_empty() {
None
} else {
Some(s)
}
}
fn make_path_note(rel_path: &str, content: &str, meta: &std::fs::Metadata) -> PathNote {
let mut links = extract_link_targets(content);
if let Some(owner) = implicit_owner(rel_path) {
if !links.contains(&owner) {
links.push(owner);
}
}
PathNote {
path: rel_path.to_string(),
title: extract_title(content, rel_path),
links,
updated_at: mtime_iso(meta),
}
}
fn walk_markdown_files(
workspace_root: &Path,
mut cb: impl FnMut(&str, &str, &std::fs::Metadata) -> Result<bool>,
) -> Result<()> {
let walker = WalkBuilder::new(workspace_root)
.standard_filters(true)
.build();
for entry in walker {
let entry = entry.context("directory walk error")?;
if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
continue;
}
let path = entry.path();
match path.extension().and_then(|e| e.to_str()) {
Some("md") | Some("MD") | Some("markdown") => {}
_ => continue,
}
let rel = wsb::platform::slash_path(
path.strip_prefix(workspace_root)
.context("walk returned path outside workspace root")?,
);
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
let meta = path
.metadata()
.with_context(|| format!("failed to stat {}", path.display()))?;
if !cb(&rel, &content, &meta)? {
break;
}
}
Ok(())
}
pub fn list_path_notes(workspace_root: &Path, linked_path: &str) -> Result<Vec<PathNote>> {
let mut notes = Vec::new();
walk_markdown_files(workspace_root, |rel, content, meta| {
let matches = note_mentions_path(content, linked_path)
|| implicit_owner(rel).as_deref() == Some(linked_path);
if matches {
notes.push(make_path_note(rel, content, meta));
}
Ok(true)
})?;
notes.sort_by(|a, b| a.path.cmp(&b.path));
Ok(notes)
}
pub fn list_all_notes(workspace_root: &Path) -> Result<Vec<PathNote>> {
let mut notes = Vec::new();
walk_markdown_files(workspace_root, |rel, content, meta| {
notes.push(make_path_note(rel, content, meta));
Ok(true)
})?;
notes.sort_by(|a, b| a.path.cmp(&b.path));
Ok(notes)
}
pub fn docs_dir_for_path<'a>(linked_path: &str, submodule_paths: &'a [String]) -> &'a str {
let mut owner: Option<&'a str> = None;
for sub in submodule_paths {
let prefix = format!("{sub}/");
if linked_path.starts_with(&prefix) || linked_path == sub.as_str() {
if owner.is_none_or(|current| sub.len() > current.len()) {
owner = Some(sub.as_str());
}
}
}
owner.unwrap_or("")
}
pub fn create_path_note(
workspace_root: &Path,
linked_path: &str,
submodule_paths: &[String],
) -> Result<PathNote> {
let sub = docs_dir_for_path(linked_path, submodule_paths);
let docs_dir = if sub.is_empty() {
workspace_root.join("docs")
} else {
workspace_root.join(sub).join("docs")
};
std::fs::create_dir_all(&docs_dir)
.with_context(|| format!("failed to create docs dir {}", docs_dir.display()))?;
let slug = path_to_slug(linked_path);
let (filename, abs_path) = unique_filename(&docs_dir, &slug);
let content = format!("Note on [{linked_path}]({linked_path})\n");
std::fs::write(&abs_path, &content)
.with_context(|| format!("failed to write note {}", abs_path.display()))?;
let meta = abs_path
.metadata()
.with_context(|| format!("failed to stat {}", abs_path.display()))?;
let rel_path = if sub.is_empty() {
format!("docs/{filename}")
} else {
format!("{sub}/docs/{filename}")
};
Ok(make_path_note(&rel_path, &content, &meta))
}
fn path_to_slug(path: &str) -> String {
let s = path
.replace(['/', '\\'], "-")
.chars()
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_' || *c == '.')
.collect::<String>();
s.trim_matches('-').to_string()
}
fn unique_filename(dir: &Path, slug: &str) -> (String, PathBuf) {
let base = format!("{slug}.md");
let candidate = dir.join(&base);
if !candidate.exists() {
return (base, candidate);
}
let mut n: u32 = 1;
loop {
let name = format!("{slug}-{n}.md");
let p = dir.join(&name);
if !p.exists() {
return (name, p);
}
n += 1;
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn extract_link_targets_returns_relative_paths() {
let content = "See [main](src/main.rs) and [lib](src/lib.rs) for details.";
let targets = extract_link_targets(content);
assert_eq!(targets, vec!["src/main.rs", "src/lib.rs"]);
}
#[test]
fn extract_link_targets_normalises_root_absolute_paths() {
let content = "See [capdag](/capdag/src/main.rs) and [wsb](wsb/src/lib.rs).";
let targets = extract_link_targets(content);
assert_eq!(targets, vec!["capdag/src/main.rs", "wsb/src/lib.rs"]);
}
#[test]
fn extract_link_targets_excludes_bare_root_slash() {
let content = "See [root](/).";
let targets = extract_link_targets(content);
assert!(targets.is_empty(), "got: {targets:?}");
}
#[test]
fn extract_link_targets_excludes_http_urls() {
let content = "Visit [site](https://example.com) or [docs](http://docs.rs).";
let targets = extract_link_targets(content);
assert!(targets.is_empty(), "got: {targets:?}");
}
#[test]
fn extract_link_targets_excludes_fragment_anchors() {
let content = "See [section](#heading) or [sub](doc.md#sub).";
let targets = extract_link_targets(content);
assert!(!targets.contains(&"#heading".to_string()));
assert!(
targets.contains(&"doc.md#sub".to_string()) || targets.contains(&"doc.md".to_string()),
"expected doc.md reference, got: {targets:?}"
);
}
#[test]
fn extract_link_targets_handles_link_with_title() {
let content = r#"[click](src/main.rs "go here")"#;
let targets = extract_link_targets(content);
assert_eq!(targets, vec!["src/main.rs"]);
}
#[test]
fn extract_link_targets_empty_on_plain_text() {
let content = "No links here at all.";
assert!(extract_link_targets(content).is_empty());
}
#[test]
fn note_mentions_path_exact_match() {
let content = "Note on [src/main.rs](src/main.rs)\n";
assert!(note_mentions_path(content, "src/main.rs"));
}
#[test]
fn note_mentions_path_with_anchor() {
let content = "See [header](src/main.rs#header) here.";
assert!(note_mentions_path(content, "src/main.rs"));
}
#[test]
fn note_mentions_path_with_title() {
let content = r#"[x](src/main.rs "title")"#;
assert!(note_mentions_path(content, "src/main.rs"));
}
#[test]
fn note_mentions_path_matches_root_absolute_form() {
let content = "See [capdag main](/capdag/src/main.rs) for details.";
assert!(note_mentions_path(content, "capdag/src/main.rs"));
}
#[test]
fn note_mentions_path_root_absolute_with_anchor() {
let content = "See [header](/capdag/src/main.rs#init) here.";
assert!(note_mentions_path(content, "capdag/src/main.rs"));
}
#[test]
fn note_mentions_path_root_absolute_no_false_positive_on_prefix() {
let content = "Note on [backup](/capdag/src/main.rs.bak)\n";
assert!(!note_mentions_path(content, "capdag/src/main.rs"));
}
#[test]
fn note_mentions_path_no_false_positive_on_prefix() {
let content = "Note on [backup](src/main.rs.bak)\n";
assert!(!note_mentions_path(content, "src/main.rs"));
}
#[test]
fn note_mentions_path_no_match_on_unrelated_content() {
let content = "Nothing about src/main.rs here.\n";
assert!(!note_mentions_path(content, "src/main.rs"));
}
#[test]
fn implicit_owner_returns_parent_of_docs_dir() {
assert_eq!(
implicit_owner("capdag/docs/overview.md").as_deref(),
Some("capdag")
);
assert_eq!(
implicit_owner("capdag-dot-com/docs/api.md").as_deref(),
Some("capdag-dot-com")
);
assert_eq!(implicit_owner("a/b/docs/file.md").as_deref(), Some("a/b"));
}
#[test]
fn implicit_owner_returns_none_for_root_docs() {
assert_eq!(implicit_owner("docs/overview.md"), None);
}
#[test]
fn implicit_owner_returns_none_for_non_docs_path() {
assert_eq!(implicit_owner("capdag/src/main.rs"), None);
assert_eq!(implicit_owner("README.md"), None);
}
#[test]
fn make_path_note_adds_implicit_owner_link_when_no_explicit_link() {
use std::fs;
let temp = TempDir::new().unwrap();
let docs = temp.path().join("capdag/docs");
fs::create_dir_all(&docs).unwrap();
fs::write(docs.join("spec.md"), "# Spec\nPure prose, no links.\n").unwrap();
let notes = list_all_notes(temp.path()).unwrap();
assert_eq!(notes.len(), 1);
assert!(
notes[0].links.contains(&"capdag".to_string()),
"implicit owner link missing, got: {:?}",
notes[0].links
);
}
#[test]
fn make_path_note_does_not_duplicate_explicit_link_to_owner() {
use std::fs;
let temp = TempDir::new().unwrap();
let docs = temp.path().join("capdag/docs");
fs::create_dir_all(&docs).unwrap();
fs::write(docs.join("note.md"), "Note on [capdag](capdag)\n").unwrap();
let notes = list_all_notes(temp.path()).unwrap();
assert_eq!(
notes[0].links.iter().filter(|l| *l == "capdag").count(),
1,
"duplicate implicit link, got: {:?}",
notes[0].links
);
}
#[test]
fn list_path_notes_finds_notes_without_explicit_link_via_implicit_owner() {
use std::fs;
let temp = TempDir::new().unwrap();
let docs = temp.path().join("capdag/docs");
fs::create_dir_all(&docs).unwrap();
fs::write(
docs.join("01-OVERVIEW.md"),
"# Overview\nThis is the capdag spec.\n",
)
.unwrap();
let notes = list_path_notes(temp.path(), "capdag").unwrap();
assert_eq!(
notes.len(),
1,
"expected note via implicit owner, got: {notes:?}"
);
assert_eq!(notes[0].path, "capdag/docs/01-OVERVIEW.md");
}
#[test]
fn list_path_notes_does_not_match_sibling_docs_dirs() {
use std::fs;
let temp = TempDir::new().unwrap();
fs::create_dir_all(temp.path().join("capdag/docs")).unwrap();
fs::create_dir_all(temp.path().join("candlecartridge/docs")).unwrap();
fs::write(temp.path().join("capdag/docs/spec.md"), "# Spec\n").unwrap();
fs::write(
temp.path().join("candlecartridge/docs/guide.md"),
"# Guide\n",
)
.unwrap();
let capdag_notes = list_path_notes(temp.path(), "capdag").unwrap();
assert_eq!(capdag_notes.len(), 1);
assert_eq!(capdag_notes[0].path, "capdag/docs/spec.md");
let cc_notes = list_path_notes(temp.path(), "candlecartridge").unwrap();
assert_eq!(cc_notes.len(), 1);
assert_eq!(cc_notes[0].path, "candlecartridge/docs/guide.md");
}
#[test]
fn extract_title_uses_first_heading() {
let content = "# My Note\nsome content";
assert_eq!(extract_title(content, "slug.md"), "My Note");
}
#[test]
fn extract_title_uses_h2_when_no_h1() {
let content = "## Section\nsome content";
assert_eq!(extract_title(content, "slug.md"), "Section");
}
#[test]
fn extract_title_falls_back_to_filename_stem() {
let content = "Just prose, no heading.";
assert_eq!(extract_title(content, "docs/src-main-rs.md"), "src-main-rs");
}
#[test]
fn extract_title_skips_empty_headings() {
let content = "#\n## Real Heading\ncontent";
assert_eq!(extract_title(content, "x.md"), "Real Heading");
}
#[test]
fn path_to_slug_converts_slashes_to_dashes() {
assert_eq!(path_to_slug("src/web/mod.rs"), "src-web-mod.rs");
}
#[test]
fn path_to_slug_strips_leading_trailing_dashes() {
assert_eq!(path_to_slug("/src/main.rs"), "src-main.rs");
}
#[test]
fn path_to_slug_removes_special_chars() {
assert_eq!(path_to_slug("src/foo bar.rs"), "src-foobar.rs");
}
#[test]
fn docs_dir_for_path_root_when_no_submodule_matches() {
let subs = vec!["wsb".to_string(), "wrapper".to_string()];
assert_eq!(docs_dir_for_path("README.md", &subs), "");
assert_eq!(docs_dir_for_path("docs/guide.md", &subs), "");
}
#[test]
fn docs_dir_for_path_returns_submodule_for_nested_path() {
let subs = vec!["wsb".to_string(), "wrapper".to_string()];
assert_eq!(docs_dir_for_path("wsb/src/main.rs", &subs), "wsb");
assert_eq!(
docs_dir_for_path("wrapper/Sources/Foo.swift", &subs),
"wrapper"
);
}
#[test]
fn test9616_the_innermost_submodule_owns_the_note() {
let subs = vec![
"capdag".to_string(),
"capdag/capdag-rs".to_string(),
"capdag/capdag-rs/vendor/dep".to_string(),
];
assert_eq!(docs_dir_for_path("capdag/README.md", &subs), "capdag");
assert_eq!(
docs_dir_for_path("capdag/capdag-rs/src/lib.rs", &subs),
"capdag/capdag-rs"
);
assert_eq!(
docs_dir_for_path("capdag/capdag-rs/vendor/dep/src/lib.rs", &subs),
"capdag/capdag-rs/vendor/dep"
);
assert_eq!(
docs_dir_for_path("capdag/capdag-rs", &subs),
"capdag/capdag-rs"
);
}
#[test]
fn docs_dir_for_path_exact_submodule_path() {
let subs = vec!["wsb".to_string()];
assert_eq!(docs_dir_for_path("wsb", &subs), "wsb");
}
#[test]
fn list_path_notes_finds_notes_linking_to_path() {
let temp = TempDir::new().unwrap();
let docs = temp.path().join("docs");
fs::create_dir_all(&docs).unwrap();
fs::write(
docs.join("note-on-main.md"),
"Note on [src/main.rs](src/main.rs)\n",
)
.unwrap();
fs::write(docs.join("other.md"), "Note on [src/lib.rs](src/lib.rs)\n").unwrap();
let notes = list_path_notes(temp.path(), "src/main.rs").unwrap();
assert_eq!(notes.len(), 1, "expected 1 note, got: {notes:?}");
assert_eq!(notes[0].path, "docs/note-on-main.md");
}
#[test]
fn list_path_notes_returns_empty_when_no_notes_match() {
let temp = TempDir::new().unwrap();
let docs = temp.path().join("docs");
fs::create_dir_all(&docs).unwrap();
fs::write(docs.join("other.md"), "# Unrelated\nNo links here.\n").unwrap();
let notes = list_path_notes(temp.path(), "src/main.rs").unwrap();
assert!(notes.is_empty());
}
#[test]
fn list_path_notes_scans_across_subdirectories() {
let temp = TempDir::new().unwrap();
fs::create_dir_all(temp.path().join("docs")).unwrap();
fs::create_dir_all(temp.path().join("wsb/docs")).unwrap();
fs::write(
temp.path().join("docs/root-note.md"),
"Note on [README.md](README.md)\n",
)
.unwrap();
fs::write(
temp.path().join("wsb/docs/sub-note.md"),
"Note on [README.md](README.md)\n",
)
.unwrap();
let notes = list_path_notes(temp.path(), "README.md").unwrap();
assert_eq!(notes.len(), 2, "expected 2 notes, got: {notes:?}");
let paths: Vec<&str> = notes.iter().map(|n| n.path.as_str()).collect();
assert!(paths.contains(&"docs/root-note.md"), "paths: {paths:?}");
assert!(paths.contains(&"wsb/docs/sub-note.md"), "paths: {paths:?}");
}
#[test]
fn list_path_notes_no_false_positive_on_path_prefix() {
let temp = TempDir::new().unwrap();
let docs = temp.path().join("docs");
fs::create_dir_all(&docs).unwrap();
fs::write(docs.join("note.md"), "Note on [backup](src/main.rs.bak)\n").unwrap();
let notes = list_path_notes(temp.path(), "src/main.rs").unwrap();
assert!(notes.is_empty(), "got: {notes:?}");
}
#[test]
fn list_all_notes_returns_every_md_file_with_links() {
let temp = TempDir::new().unwrap();
let docs = temp.path().join("docs");
fs::create_dir_all(&docs).unwrap();
fs::write(docs.join("a.md"), "Note on [src/main.rs](src/main.rs)\n").unwrap();
fs::write(docs.join("b.md"), "# Standalone\nNo links.\n").unwrap();
let notes = list_all_notes(temp.path()).unwrap();
assert_eq!(notes.len(), 2);
let a = notes.iter().find(|n| n.path.ends_with("a.md")).unwrap();
assert_eq!(a.links, vec!["src/main.rs"]);
let b = notes.iter().find(|n| n.path.ends_with("b.md")).unwrap();
assert!(b.links.is_empty());
}
#[test]
fn create_path_note_writes_to_root_docs_when_no_submodule_matches() {
let temp = TempDir::new().unwrap();
let note = create_path_note(temp.path(), "README.md", &[]).unwrap();
assert!(note.path.starts_with("docs/"), "got: {}", note.path);
let abs = temp.path().join(¬e.path);
assert!(abs.exists(), "file not found at {}", abs.display());
let content = fs::read_to_string(&abs).unwrap();
assert!(content.contains("](README.md)"), "content: {content}");
}
#[test]
fn create_path_note_writes_to_submodule_docs_when_path_is_inside_submodule() {
let temp = TempDir::new().unwrap();
fs::create_dir_all(temp.path().join("wsb/src")).unwrap();
let subs = vec!["wsb".to_string()];
let note = create_path_note(temp.path(), "wsb/src/main.rs", &subs).unwrap();
assert!(note.path.starts_with("wsb/docs/"), "got: {}", note.path);
let abs = temp.path().join(¬e.path);
assert!(abs.exists());
let content = fs::read_to_string(&abs).unwrap();
assert!(content.contains("](wsb/src/main.rs)"), "content: {content}");
}
#[test]
fn create_path_note_creates_docs_dir_if_missing() {
let temp = TempDir::new().unwrap();
assert!(!temp.path().join("docs").exists());
create_path_note(temp.path(), "src/lib.rs", &[]).unwrap();
assert!(temp.path().join("docs").exists());
}
#[test]
fn create_path_note_avoids_filename_collision() {
let temp = TempDir::new().unwrap();
let docs = temp.path().join("docs");
fs::create_dir_all(&docs).unwrap();
let slug = path_to_slug("src/main.rs");
fs::write(docs.join(format!("{slug}.md")), "existing\n").unwrap();
let note = create_path_note(temp.path(), "src/main.rs", &[]).unwrap();
assert!(
note.path.ends_with("-1.md"),
"expected -1 suffix, got {}",
note.path
);
}
#[test]
fn create_path_note_initial_content_links_back_to_path() {
let temp = TempDir::new().unwrap();
let note = create_path_note(temp.path(), "wsb/src/web/mod.rs", &[]).unwrap();
let abs = temp.path().join(¬e.path);
let content = fs::read_to_string(abs).unwrap();
assert_eq!(
content,
"Note on [wsb/src/web/mod.rs](wsb/src/web/mod.rs)\n"
);
}
#[test]
fn created_note_is_immediately_found_by_list_path_notes() {
let temp = TempDir::new().unwrap();
create_path_note(temp.path(), "src/main.rs", &[]).unwrap();
let found = list_path_notes(temp.path(), "src/main.rs").unwrap();
assert_eq!(found.len(), 1, "expected 1, got: {found:?}");
assert!(note_mentions_path(
&format!("Note on [src/main.rs](src/main.rs)\n"),
"src/main.rs"
));
}
}