use crate::notes::title_of;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::SystemTime;
#[derive(Clone)]
pub struct Entry {
pub path: PathBuf,
pub title: String,
pub rel: String,
pub folder: String,
pub modified: SystemTime,
pub aliases: Vec<String>,
pub name: String,
}
impl Entry {
pub fn name(&self) -> String {
self.name.clone()
}
pub fn name_of(path: &Path) -> String {
path.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default()
}
}
const MAX_FILES: usize = 8000;
const MAX_DEPTH: usize = 10;
const TITLE_BYTES: usize = 4096;
fn skip_dir(name: &str) -> bool {
name.starts_with('.') || matches!(name, "node_modules" | "target" | "attachments")
}
pub(crate) fn walk_notes(
roots: &[PathBuf],
cancel: Option<&AtomicBool>,
mut f: impl FnMut(&Path, PathBuf, &fs::DirEntry),
) -> Option<HashSet<PathBuf>> {
let mut seen: HashSet<PathBuf> = HashSet::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 || seen.len() >= MAX_FILES {
continue;
}
let Ok(read) = fs::read_dir(&dir) else {
continue;
};
for entry in read.flatten() {
if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
return None;
}
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
if !skip_dir(name) {
stack.push((path, depth + 1));
}
continue;
}
if name.starts_with('.') || !name.ends_with(".md") {
continue;
}
if seen.len() >= MAX_FILES || !seen.insert(path.clone()) {
continue;
}
f(&root, path, &entry);
}
}
}
Some(seen)
}
#[cfg(test)]
pub(crate) fn title_at(path: &Path) -> String {
head_at(path).0
}
pub(crate) fn head_at(path: &Path) -> (String, Vec<String>) {
head_into(path, &mut Vec::new())
}
fn head_into(path: &Path, buf: &mut Vec<u8>) -> (String, Vec<String>) {
buf.clear();
buf.resize(TITLE_BYTES, 0);
let read = fs::File::open(path)
.and_then(|mut f| f.read(buf))
.unwrap_or(0);
let head = String::from_utf8_lossy(&buf[..read]);
(title_of(&head), crate::md::front_matter_aliases(&head))
}
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 = std::env::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 entries: Vec<Entry> = Vec::new();
let mut buf = Vec::new();
let mut seen = walk_notes(roots, None, |root, path, entry| {
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);
let (title, aliases) = head_into(&path, &mut buf);
entries.push(Entry {
title,
name: Entry::name_of(&path),
path,
rel,
folder,
modified,
aliases,
});
})
.unwrap_or_default();
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()) {
continue;
}
let modified = fs::metadata(&path)
.and_then(|m| m.modified())
.unwrap_or(SystemTime::UNIX_EPOCH);
let (title, aliases) = head_into(&path, &mut buf);
entries.push(Entry {
title,
rel: short(&path),
folder: folder_of(&path, &home_root),
name: Entry::name_of(&path),
path,
modified,
aliases,
});
}
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),
crate::md::link_key(&entry.name),
];
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.extend(entry.aliases.iter().cloned());
keys.retain(|k| !k.is_empty());
keys
}
pub fn resolve<'a>(entries: &'a [Entry], target: &str) -> Option<&'a Entry> {
resolve_with(entries, target, true)
}
pub fn resolve_by_name<'a>(entries: &'a [Entry], target: &str) -> Option<&'a Entry> {
resolve_with(entries, target, false)
}
fn resolve_with<'a>(entries: &'a [Entry], target: &str, aliases: bool) -> Option<&'a Entry> {
let want = crate::md::link_key(target);
if want.is_empty() {
return None;
}
best(entries.iter(), &want, aliases)
}
pub struct Resolver {
entries: Vec<Entry>,
by_key: HashMap<String, Vec<usize>>,
}
impl Resolver {
pub fn new(entries: Vec<Entry>) -> Self {
let mut by_key: HashMap<String, Vec<usize>> = HashMap::new();
for (i, e) in entries.iter().enumerate() {
for k in link_keys(e) {
let hits = by_key.entry(k).or_default();
if hits.last() != Some(&i) {
hits.push(i);
}
}
}
Self { entries, by_key }
}
pub fn resolve(&self, target: &str) -> Option<&Entry> {
let want = crate::md::link_key(target);
if want.is_empty() {
return None;
}
let hits = self.by_key.get(&want)?;
best(hits.iter().map(|&i| &self.entries[i]), &want, true)
}
}
fn best<'a>(
candidates: impl Iterator<Item = &'a Entry>,
want: &str,
aliases: bool,
) -> Option<&'a Entry> {
candidates
.filter_map(|e| {
rank(e, want)
.or_else(|| (aliases && e.aliases.iter().any(|a| a == want)).then_some(3))
.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 crate::md::link_key(&entry.name) == 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
}
pub fn tags_of(content: &str) -> Vec<String> {
let mut tags = front_matter_tags(content);
for (_, line) in crate::notes::prose_lines(content) {
let chars: Vec<char> = line.chars().collect();
for (s, e) in crate::md::tags_in(line) {
tags.push(crate::md::tag_key(&chars[s..e].iter().collect::<String>()));
}
}
let mut seen = std::collections::HashSet::new();
tags.retain(|t| seen.insert(t.clone()));
tags
}
pub fn front_matter_tags(content: &str) -> Vec<String> {
let mut out = Vec::new();
for key in ["tags", "tag"] {
for v in crate::md::front_matter_values(content, key) {
push_tags(&mut out, &v);
}
}
out
}
fn push_tags(out: &mut Vec<String>, text: &str) {
for t in text.split(|c: char| c == ',' || c.is_whitespace()) {
let key = crate::md::tag_key(t.trim_matches(|c| c == '"' || c == '\''));
if !key.is_empty() {
out.push(key);
}
}
}
pub fn tags_at(path: &Path) -> Vec<String> {
fs::read_to_string(path)
.map(|c| tags_of(&c))
.unwrap_or_default()
}
pub fn with_tag(entries: &[Entry], tag: &str) -> Vec<usize> {
let want = crate::md::tag_key(tag);
entries
.iter()
.enumerate()
.filter(|(_, e)| tags_at(&e.path).iter().any(|t| tag_under(t, &want)))
.map(|(i, _)| i)
.collect()
}
pub fn tag_under(tag: &str, want: &str) -> bool {
tag == want
|| tag
.strip_prefix(want)
.is_some_and(|rest| rest.starts_with('/'))
}
pub fn tag_counts(entries: &[Entry]) -> Vec<(String, usize)> {
let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for e in entries {
for t in tags_at(&e.path) {
*counts.entry(t).or_default() += 1;
}
}
let mut out: Vec<(String, usize)> = counts.into_iter().collect();
out.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
out
}
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 {
crate::testutil::tmpdir("index", name)
}
#[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![dir.join("c.md"), dir.join("a.md")];
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![far.clone()];
let found = scan(&[dir.join("notes")], &recent);
assert_eq!(found[0].title, "Job Application Log");
assert_eq!(found[0].path, far);
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![path.clone()];
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,
aliases: Vec::new(),
name: Entry::name_of(&PathBuf::from(rel)),
}
}
#[test]
fn the_resolver_ranks_hits_the_way_resolve_does() {
let entries = vec![
entry("notes/launch.md", "Something Else"),
entry("plan.md", "Launch"),
aliased("deep/other.md", "Other", &["launch"]),
entry("stories/story-matrix.md", "Story Matrix"),
];
let r = Resolver::new(entries.clone());
assert_eq!(r.resolve("launch").unwrap().rel, "notes/launch.md");
assert_eq!(r.resolve("Launch").unwrap().rel, "notes/launch.md");
assert_eq!(
r.resolve("stories/story-matrix").unwrap().rel,
"stories/story-matrix.md"
);
assert_eq!(r.resolve("Something Else").unwrap().rel, "notes/launch.md");
assert!(r.resolve("matrix").is_none());
assert!(r.resolve("").is_none());
for t in ["launch", "plan", "Other", "story-matrix", "nowhere"] {
assert_eq!(
r.resolve(t).map(|e| &e.rel),
resolve(&entries, t).map(|e| &e.rel),
"{t}"
);
}
}
fn aliased(rel: &str, title: &str, aliases: &[&str]) -> Entry {
let mut e = entry(rel, title);
e.aliases = aliases.iter().map(|a| crate::md::link_key(a)).collect();
e
}
#[test]
fn a_wikilink_falls_back_to_a_front_matter_alias_after_every_real_name() {
let entries = vec![
aliased(
"projects/big-launch.md",
"The Big Launch",
&["launch", "Go Live"],
),
entry("launch.md", "Launch"),
];
assert_eq!(resolve(&entries, "launch").unwrap().rel, "launch.md");
assert_eq!(
resolve(&entries, "go live").unwrap().rel,
"projects/big-launch.md"
);
assert_eq!(
resolve(&entries, "GO LIVE").unwrap().rel,
"projects/big-launch.md"
);
assert!(resolve(&entries, "nothing").is_none());
assert!(resolve_by_name(&entries, "go live").is_none());
assert!(link_keys(&entries[0]).contains(&"go live".to_string()));
}
#[test]
fn a_scanned_note_carries_the_aliases_its_front_matter_declares() {
let dir = crate::testutil::tmpdir("index", "aliases");
fs::write(
dir.join("big-launch.md"),
"---
aliases: [launch, \"Go Live\"]
---
# The Big Launch
",
)
.unwrap();
let entries = scan(std::slice::from_ref(&dir), &[]);
let e = entries.iter().find(|e| e.rel == "big-launch.md").unwrap();
assert_eq!(e.title, "The Big Launch");
assert_eq!(e.aliases, vec!["launch", "go live"]);
assert_eq!(resolve(&entries, "Launch").unwrap().rel, "big-launch.md");
}
#[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_tags_come_inline_bracketed_or_as_a_list() {
assert_eq!(front_matter_tags("---\ntags: a, b\n---\n"), vec!["a", "b"]);
assert_eq!(
front_matter_tags("---\ntags: [A, \"b\"]\n---\n"),
vec!["a", "b"]
);
assert_eq!(
front_matter_tags("---\ntags:\n - a\n - '#b'\nother: x\n---\n"),
vec!["a", "b"]
);
assert_eq!(front_matter_tags("---\ntags:\n- a\n---\n"), vec!["a"]);
assert!(front_matter_tags("---\nmeta:\n tags: a\n---\n").is_empty());
assert!(front_matter_tags("tags: a\n").is_empty());
assert!(front_matter_tags("---\ntags:\n---\n").is_empty());
assert_eq!(front_matter_tags("---\ntag: solo\n---\n"), vec!["solo"]);
assert_eq!(
front_matter_tags("---\ntags: a\ntag: b\n---\n"),
vec!["a", "b"]
);
}
#[test]
fn a_tag_covers_the_tags_nested_under_it_but_not_its_lookalikes() {
assert!(tag_under("work", "work"));
assert!(tag_under("work/projects", "work"));
assert!(tag_under("work/projects/x", "work"));
assert!(!tag_under("workshop", "work"));
assert!(!tag_under("work", "work/projects"));
}
#[test]
fn tags_are_counted_across_the_index_most_used_first() {
let dir = tmpdir("tag-counts");
fs::write(dir.join("a.md"), "# A\n#work #home\n").unwrap();
fs::write(dir.join("b.md"), "---\ntags: [work]\n---\n# B\n#work\n").unwrap();
fs::write(dir.join("c.md"), "# C\n#alpha\n").unwrap();
let found = scan(std::slice::from_ref(&dir), &[]);
assert_eq!(
tag_counts(&found),
vec![
("work".to_string(), 2),
("alpha".to_string(), 1),
("home".to_string(), 1)
]
);
}
#[test]
fn a_notes_tags_are_its_front_matter_and_its_body_each_once() {
let tags = tags_of(
"---\ntags: work\n---\n# T #Work\n\n```\n#fenced\n```\n~~~\n#tilde\n~~~\nsee #home and `#code`\n",
);
assert_eq!(tags, vec!["work", "home"]);
}
#[test]
fn the_notes_carrying_a_tag_are_picked_out_of_the_index() {
let dir = tmpdir("tags");
fs::write(dir.join("a.md"), "# A\n#work\n").unwrap();
fs::write(dir.join("b.md"), "---\ntags: [home, work]\n---\n# B\n").unwrap();
fs::write(dir.join("c.md"), "# C\nnothing\n").unwrap();
let mut found = scan(std::slice::from_ref(&dir), &[]);
found.sort_by(|a, b| a.rel.cmp(&b.rel));
let names =
|idx: Vec<usize>| -> Vec<String> { idx.iter().map(|i| found[*i].name()).collect() };
assert_eq!(names(with_tag(&found, "Work")), vec!["a", "b"]);
assert_eq!(names(with_tag(&found, "#home")), vec!["b"]);
fs::write(dir.join("d.md"), "# D\n#work/projects #workshop\n").unwrap();
let mut found = scan(std::slice::from_ref(&dir), &[]);
found.sort_by(|a, b| a.rel.cmp(&b.rel));
let names =
|idx: Vec<usize>| -> Vec<String> { idx.iter().map(|i| found[*i].name()).collect() };
assert_eq!(names(with_tag(&found, "work")), vec!["a", "b", "d"]);
assert_eq!(names(with_tag(&found, "work/projects")), vec!["d"]);
assert_eq!(names(with_tag(&found, "workshop")), vec!["d"]);
assert!(with_tag(&found, "none").is_empty());
let _ = fs::remove_dir_all(&dir);
}
#[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);
}
#[test]
fn head_buffer_is_reused_without_bleeding_between_files() {
let dir = tmpdir("headbuf");
let long = dir.join("long.md");
let short = dir.join("short.md");
fs::write(
&long,
format!("# A much longer title here\n{}", "x".repeat(3000)),
)
.unwrap();
fs::write(&short, "# S\n").unwrap();
let mut buf = Vec::new();
assert_eq!(head_into(&long, &mut buf).0, "A much longer title here");
assert_eq!(head_into(&short, &mut buf).0, "S");
assert_eq!(buf.len(), TITLE_BYTES);
let _ = fs::remove_dir_all(&dir);
}
}