#![allow(clippy::missing_errors_doc)]
use anyhow::{Context, Result};
use globset::{GlobBuilder, GlobSetBuilder};
use ignore::WalkBuilder;
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use crate::link_graph::strip_site_prefix;
use crate::util::levenshtein;
pub fn discover_files(dir: &Path) -> Result<Vec<PathBuf>> {
let (tx, rx) = mpsc::channel();
let (err_tx, err_rx) = mpsc::channel::<String>();
WalkBuilder::new(dir)
.hidden(true) .git_ignore(true)
.build_parallel()
.run(|| {
let tx = tx.clone();
let err_tx = err_tx.clone();
Box::new(move |entry| {
let entry = match entry {
Ok(e) => e,
Err(e) => {
let _ = err_tx.send(format!("{e}"));
return ignore::WalkState::Continue;
}
};
let path = entry.path();
if path.is_file() && path.extension().is_some_and(|ext| ext == "md") {
let _ = tx.send(path.to_path_buf());
}
ignore::WalkState::Continue
})
});
drop(tx); drop(err_tx);
for e in err_rx {
eprintln!("warning: directory walk error: {e}");
}
let mut files: Vec<PathBuf> = rx.into_iter().collect();
let canonical_dir = canonicalize_vault_dir(dir)?;
files.retain(|path| {
let is_symlink = path
.symlink_metadata()
.map(|m| m.file_type().is_symlink())
.unwrap_or(false);
if !is_symlink {
return true;
}
match dunce::canonicalize(path) {
Ok(canonical) => {
if canonical.starts_with(&canonical_dir) {
true
} else {
eprintln!(
"warning: skipping {}: symlink target resolves outside vault",
path.display()
);
false
}
}
Err(e) => {
eprintln!(
"warning: skipping {}: failed to resolve path: {}",
path.display(),
e
);
false
}
}
});
files.sort();
Ok(files)
}
pub fn resolve_file(dir: &Path, path_arg: &str) -> Result<(PathBuf, String), FileResolveError> {
if path_arg.contains('\0') {
return Err(FileResolveError::InvalidPath {
path: path_arg.to_owned(),
reason: "contains null byte",
});
}
let mut normalized = normalize_path(path_arg);
if let Some(stripped) = strip_dir_prefix(dir, &normalized) {
normalized = stripped;
}
if normalized.starts_with('/')
|| has_parent_traversal(&normalized)
|| Path::new(&normalized).is_absolute()
{
return Err(FileResolveError::OutsideVault { path: normalized });
}
if !std::path::Path::new(&normalized)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
{
if dir.join(&normalized).is_dir() {
let glob_hint = format!("--glob '{normalized}/*'");
return Err(FileResolveError::IsDirectory {
path: normalized,
hint: glob_hint,
});
}
let hint = format!("{normalized}.md");
return Err(FileResolveError::MissingExtension {
path: normalized,
hint,
});
}
let full = dir.join(&normalized);
if !full.is_file() {
if let Some(sibling_name) = fuzzy_match_sibling(&full) {
let suggestion = match Path::new(&normalized).parent() {
Some(p) if p != Path::new("") => {
format!("{}/{sibling_name}", p.display())
}
_ => sibling_name,
};
return Err(FileResolveError::NotFoundSuggestion {
path: normalized,
suggestion,
});
}
return Err(FileResolveError::NotFound { path: normalized });
}
let canonical_dir = canonicalize_vault_dir(dir).map_err(|_| FileResolveError::NotFound {
path: normalized.clone(),
})?;
match ensure_within_vault(&canonical_dir, &full) {
Ok(true) => {}
Ok(false) => {
return Err(FileResolveError::OutsideVault {
path: normalized.clone(),
});
}
Err(_) => {
return Err(FileResolveError::NotFound { path: normalized });
}
}
Ok((full, normalized))
}
fn fuzzy_match_sibling(full: &Path) -> Option<String> {
let parent = full.parent()?;
let target_name = full.file_name()?.to_str()?;
let entries = std::fs::read_dir(parent).ok()?;
let mut best: Option<(usize, String)> = None;
for entry in entries.filter_map(Result::ok) {
let name = entry.file_name();
let Some(name_str) = name.to_str() else {
continue;
};
let is_md = std::path::Path::new(name_str)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"));
if !is_md || name_str == target_name {
continue;
}
let dist = levenshtein(target_name, name_str);
if dist <= 3 && best.as_ref().is_none_or(|(d, _)| dist < *d) {
best = Some((dist, name_str.to_owned()));
}
}
best.map(|(_, name)| name)
}
fn has_parent_traversal(path: &str) -> bool {
Path::new(path)
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
}
pub fn canonicalize_vault_dir(dir: &Path) -> Result<PathBuf> {
dunce::canonicalize(dir)
.with_context(|| format!("failed to canonicalize vault dir: {}", dir.display()))
}
pub(crate) fn ensure_within_vault(canonical_dir: &Path, full: &Path) -> Result<bool> {
let canonical_full = dunce::canonicalize(full)
.with_context(|| format!("failed to canonicalize path: {}", full.display()))?;
Ok(canonical_full.starts_with(canonical_dir))
}
pub fn strip_dir_prefix(dir: &Path, normalized: &str) -> Option<String> {
let norm_path = Path::new(normalized);
let stripped = norm_path.strip_prefix(dir).ok().or_else(|| {
let last = dir.file_name()?;
norm_path.strip_prefix(last).ok()
})?;
let s = stripped.to_string_lossy().replace('\\', "/");
if s.is_empty() { None } else { Some(s) }
}
fn normalize_path(path: &str) -> String {
let normalized = path.replace('\\', "/");
normalized
.strip_prefix("./")
.unwrap_or(&normalized)
.to_owned()
}
#[must_use]
#[allow(dead_code)] pub(crate) fn is_glob(path: &str) -> bool {
path.starts_with('!')
|| path.starts_with("\\!")
|| path.contains('*')
|| path.contains('?')
|| path.contains('[')
}
#[allow(dead_code)] pub(crate) fn match_glob(
dir: &Path,
files: &[PathBuf],
pattern: &str,
) -> Result<Vec<(PathBuf, String)>> {
let normalized;
let pattern = if let Some(rest) = pattern.strip_prefix("\\!") {
normalized = format!("!{rest}");
normalized.as_str()
} else {
pattern
};
if let Some(neg_pattern) = pattern.strip_prefix('!') {
anyhow::ensure!(
!neg_pattern.is_empty(),
"negation glob pattern must not be empty (got '!')"
);
let glob = GlobBuilder::new(neg_pattern)
.literal_separator(true)
.build()
.context("invalid glob negation pattern")?
.compile_matcher();
let mut matched = Vec::new();
for file in files {
let rel = relative_path(dir, file);
if !glob.is_match(&rel) {
matched.push((file.clone(), rel));
}
}
return Ok(matched);
}
let glob = GlobBuilder::new(pattern)
.literal_separator(true)
.build()
.context("invalid glob pattern")?
.compile_matcher();
let mut matched = Vec::new();
for file in files {
let rel = relative_path(dir, file);
if glob.is_match(&rel) {
matched.push((file.clone(), rel));
}
}
Ok(matched)
}
pub fn match_globs(
dir: &Path,
files: &[PathBuf],
patterns: &[String],
) -> Result<Vec<(PathBuf, String)>> {
let normalized: Vec<String> = patterns
.iter()
.map(|p| {
if let Some(rest) = p.strip_prefix("\\!") {
format!("!{rest}")
} else {
p.clone()
}
})
.collect();
let mut positive: Vec<&str> = Vec::new();
let mut negative: Vec<&str> = Vec::new();
for p in &normalized {
if let Some(neg) = p.strip_prefix('!') {
anyhow::ensure!(
!neg.is_empty(),
"negation glob pattern must not be empty (got '!')"
);
negative.push(neg);
} else {
positive.push(p.as_str());
}
}
let positive_set = if positive.is_empty() {
None
} else {
let mut builder = GlobSetBuilder::new();
for pat in &positive {
builder.add(
GlobBuilder::new(pat)
.literal_separator(true)
.build()
.context("invalid glob pattern")?,
);
}
Some(
builder
.build()
.context("failed to build positive globset")?,
)
};
let negative_set = if negative.is_empty() {
None
} else {
let mut builder = GlobSetBuilder::new();
for pat in &negative {
builder.add(
GlobBuilder::new(pat)
.literal_separator(true)
.build()
.context("invalid glob negation pattern")?,
);
}
Some(
builder
.build()
.context("failed to build negative globset")?,
)
};
let mut matched = Vec::new();
for file in files {
let rel = relative_path(dir, file);
let passes_positive = positive_set.as_ref().is_none_or(|gs| gs.is_match(&rel));
let passes_negative = negative_set.as_ref().is_none_or(|gs| !gs.is_match(&rel));
if passes_positive && passes_negative {
matched.push((file.clone(), rel));
}
}
Ok(matched)
}
#[must_use]
pub fn relative_path(dir: &Path, file: &Path) -> String {
let raw = file.strip_prefix(dir).map_or_else(
|_| file.to_string_lossy().to_string(),
|p| p.to_string_lossy().to_string(),
);
raw.replace('\\', "/")
}
#[derive(Debug)]
pub enum FileResolveError {
NotFound { path: String },
NotFoundSuggestion { path: String, suggestion: String },
MissingExtension { path: String, hint: String },
IsDirectory { path: String, hint: String },
OutsideVault { path: String },
InvalidPath { path: String, reason: &'static str },
}
impl std::fmt::Display for FileResolveError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotFound { path } => write!(f, "file not found: {path}"),
Self::NotFoundSuggestion { path, suggestion } => {
write!(f, "file not found: {path} (did you mean {suggestion}?)")
}
Self::MissingExtension { path, hint } => {
write!(f, "file not found: {path} (did you mean {hint}?)")
}
Self::IsDirectory { path, hint } => {
write!(f, "path is a directory, not a file: {path} (try {hint})")
}
Self::OutsideVault { path } => {
write!(f, "file resolves outside vault boundary: {path}")
}
Self::InvalidPath { path, reason } => {
write!(f, "invalid path ({reason}): {path}")
}
}
}
}
impl std::error::Error for FileResolveError {}
#[must_use]
pub fn resolve_target(
canonical_dir: &Path,
target: &str,
site_prefix: Option<&str>,
) -> Option<String> {
if target.is_empty() {
return None;
}
let mut target = target.replace('\\', "/");
if let Some(pos) = target.find('#') {
target.truncate(pos);
}
if let Some(pos) = target.find('?') {
target.truncate(pos);
}
while target.ends_with('/') && target.len() > 1 {
target.pop();
}
if target.is_empty() {
return None;
}
let target = if target.starts_with('/') {
let stripped = strip_site_prefix(&target, site_prefix);
if has_parent_traversal(&stripped) {
return None;
}
stripped
} else {
if has_parent_traversal(&target) || Path::new(&target).is_absolute() {
return None;
}
target
};
let full = canonical_dir.join(&target);
if full.is_file() {
if ensure_within_vault(canonical_dir, &full).unwrap_or(false) {
return Some(target.clone());
}
return None;
}
if !std::path::Path::new(&target)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
{
let with_ext = format!("{target}.md");
let full = canonical_dir.join(&with_ext);
if full.is_file() {
if ensure_within_vault(canonical_dir, &full).unwrap_or(false) {
return Some(with_ext);
}
return None;
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn discover_finds_md_files() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("note.md"), "# Note").unwrap();
fs::write(tmp.path().join("readme.txt"), "text").unwrap();
fs::create_dir_all(tmp.path().join("sub")).unwrap();
fs::write(tmp.path().join("sub/deep.md"), "# Deep").unwrap();
let files = discover_files(tmp.path()).unwrap();
assert_eq!(files.len(), 2);
assert!(files.iter().all(|f| f.extension().unwrap() == "md"));
}
#[test]
fn discover_skips_hidden_dirs() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("visible.md"), "# Visible").unwrap();
fs::create_dir_all(tmp.path().join(".hidden")).unwrap();
fs::write(tmp.path().join(".hidden/secret.md"), "# Secret").unwrap();
let files = discover_files(tmp.path()).unwrap();
assert_eq!(files.len(), 1);
assert!(files[0].ends_with("visible.md"));
}
#[test]
fn glob_matching() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("a.md"), "").unwrap();
fs::create_dir_all(tmp.path().join("notes")).unwrap();
fs::write(tmp.path().join("notes/b.md"), "").unwrap();
fs::write(tmp.path().join("notes/c.md"), "").unwrap();
let files = discover_files(tmp.path()).unwrap();
let matched = match_glob(tmp.path(), &files, "notes/*.md").unwrap();
assert_eq!(matched.len(), 2);
let matched_all = match_glob(tmp.path(), &files, "**/*.md").unwrap();
assert_eq!(matched_all.len(), 3);
}
#[test]
fn glob_star_does_not_cross_slash() {
let tmp = tempfile::tempdir().unwrap();
make_files(tmp.path(), &["a.md", "b.md", "sub/c.md", "sub/deep/d.md"]);
let files = discover_files(tmp.path()).unwrap();
let star = match_glob(tmp.path(), &files, "*.md").unwrap();
assert_eq!(star.len(), 2);
let double_star = match_glob(tmp.path(), &files, "**/*.md").unwrap();
assert_eq!(double_star.len(), 4);
}
#[test]
fn resolve_file_success() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("note.md"), "").unwrap();
let (path, rel) = resolve_file(tmp.path(), "note.md").unwrap();
assert!(path.is_file());
assert_eq!(rel, "note.md");
}
#[test]
fn resolve_file_strips_leading_dot_slash() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("note.md"), "").unwrap();
let (_, rel) = resolve_file(tmp.path(), "./note.md").unwrap();
assert_eq!(rel, "note.md");
}
#[test]
fn resolve_file_strips_leading_dot_backslash() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("note.md"), "").unwrap();
let (_, rel) = resolve_file(tmp.path(), r".\note.md").unwrap();
assert_eq!(rel, "note.md");
}
#[test]
fn resolve_file_missing_extension_hint() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("note.md"), "").unwrap();
let err = resolve_file(tmp.path(), "note").unwrap_err();
match err {
FileResolveError::MissingExtension { path, hint } => {
assert_eq!(path, "note");
assert_eq!(hint, "note.md");
}
other => {
panic!("expected MissingExtension, got {other:?}")
}
}
}
#[test]
fn resolve_file_rejects_path_traversal() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("note.md"), "").unwrap();
assert!(matches!(
resolve_file(tmp.path(), "/etc/passwd.md"),
Err(FileResolveError::OutsideVault { .. })
));
assert!(matches!(
resolve_file(tmp.path(), "../secret.md"),
Err(FileResolveError::OutsideVault { .. })
));
assert!(matches!(
resolve_file(tmp.path(), "sub/../../../etc/passwd.md"),
Err(FileResolveError::OutsideVault { .. })
));
}
#[test]
fn is_glob_detects_patterns() {
assert!(is_glob("*.md"));
assert!(is_glob("notes/**/*.md"));
assert!(is_glob("note[123].md"));
assert!(!is_glob("notes/file.md"));
}
#[test]
fn is_glob_detects_negation_prefix() {
assert!(is_glob("!notes/draft.md"));
assert!(is_glob("!**/index.md"));
}
#[test]
fn is_glob_detects_escaped_negation_prefix() {
assert!(is_glob("\\!notes/draft.md"));
assert!(is_glob("\\!**/index.md"));
}
#[test]
fn glob_negation_escaped_backslash_bang() {
let tmp = tempfile::tempdir().unwrap();
make_files(
tmp.path(),
&["a.md", "b.md", "notes/draft.md", "notes/final.md"],
);
let files = discover_files(tmp.path()).unwrap();
let matched = match_glob(tmp.path(), &files, "\\!notes/draft.md").unwrap();
let rels: Vec<_> = matched.iter().map(|(_, r)| r.as_str()).collect();
assert!(
!rels.contains(&"notes/draft.md"),
"draft.md should be excluded via escaped negation"
);
assert!(rels.contains(&"notes/final.md"));
assert!(rels.contains(&"a.md"));
assert_eq!(matched.len(), 3);
}
#[test]
fn glob_negation_excludes_matching_files() {
let tmp = tempfile::tempdir().unwrap();
make_files(
tmp.path(),
&["a.md", "b.md", "notes/draft.md", "notes/final.md"],
);
let files = discover_files(tmp.path()).unwrap();
let matched = match_glob(tmp.path(), &files, "!notes/draft.md").unwrap();
let rels: Vec<_> = matched.iter().map(|(_, r)| r.as_str()).collect();
assert!(
!rels.contains(&"notes/draft.md"),
"draft.md should be excluded"
);
assert!(rels.contains(&"notes/final.md"));
assert!(rels.contains(&"a.md"));
assert_eq!(matched.len(), 3);
}
#[test]
fn glob_negation_with_wildcard() {
let tmp = tempfile::tempdir().unwrap();
make_files(
tmp.path(),
&["a.md", "draft-b.md", "draft-c.md", "final.md"],
);
let files = discover_files(tmp.path()).unwrap();
let matched = match_glob(tmp.path(), &files, "!draft-*").unwrap();
let rels: Vec<_> = matched.iter().map(|(_, r)| r.as_str()).collect();
assert!(!rels.iter().any(|r| r.starts_with("draft-")));
assert!(rels.contains(&"a.md"));
assert!(rels.contains(&"final.md"));
assert_eq!(matched.len(), 2);
}
#[test]
fn glob_negation_double_star_excludes_recursively() {
let tmp = tempfile::tempdir().unwrap();
make_files(tmp.path(), &["index.md", "notes/index.md", "notes/real.md"]);
let files = discover_files(tmp.path()).unwrap();
let matched = match_glob(tmp.path(), &files, "!**/index.md").unwrap();
let rels: Vec<_> = matched.iter().map(|(_, r)| r.as_str()).collect();
assert!(!rels.iter().any(|r| r.ends_with("index.md")));
assert!(rels.contains(&"notes/real.md"));
assert_eq!(matched.len(), 1);
}
#[test]
fn match_globs_multiple_positive_patterns_union() {
let tmp = tempfile::tempdir().unwrap();
make_files(
tmp.path(),
&["root.md", "sub1/a.md", "sub1/b.md", "sub2/c.md"],
);
let files = discover_files(tmp.path()).unwrap();
let patterns: Vec<String> = vec!["sub1/**".to_owned(), "sub2/**".to_owned()];
let matched = match_globs(tmp.path(), &files, &patterns).unwrap();
let rels: Vec<_> = matched.iter().map(|(_, r)| r.as_str()).collect();
assert_eq!(matched.len(), 3);
assert!(rels.contains(&"sub1/a.md"));
assert!(rels.contains(&"sub1/b.md"));
assert!(rels.contains(&"sub2/c.md"));
assert!(!rels.contains(&"root.md"));
}
#[test]
fn match_globs_positive_and_negative() {
let tmp = tempfile::tempdir().unwrap();
make_files(tmp.path(), &["sub/keep.md", "sub/draft.md", "root.md"]);
let files = discover_files(tmp.path()).unwrap();
let patterns: Vec<String> = vec!["sub/**".to_owned(), "!sub/draft.md".to_owned()];
let matched = match_globs(tmp.path(), &files, &patterns).unwrap();
let rels: Vec<_> = matched.iter().map(|(_, r)| r.as_str()).collect();
assert_eq!(matched.len(), 1);
assert!(rels.contains(&"sub/keep.md"));
assert!(!rels.contains(&"sub/draft.md"));
}
#[test]
fn match_globs_no_positive_means_all_files() {
let tmp = tempfile::tempdir().unwrap();
make_files(tmp.path(), &["a.md", "b.md", "draft.md"]);
let files = discover_files(tmp.path()).unwrap();
let patterns: Vec<String> = vec!["!draft.md".to_owned()];
let matched = match_globs(tmp.path(), &files, &patterns).unwrap();
let rels: Vec<_> = matched.iter().map(|(_, r)| r.as_str()).collect();
assert_eq!(matched.len(), 2);
assert!(rels.contains(&"a.md"));
assert!(rels.contains(&"b.md"));
assert!(!rels.contains(&"draft.md"));
}
#[test]
fn match_globs_single_pattern_same_as_match_glob() {
let tmp = tempfile::tempdir().unwrap();
make_files(tmp.path(), &["a.md", "notes/b.md", "notes/c.md"]);
let files = discover_files(tmp.path()).unwrap();
let single: Vec<String> = vec!["notes/*.md".to_owned()];
let matched = match_globs(tmp.path(), &files, &single).unwrap();
assert_eq!(matched.len(), 2);
}
#[test]
fn match_globs_empty_negation_returns_error() {
let tmp = tempfile::tempdir().unwrap();
make_files(tmp.path(), &["a.md"]);
let files = discover_files(tmp.path()).unwrap();
let patterns: Vec<String> = vec!["!".to_owned()];
assert!(match_globs(tmp.path(), &files, &patterns).is_err());
}
#[test]
fn strip_dir_prefix_matches_single_component() {
let dir = Path::new("docs");
assert_eq!(
strip_dir_prefix(dir, "docs/notes/foo.md"),
Some("notes/foo.md".to_owned())
);
}
#[test]
fn strip_dir_prefix_matches_multi_component() {
let dir = Path::new("my/docs");
assert_eq!(
strip_dir_prefix(dir, "my/docs/foo.md"),
Some("foo.md".to_owned())
);
}
#[test]
fn strip_dir_prefix_no_match() {
let dir = Path::new("docs");
assert_eq!(strip_dir_prefix(dir, "notes/foo.md"), None);
}
#[test]
fn strip_dir_prefix_partial_component_no_match() {
let dir = Path::new("docs");
assert_eq!(strip_dir_prefix(dir, "docs-old/foo.md"), None);
}
#[test]
fn strip_dir_prefix_exact_match_returns_none() {
let dir = Path::new("docs");
assert_eq!(strip_dir_prefix(dir, "docs"), None);
}
#[test]
fn resolve_file_cwd_relative_fallback() {
let tmp = tempfile::tempdir().unwrap();
let kb = tmp.path().join("kb");
fs::create_dir_all(&kb).unwrap();
fs::write(kb.join("note.md"), "# Note").unwrap();
let (path, rel) = resolve_file(&kb, "kb/note.md").unwrap();
assert!(path.is_file());
assert_eq!(rel, "note.md");
}
#[test]
fn resolve_file_cwd_relative_nested() {
let tmp = tempfile::tempdir().unwrap();
let kb = tmp.path().join("kb");
fs::create_dir_all(kb.join("sub")).unwrap();
fs::write(kb.join("sub/deep.md"), "").unwrap();
let (path, rel) = resolve_file(&kb, "kb/sub/deep.md").unwrap();
assert!(path.is_file());
assert_eq!(rel, "sub/deep.md");
}
#[test]
fn resolve_file_cwd_relative_always_strips_prefix() {
let tmp = tempfile::tempdir().unwrap();
let kb = tmp.path().join("kb");
fs::create_dir_all(kb.join("kb")).unwrap();
fs::write(kb.join("note.md"), "top").unwrap();
fs::write(kb.join("kb/note.md"), "nested").unwrap();
let (path, rel) = resolve_file(&kb, "kb/note.md").unwrap();
assert!(path.is_file());
assert_eq!(rel, "note.md");
assert_eq!(
fs::read_to_string(&path).unwrap(),
"top",
"should resolve to the stripped (vault-relative) file"
);
}
#[test]
fn resolve_file_cwd_relative_not_found_still_errors() {
let tmp = tempfile::tempdir().unwrap();
let kb = tmp.path().join("kb");
fs::create_dir_all(&kb).unwrap();
let err = resolve_file(&kb, "kb/nonexistent.md").unwrap_err();
assert!(
matches!(err, FileResolveError::NotFound { .. }),
"expected NotFound, got {err:?}"
);
}
fn make_files(dir: &Path, paths: &[&str]) {
for path in paths {
let full = dir.join(path);
if let Some(parent) = full.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(full, "").unwrap();
}
}
#[test]
fn resolve_target_stem_appends_md() {
let tmp = tempfile::tempdir().unwrap();
make_files(tmp.path(), &["note.md"]);
let canonical = canonicalize_vault_dir(tmp.path()).unwrap();
assert_eq!(
resolve_target(&canonical, "note", None),
Some("note.md".to_owned())
);
}
#[test]
fn resolve_target_explicit_md_extension() {
let tmp = tempfile::tempdir().unwrap();
make_files(tmp.path(), &["note.md"]);
let canonical = canonicalize_vault_dir(tmp.path()).unwrap();
assert_eq!(
resolve_target(&canonical, "note.md", None),
Some("note.md".to_owned())
);
}
#[test]
fn resolve_target_subpath_stem() {
let tmp = tempfile::tempdir().unwrap();
make_files(tmp.path(), &["sub/other.md"]);
let canonical = canonicalize_vault_dir(tmp.path()).unwrap();
assert_eq!(
resolve_target(&canonical, "sub/other", None),
Some("sub/other.md".to_owned())
);
}
#[test]
fn resolve_target_subpath_with_extension() {
let tmp = tempfile::tempdir().unwrap();
make_files(tmp.path(), &["sub/other.md"]);
let canonical = canonicalize_vault_dir(tmp.path()).unwrap();
assert_eq!(
resolve_target(&canonical, "sub/other.md", None),
Some("sub/other.md".to_owned())
);
}
#[test]
fn resolve_target_bare_stem_does_not_match_subdirectory() {
let tmp = tempfile::tempdir().unwrap();
make_files(tmp.path(), &["sub/other.md"]);
let canonical = canonicalize_vault_dir(tmp.path()).unwrap();
assert_eq!(resolve_target(&canonical, "other", None), None);
}
#[test]
fn resolve_target_nonexistent_returns_none() {
let tmp = tempfile::tempdir().unwrap();
let canonical = canonicalize_vault_dir(tmp.path()).unwrap();
assert_eq!(resolve_target(&canonical, "nonexistent", None), None);
}
#[test]
fn resolve_target_empty_returns_none() {
let tmp = tempfile::tempdir().unwrap();
let canonical = canonicalize_vault_dir(tmp.path()).unwrap();
assert_eq!(resolve_target(&canonical, "", None), None);
}
#[test]
fn resolve_target_rejects_traversal() {
let tmp = tempfile::tempdir().unwrap();
make_files(tmp.path(), &["note.md"]);
let canonical = canonicalize_vault_dir(tmp.path()).unwrap();
assert_eq!(resolve_target(&canonical, "../note", None), None);
assert_eq!(resolve_target(&canonical, "sub/../../note", None), None);
assert_eq!(resolve_target(&canonical, "/etc/passwd", None), None);
}
#[test]
fn resolve_target_non_md_file_exact_match() {
let tmp = tempfile::tempdir().unwrap();
make_files(tmp.path(), &["image.png"]);
let canonical = canonicalize_vault_dir(tmp.path()).unwrap();
assert_eq!(
resolve_target(&canonical, "image.png", None),
Some("image.png".to_owned())
);
}
#[test]
fn resolve_file_accepts_dotdot_in_filename() {
let tmp = tempfile::tempdir().unwrap();
fs::create_dir_all(tmp.path().join("notes")).unwrap();
fs::write(tmp.path().join("notes/etc..md"), "# dotdot").unwrap();
let (path, rel) = resolve_file(tmp.path(), "notes/etc..md").unwrap();
assert!(path.is_file());
assert_eq!(rel, "notes/etc..md");
}
#[test]
fn resolve_file_rejects_parent_traversal_segments() {
let tmp = tempfile::tempdir().unwrap();
assert!(matches!(
resolve_file(tmp.path(), "../secret.md"),
Err(FileResolveError::OutsideVault { .. })
));
assert!(matches!(
resolve_file(tmp.path(), "sub/../../etc/passwd.md"),
Err(FileResolveError::OutsideVault { .. })
));
}
#[test]
fn resolve_target_accepts_dotdot_in_filename() {
let tmp = tempfile::tempdir().unwrap();
make_files(tmp.path(), &["etc..md"]);
let canonical = canonicalize_vault_dir(tmp.path()).unwrap();
assert_eq!(
resolve_target(&canonical, "etc..md", None),
Some("etc..md".to_owned())
);
}
#[test]
fn resolve_target_rejects_parent_traversal_segment() {
let tmp = tempfile::tempdir().unwrap();
let canonical = canonicalize_vault_dir(tmp.path()).unwrap();
assert_eq!(resolve_target(&canonical, "../secret.md", None), None);
}
#[cfg(unix)]
#[test]
fn resolve_file_rejects_symlink_escape() {
let vault = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
fs::write(outside.path().join("secret.md"), "# Secret").unwrap();
std::os::unix::fs::symlink(outside.path(), vault.path().join("linked")).unwrap();
let err = resolve_file(vault.path(), "linked/secret.md").unwrap_err();
assert!(
matches!(err, FileResolveError::OutsideVault { .. }),
"expected OutsideVault, got {err:?}"
);
}
#[cfg(unix)]
#[test]
fn resolve_target_rejects_symlink_escape() {
let vault = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
fs::write(outside.path().join("secret.md"), "# Secret").unwrap();
std::os::unix::fs::symlink(outside.path(), vault.path().join("linked")).unwrap();
let canonical = canonicalize_vault_dir(vault.path()).unwrap();
assert_eq!(resolve_target(&canonical, "linked/secret", None), None);
assert_eq!(resolve_target(&canonical, "linked/secret.md", None), None);
}
#[test]
fn resolve_target_absolute_with_site_prefix() {
let tmp = tempfile::tempdir().unwrap();
make_files(tmp.path(), &["page.md"]);
let canonical = canonicalize_vault_dir(tmp.path()).unwrap();
assert_eq!(
resolve_target(&canonical, "/docs/page.md", Some("docs")),
Some("page.md".to_owned())
);
}
#[test]
fn resolve_target_absolute_no_prefix() {
let tmp = tempfile::tempdir().unwrap();
make_files(tmp.path(), &["page.md"]);
let canonical = canonicalize_vault_dir(tmp.path()).unwrap();
assert_eq!(
resolve_target(&canonical, "/page.md", None),
Some("page.md".to_owned())
);
}
#[test]
fn resolve_target_absolute_nonmatching_prefix() {
let tmp = tempfile::tempdir().unwrap();
make_files(tmp.path(), &["other/b.md"]);
let canonical = canonicalize_vault_dir(tmp.path()).unwrap();
assert_eq!(
resolve_target(&canonical, "/other/b.md", Some("docs")),
Some("other/b.md".to_owned())
);
}
#[test]
fn resolve_target_absolute_stem_with_prefix() {
let tmp = tempfile::tempdir().unwrap();
make_files(tmp.path(), &["page.md"]);
let canonical = canonicalize_vault_dir(tmp.path()).unwrap();
assert_eq!(
resolve_target(&canonical, "/docs/page", Some("docs")),
Some("page.md".to_owned())
);
}
#[test]
fn resolve_target_strips_trailing_slash() {
let tmp = tempfile::tempdir().unwrap();
make_files(tmp.path(), &["page.md"]);
let canonical = canonicalize_vault_dir(tmp.path()).unwrap();
assert_eq!(
resolve_target(&canonical, "page.md/", None),
Some("page.md".to_owned())
);
assert_eq!(
resolve_target(&canonical, "page/", None),
Some("page.md".to_owned())
);
}
#[test]
fn resolve_target_strips_query_string() {
let tmp = tempfile::tempdir().unwrap();
make_files(tmp.path(), &["page.md"]);
let canonical = canonicalize_vault_dir(tmp.path()).unwrap();
assert_eq!(
resolve_target(&canonical, "page?foo=bar", None),
Some("page.md".to_owned())
);
assert_eq!(
resolve_target(&canonical, "page.md?dv=winzip", None),
Some("page.md".to_owned())
);
}
#[test]
fn resolve_target_strips_fragment() {
let tmp = tempfile::tempdir().unwrap();
make_files(tmp.path(), &["page.md"]);
let canonical = canonicalize_vault_dir(tmp.path()).unwrap();
assert_eq!(
resolve_target(&canonical, "page#section", None),
Some("page.md".to_owned())
);
assert_eq!(
resolve_target(&canonical, "page.md#heading", None),
Some("page.md".to_owned())
);
}
#[test]
fn resolve_target_strips_query_and_fragment_combined() {
let tmp = tempfile::tempdir().unwrap();
make_files(tmp.path(), &["page.md"]);
let canonical = canonicalize_vault_dir(tmp.path()).unwrap();
assert_eq!(
resolve_target(&canonical, "page?foo=bar#section", None),
Some("page.md".to_owned())
);
assert_eq!(
resolve_target(&canonical, "page/?q=1#top", None),
Some("page.md".to_owned())
);
}
#[test]
fn resolve_target_fragment_only_returns_none() {
let tmp = tempfile::tempdir().unwrap();
make_files(tmp.path(), &["page.md"]);
let canonical = canonicalize_vault_dir(tmp.path()).unwrap();
assert_eq!(resolve_target(&canonical, "#section", None), None);
}
#[cfg(unix)]
#[test]
fn resolve_file_allows_symlink_within_vault() {
let vault = tempfile::tempdir().unwrap();
let subdir = vault.path().join("notes");
fs::create_dir_all(&subdir).unwrap();
fs::write(subdir.join("real.md"), "# Real").unwrap();
std::os::unix::fs::symlink(&subdir, vault.path().join("alias")).unwrap();
let (path, rel) = resolve_file(vault.path(), "alias/real.md").unwrap();
assert!(path.is_file());
assert_eq!(rel, "alias/real.md");
}
#[test]
fn resolve_file_rejects_null_byte_in_path() {
let vault = tempfile::tempdir().unwrap();
let err = resolve_file(vault.path(), "notes/file\0.md").unwrap_err();
assert!(matches!(
err,
FileResolveError::InvalidPath {
reason: "contains null byte",
..
}
));
assert!(err.to_string().contains("contains null byte"));
}
#[test]
fn resolve_file_rejects_null_byte_only_path() {
let vault = tempfile::tempdir().unwrap();
let err = resolve_file(vault.path(), "\0").unwrap_err();
assert!(matches!(
err,
FileResolveError::InvalidPath {
reason: "contains null byte",
..
}
));
assert!(err.to_string().contains("contains null byte"));
}
#[test]
fn resolve_file_directory_suggests_glob() {
let tmp = tempfile::tempdir().unwrap();
fs::create_dir(tmp.path().join("notes")).unwrap();
let err = resolve_file(tmp.path(), "notes").unwrap_err();
match err {
FileResolveError::IsDirectory { ref path, ref hint } => {
assert_eq!(path, "notes");
assert!(hint.contains("--glob"));
assert!(hint.contains("notes/*"));
}
other => panic!("expected IsDirectory, got {other:?}"),
}
assert!(err.to_string().contains("directory"));
}
#[test]
fn resolve_file_non_directory_without_ext_still_hints_md() {
let tmp = tempfile::tempdir().unwrap();
let err = resolve_file(tmp.path(), "notes").unwrap_err();
assert!(matches!(err, FileResolveError::MissingExtension { .. }));
}
#[test]
fn resolve_file_fuzzy_suggests_close_match() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("readme.md"), "").unwrap();
let err = resolve_file(tmp.path(), "readem.md").unwrap_err();
match err {
FileResolveError::NotFoundSuggestion { ref suggestion, .. } => {
assert_eq!(suggestion, "readme.md");
}
other => panic!("expected NotFoundSuggestion, got {other:?}"),
}
assert!(err.to_string().contains("did you mean"));
}
#[test]
fn resolve_file_fuzzy_suggests_with_relative_path() {
let tmp = tempfile::tempdir().unwrap();
fs::create_dir(tmp.path().join("sub")).unwrap();
fs::write(tmp.path().join("sub/readme.md"), "").unwrap();
let err = resolve_file(tmp.path(), "sub/readem.md").unwrap_err();
match err {
FileResolveError::NotFoundSuggestion { ref suggestion, .. } => {
assert_eq!(suggestion, "sub/readme.md");
}
other => panic!("expected NotFoundSuggestion, got {other:?}"),
}
}
#[test]
fn resolve_file_no_fuzzy_for_distant_names() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("readme.md"), "").unwrap();
let err = resolve_file(tmp.path(), "zzzzz.md").unwrap_err();
assert!(matches!(err, FileResolveError::NotFound { .. }));
}
#[test]
fn resolve_file_traversal_says_outside_vault() {
let tmp = tempfile::tempdir().unwrap();
let err = resolve_file(tmp.path(), "../Cargo.toml").unwrap_err();
assert!(matches!(err, FileResolveError::OutsideVault { .. }));
assert!(
err.to_string().contains("outside vault"),
"message should mention 'outside vault', got: {err}",
);
}
#[test]
fn resolve_file_absolute_path_says_outside_vault() {
let tmp = tempfile::tempdir().unwrap();
let err = resolve_file(tmp.path(), "/etc/passwd.md").unwrap_err();
assert!(matches!(err, FileResolveError::OutsideVault { .. }));
}
}