use crate::notes::title_of;
use std::collections::HashMap;
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
#[derive(Clone)]
pub struct Entry {
pub path: PathBuf,
pub title: String,
pub rel: String,
pub folder: String,
pub modified: SystemTime,
}
impl Entry {
pub fn name(&self) -> String {
self.path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default()
}
}
pub(crate) const MAX_FILES: usize = 8000;
pub(crate) const MAX_DEPTH: usize = 10;
const TITLE_BYTES: usize = 4096;
pub(crate) fn skip_dir(name: &str) -> bool {
name.starts_with('.') || matches!(name, "node_modules" | "target" | "attachments")
}
pub(crate) fn title_at(path: &Path) -> String {
let mut buf = vec![0u8; TITLE_BYTES];
let read = fs::File::open(path)
.and_then(|mut f| f.read(&mut buf))
.unwrap_or(0);
title_of(&String::from_utf8_lossy(&buf[..read]))
}
pub fn folder_of(path: &Path, notes_dir: &Path) -> String {
let parent = path.parent().unwrap_or(path);
match parent.strip_prefix(notes_dir) {
Ok(rel) => rel.to_string_lossy().into_owned(),
Err(_) => short(parent),
}
}
pub fn short(path: &Path) -> String {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));
match path.strip_prefix(&home) {
Ok(rel) => format!("~/{}", rel.display()),
Err(_) => path.display().to_string(),
}
}
pub fn age(modified: SystemTime, now: SystemTime) -> String {
let Ok(since) = now.duration_since(modified) else {
return "today".to_string();
};
let days = since.as_secs() / 86_400;
match days {
0 => "today".to_string(),
1..=6 => format!("{days}d"),
7..=29 => format!("{}w", days / 7),
30..=364 => format!("{}mo", days / 30),
_ => format!("{}y", days / 365),
}
}
pub fn scan(roots: &[PathBuf], recent: &[PathBuf]) -> Vec<Entry> {
let home_root = roots.first().cloned().unwrap_or_default();
let home_root = fs::canonicalize(&home_root).unwrap_or(home_root);
let mut seen: HashMap<PathBuf, ()> = HashMap::new();
let mut entries: Vec<Entry> = Vec::new();
for root in roots {
let root = fs::canonicalize(root).unwrap_or_else(|_| root.clone());
let mut stack = vec![(root.clone(), 0usize)];
while let Some((dir, depth)) = stack.pop() {
if depth > MAX_DEPTH || entries.len() >= MAX_FILES {
continue;
}
let Ok(read) = fs::read_dir(&dir) else {
continue;
};
for entry in read.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
if is_dir {
if !skip_dir(name) {
stack.push((path, depth + 1));
}
continue;
}
if name.starts_with('.') || !name.ends_with(".md") {
continue;
}
if entries.len() >= MAX_FILES || seen.insert(path.clone(), ()).is_some() {
continue;
}
let modified = entry
.metadata()
.and_then(|m| m.modified())
.unwrap_or(SystemTime::UNIX_EPOCH);
let rel = path
.strip_prefix(&root)
.unwrap_or(&path)
.to_string_lossy()
.into_owned();
let folder = folder_of(&path, &home_root);
entries.push(Entry {
title: title_at(&path),
path,
rel,
folder,
modified,
});
}
}
}
for path in recent {
if entries.len() >= MAX_FILES {
break;
}
let path = fs::canonicalize(path).unwrap_or_else(|_| path.clone());
if !path.is_file() || seen.insert(path.clone(), ()).is_some() {
continue;
}
let modified = fs::metadata(&path)
.and_then(|m| m.modified())
.unwrap_or(SystemTime::UNIX_EPOCH);
entries.push(Entry {
title: title_at(&path),
rel: short(&path),
folder: folder_of(&path, &home_root),
path,
modified,
});
}
let rank: HashMap<&Path, usize> = recent
.iter()
.enumerate()
.map(|(i, p)| (p.as_path(), i))
.collect();
entries.sort_by(|a, b| {
let ra = rank.get(a.path.as_path()).copied();
let rb = rank.get(b.path.as_path()).copied();
match (ra, rb) {
(Some(x), Some(y)) => x.cmp(&y),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => b.modified.cmp(&a.modified),
}
});
entries
}
pub fn link_keys(entry: &Entry) -> Vec<String> {
let mut keys = vec![crate::md::link_key(&entry.title)];
if let Some(stem) = entry.path.file_stem().and_then(|s| s.to_str()) {
keys.push(crate::md::link_key(stem));
}
let rel = crate::md::link_key(&entry.rel);
keys.push(rel.clone());
for (i, _) in rel.match_indices('/') {
keys.push(rel[i + 1..].to_string());
}
keys.retain(|k| !k.is_empty());
keys
}
pub fn resolve<'a>(entries: &'a [Entry], target: &str) -> Option<&'a Entry> {
let want = crate::md::link_key(target);
if want.is_empty() {
return None;
}
entries
.iter()
.filter_map(|e| rank(e, &want).map(|r| (r, e)))
.min_by_key(|(r, e)| (*r, e.rel.chars().count(), e.rel.clone()))
.map(|(_, e)| e)
}
fn rank(entry: &Entry, want: &str) -> Option<u8> {
if entry
.path
.file_stem()
.and_then(|s| s.to_str())
.is_some_and(|s| crate::md::link_key(s) == want)
{
return Some(0);
}
if crate::md::link_key(&entry.title) == want {
return Some(1);
}
let rel = crate::md::link_key(&entry.rel);
if rel == want || rel.ends_with(&format!("/{want}")) {
return Some(2);
}
None
}
const MAX_RECENT: usize = 100;
fn recent_path() -> Option<PathBuf> {
crate::config::config_dir().ok().map(|d| d.join("recent"))
}
pub fn load_recent() -> Vec<PathBuf> {
let Some(path) = recent_path() else {
return Vec::new();
};
let Ok(text) = fs::read_to_string(path) else {
return Vec::new();
};
let settings = crate::config::settings_path().ok();
text.lines()
.map(|l| PathBuf::from(l.trim()))
.filter(|p| !p.as_os_str().is_empty() && p.exists())
.filter(|p| settings.as_ref() != Some(p))
.take(MAX_RECENT)
.collect()
}
pub fn push_recent(recent: &mut Vec<PathBuf>, path: &Path) {
let path = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
recent.retain(|p| p != &path);
recent.insert(0, path);
recent.truncate(MAX_RECENT);
if let Some(file) = recent_path() {
if let Some(dir) = file.parent() {
let _ = fs::create_dir_all(dir);
}
let body: String = recent
.iter()
.map(|p| format!("{}\n", p.display()))
.collect();
let _ = fs::write(file, body);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn tmpdir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("catcher-index-{name}"));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn the_walk_reaches_subfolders_and_skips_dotted_ones() {
let dir = tmpdir("walk");
fs::create_dir_all(dir.join("applications")).unwrap();
fs::create_dir_all(dir.join(".obsidian")).unwrap();
fs::write(dir.join("top.md"), "# Top\n").unwrap();
fs::write(dir.join("applications/log.md"), "# Job Log\nbody").unwrap();
fs::write(dir.join(".obsidian/hidden.md"), "# Hidden\n").unwrap();
fs::write(dir.join("not-a-note.txt"), "x").unwrap();
let found = scan(std::slice::from_ref(&dir), &[]);
let titles: Vec<&str> = found.iter().map(|e| e.title.as_str()).collect();
assert!(titles.contains(&"Top"));
assert!(titles.contains(&"Job Log"));
assert!(!titles.contains(&"Hidden"));
let log = found.iter().find(|e| e.title == "Job Log").unwrap();
assert_eq!(log.rel, "applications/log.md");
assert_eq!(log.folder, "applications");
let top = found.iter().find(|e| e.title == "Top").unwrap();
assert_eq!(top.folder, "");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn recently_opened_notes_come_first() {
let dir = tmpdir("recents");
fs::write(dir.join("a.md"), "# A\n").unwrap();
fs::write(dir.join("b.md"), "# B\n").unwrap();
fs::write(dir.join("c.md"), "# C\n").unwrap();
let recent = vec![
fs::canonicalize(dir.join("c.md")).unwrap(),
fs::canonicalize(dir.join("a.md")).unwrap(),
];
let found = scan(std::slice::from_ref(&dir), &recent);
let titles: Vec<&str> = found.iter().map(|e| e.title.as_str()).collect();
assert_eq!(&titles[..2], &["C", "A"]);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn a_recently_opened_note_is_offered_even_from_a_folder_that_is_not_scanned() {
let dir = tmpdir("elsewhere");
fs::create_dir_all(dir.join("notes")).unwrap();
fs::create_dir_all(dir.join("vault/applications")).unwrap();
fs::write(dir.join("notes/a.md"), "# A\n").unwrap();
let far = dir.join("vault/applications/log.md");
fs::write(&far, "# Job Application Log\n").unwrap();
let found = scan(std::slice::from_ref(&dir.join("notes")), &[]);
assert!(!found.iter().any(|e| e.title == "Job Application Log"));
let recent = vec![fs::canonicalize(&far).unwrap()];
let found = scan(&[dir.join("notes")], &recent);
assert_eq!(found[0].title, "Job Application Log");
assert_eq!(found[0].path, fs::canonicalize(&far).unwrap());
assert!(
found[0].folder.ends_with("vault/applications"),
"{}",
found[0].folder
);
assert!(found[0].folder.starts_with('~') || found[0].folder.starts_with('/'));
assert!(crate::search::fuzzy("applications/log", &found[0].rel).is_some());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn a_recent_note_that_has_been_deleted_is_dropped_rather_than_offered() {
let dir = tmpdir("gone");
fs::write(dir.join("a.md"), "# A\n").unwrap();
let recent = vec![dir.join("deleted.md")];
let found = scan(std::slice::from_ref(&dir), &recent);
assert_eq!(found.len(), 1);
assert_eq!(found[0].title, "A");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn a_note_reached_by_both_the_walk_and_the_recents_appears_once() {
let dir = tmpdir("dedup");
let path = dir.join("a.md");
fs::write(&path, "# A\n").unwrap();
let recent = vec![fs::canonicalize(&path).unwrap()];
let found = scan(std::slice::from_ref(&dir), &recent);
assert_eq!(found.len(), 1);
let _ = fs::remove_dir_all(&dir);
}
fn entry(rel: &str, title: &str) -> Entry {
Entry {
path: PathBuf::from("/vault").join(rel),
title: title.to_string(),
rel: rel.to_string(),
folder: String::new(),
modified: SystemTime::UNIX_EPOCH,
}
}
#[test]
fn a_wikilink_resolves_by_filename_stem_before_title() {
let entries = vec![
entry("notes/spec.md", "The Deploy Spec"),
entry("a.md", "Spec"),
];
assert_eq!(resolve(&entries, "spec").unwrap().rel, "notes/spec.md");
}
#[test]
fn a_wikilink_resolves_a_path_suffix_at_a_folder_boundary() {
let entries = vec![entry("interviews/stories/story-matrix.md", "Story Matrix")];
assert_eq!(
resolve(&entries, "stories/story-matrix").unwrap().rel,
"interviews/stories/story-matrix.md"
);
assert!(resolve(&entries, "matrix").is_none());
}
#[test]
fn an_ambiguous_wikilink_resolves_to_the_shortest_path() {
let entries = vec![
entry("archive/2024/old/spec.md", "Old Spec"),
entry("spec.md", "Spec"),
entry("work/spec.md", "Work Spec"),
];
assert_eq!(resolve(&entries, "spec").unwrap().rel, "spec.md");
}
#[test]
fn a_wikilink_target_is_matched_without_its_case_or_its_md_suffix() {
let entries = vec![entry("Story-Matrix.md", "Story Matrix")];
for target in ["story-matrix", "Story-Matrix.md", "story-matrix#Method"] {
assert!(resolve(&entries, target).is_some(), "{target}");
}
}
#[test]
fn a_wikilink_with_no_note_behind_it_resolves_to_nothing() {
let entries = vec![entry("a.md", "A")];
assert!(resolve(&entries, "nowhere").is_none());
assert!(resolve(&entries, " ").is_none());
}
#[test]
fn every_name_a_note_answers_to_is_in_its_link_keys() {
let keys = link_keys(&entry("interviews/stories/story-matrix.md", "Story Matrix"));
for want in [
"story-matrix",
"story matrix",
"stories/story-matrix",
"interviews/stories/story-matrix",
] {
assert!(
keys.contains(&want.to_string()),
"{want} missing from {keys:?}"
);
}
let entries = vec![entry("interviews/stories/story-matrix.md", "Story Matrix")];
for k in &keys {
assert!(resolve(&entries, k).is_some(), "{k}");
}
}
#[test]
fn an_age_reads_as_the_fewest_characters_that_still_say_it() {
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(400 * 86_400);
let ago = |days: u64| age(now - Duration::from_secs(days * 86_400), now);
assert_eq!(ago(0), "today");
assert_eq!(ago(3), "3d");
assert_eq!(ago(14), "2w");
assert_eq!(ago(60), "2mo");
assert_eq!(ago(400), "1y");
}
#[test]
fn a_file_dated_in_the_future_reads_as_today_rather_than_panicking() {
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(86_400);
assert_eq!(age(now + Duration::from_secs(3600), now), "today");
}
#[test]
fn front_matter_is_not_mistaken_for_a_title() {
let dir = tmpdir("frontmatter");
let path = dir.join("log.md");
fs::write(
&path,
"---\ntype: log\nupdated: 2026-08-25\n---\n\n# Job Application Log\n",
)
.unwrap();
assert_eq!(title_at(&path), "Job Application Log");
let _ = fs::remove_dir_all(&dir);
}
}