use std::collections::BTreeSet;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use grep::regex::RegexMatcher;
use grep::searcher::sinks::UTF8;
use grep::searcher::{BinaryDetection, Searcher, SearcherBuilder};
use ignore::WalkBuilder;
use dbmd_core::{Layer, Query, Store};
use crate::cli::SearchArgs;
use crate::context::Context;
use crate::error::{CliError, CliResult, ExitCode};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct Match {
file: String,
line: u64,
text: String,
}
pub fn run(ctx: &Context, args: &SearchArgs) -> CliResult {
let store = Store::open(Path::new(&args.dir)).map_err(dbmd_core::Error::from)?;
let matches = collect_matches(&store, args)?;
emit(ctx, &matches)
}
fn collect_matches(store: &Store, args: &SearchArgs) -> Result<Vec<Match>, CliError> {
let matcher = RegexMatcher::new(&args.query).map_err(|e| {
CliError::new(
ExitCode::Runtime,
"BAD_QUERY_REGEX",
format!("invalid search pattern `{}`: {e}", args.query),
)
.with_hint("the query is a ripgrep regex; escape regex metacharacters to search literally")
})?;
let layer = parse_layer(args.r#in.as_deref())?;
let windows = TimeWindows::from_args(args)?;
let candidates = resolve_candidates(store, args, layer, &windows)?;
let mut matches: Vec<Match> = Vec::new();
let mut searcher = build_searcher();
'outer: for rel in &candidates {
let abs = store.abs_path(rel);
let rel_str = path_to_str(rel);
let scan = searcher.search_path(
&matcher,
&abs,
UTF8(|lineno, line| {
matches.push(Match {
file: rel_str.clone(),
line: lineno,
text: line.trim_end_matches(['\n', '\r']).to_string(),
});
Ok(true)
}),
);
if let Err(e) = scan {
return Err(CliError::new(
ExitCode::Runtime,
"SEARCH_FAILED",
format!("ripgrep scan of {} failed: {e}", abs.display()),
));
}
if let Some(limit) = args.limit {
if matches.len() >= limit {
matches.truncate(limit);
break 'outer;
}
}
}
Ok(matches)
}
fn resolve_candidates(
store: &Store,
args: &SearchArgs,
layer: Option<Layer>,
windows: &TimeWindows,
) -> Result<Vec<PathBuf>, CliError> {
let structured = structured_candidates(store, args, layer, windows)?;
let linked = link_candidates(store, args)?;
let mut set: Option<BTreeSet<PathBuf>> = None;
if let Some(s) = structured {
set = Some(intersect(set, s));
}
if let Some(l) = linked {
set = Some(intersect(set, l));
}
let mut resolved: Vec<PathBuf> = match set {
None => content_files(store)?,
Some(s) => s.into_iter().collect(),
};
if let Some(l) = layer {
resolved.retain(|rel| path_in_layer(rel, l));
}
if windows.is_active() && args.r#type.is_none() && args.r#where.is_empty() {
resolved.retain(|rel| windows.matches(read_file_timestamps(&store.abs_path(rel))));
}
Ok(resolved)
}
fn structured_candidates(
store: &Store,
args: &SearchArgs,
layer: Option<Layer>,
windows: &TimeWindows,
) -> Result<Option<BTreeSet<PathBuf>>, CliError> {
if args.r#type.is_none() && args.r#where.is_empty() {
return Ok(None);
}
let mut query = Query::new();
if let Some(t) = &args.r#type {
query = query.with_type(t);
}
if let Some(l) = layer {
query = query.with_layer(l);
}
for clause in &args.r#where {
let (key, value) = split_kv(clause)?;
query = query.with_where(key, value);
}
let records = query.execute(store).map_err(dbmd_core::Error::from)?;
let set = records
.into_iter()
.filter(|r| windows.matches((r.created, r.updated)))
.map(|r| r.path)
.collect();
Ok(Some(set))
}
fn link_candidates(
store: &Store,
args: &SearchArgs,
) -> Result<Option<BTreeSet<PathBuf>>, CliError> {
let mut set: Option<BTreeSet<PathBuf>> = None;
if let Some(from) = &args.linked_from {
let targets = dbmd_core::graph::forwardlinks(store, Path::new(from))
.map_err(dbmd_core::Error::from)?;
set = Some(intersect(set, resolve_link_targets(store, &targets)));
}
if let Some(to) = &args.linked_to {
let linkers =
dbmd_core::graph::backlinks(store, Path::new(to)).map_err(dbmd_core::Error::from)?;
set = Some(intersect(set, resolve_link_targets(store, &linkers)));
}
Ok(set)
}
fn resolve_link_targets(store: &Store, targets: &[PathBuf]) -> BTreeSet<PathBuf> {
let mut out = BTreeSet::new();
for target in targets {
if let Some(rel) = resolve_content_file(store, target) {
out.insert(rel);
}
}
out
}
fn content_files(store: &Store) -> Result<Vec<PathBuf>, CliError> {
let mut out = BTreeSet::new();
for layer in Layer::all() {
let dir = store.root.join(layer.dir_name());
if !dir.is_dir() {
continue;
}
let walker = WalkBuilder::new(&dir)
.hidden(true)
.git_ignore(false)
.git_global(false)
.require_git(false)
.build();
for entry in walker {
let entry = entry.map_err(|e| {
CliError::new(
ExitCode::Runtime,
"SEARCH_FAILED",
format!("walk failed under {}: {e}", dir.display()),
)
})?;
if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
continue;
}
let abs = entry.into_path();
if let Some(rel) = store.rel_path(&abs) {
if is_content_file(&rel) {
out.insert(rel);
}
}
}
}
Ok(out.into_iter().collect())
}
struct TimeWindows {
updated_after: Option<chrono::DateTime<chrono::FixedOffset>>,
updated_before: Option<chrono::DateTime<chrono::FixedOffset>>,
created_after: Option<chrono::DateTime<chrono::FixedOffset>>,
created_before: Option<chrono::DateTime<chrono::FixedOffset>>,
}
impl TimeWindows {
fn from_args(args: &SearchArgs) -> Result<Self, CliError> {
Ok(Self {
updated_after: parse_ts_opt(args.updated_after.as_deref(), "--updated-after")?,
updated_before: parse_ts_opt(args.updated_before.as_deref(), "--updated-before")?,
created_after: parse_ts_opt(args.created_after.as_deref(), "--created-after")?,
created_before: parse_ts_opt(args.created_before.as_deref(), "--created-before")?,
})
}
fn is_active(&self) -> bool {
self.updated_after.is_some()
|| self.updated_before.is_some()
|| self.created_after.is_some()
|| self.created_before.is_some()
}
fn matches(
&self,
ts: (
Option<chrono::DateTime<chrono::FixedOffset>>,
Option<chrono::DateTime<chrono::FixedOffset>>,
),
) -> bool {
let (created, updated) = ts;
if let Some(bound) = self.updated_after {
match updated {
Some(u) if u >= bound => {}
_ => return false,
}
}
if let Some(bound) = self.updated_before {
match updated {
Some(u) if u <= bound => {}
_ => return false,
}
}
if let Some(bound) = self.created_after {
match created {
Some(c) if c >= bound => {}
_ => return false,
}
}
if let Some(bound) = self.created_before {
match created {
Some(c) if c <= bound => {}
_ => return false,
}
}
true
}
}
fn emit(ctx: &Context, matches: &[Match]) -> CliResult {
let stdout = io::stdout();
let mut out = stdout.lock();
if ctx.json {
let rendered = serde_json::to_string_pretty(matches)
.map_err(|e| CliError::new(ExitCode::Runtime, "JSON_ENCODE_FAILED", e.to_string()))?;
writeln!(out, "{rendered}")?;
} else {
for m in matches {
writeln!(out, "{}:{}:{}", m.file, m.line, m.text)?;
}
}
Ok(())
}
fn parse_layer(value: Option<&str>) -> Result<Option<Layer>, CliError> {
match value {
None => Ok(None),
Some(name) => Layer::from_dir_name(name).map(Some).ok_or_else(|| {
CliError::new(
ExitCode::Runtime,
"BAD_LAYER",
format!("unknown layer `{name}`"),
)
.with_hint("valid layers are: sources, records, wiki")
}),
}
}
fn split_kv(clause: &str) -> Result<(&str, &str), CliError> {
clause.split_once('=').ok_or_else(|| {
CliError::new(
ExitCode::Runtime,
"BAD_WHERE",
format!("expected key=value, got `{clause}`"),
)
.with_hint("pass --where as key=value, e.g. --where status=active")
})
}
fn parse_ts_opt(
value: Option<&str>,
flag: &str,
) -> Result<Option<chrono::DateTime<chrono::FixedOffset>>, CliError> {
match value {
None => Ok(None),
Some(raw) => parse_ts(raw)
.map(Some)
.ok_or_else(|| bad_timestamp(flag, raw)),
}
}
fn parse_ts(raw: &str) -> Option<chrono::DateTime<chrono::FixedOffset>> {
let raw = raw.trim();
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(raw) {
return Some(dt);
}
let midnight = format!("{raw}T00:00:00Z");
chrono::DateTime::parse_from_rfc3339(&midnight).ok()
}
fn bad_timestamp(flag: &str, raw: &str) -> CliError {
CliError::new(
ExitCode::Runtime,
"BAD_TIMESTAMP",
format!("{flag} expects an RFC3339 timestamp, got `{raw}`"),
)
.with_hint("use e.g. 2026-05-15 or 2026-05-15T09:00:00Z")
}
fn read_file_timestamps(
abs: &Path,
) -> (
Option<chrono::DateTime<chrono::FixedOffset>>,
Option<chrono::DateTime<chrono::FixedOffset>>,
) {
let text = match std::fs::read_to_string(abs) {
Ok(t) => t,
Err(_) => return (None, None),
};
let yaml = match frontmatter_block(&text) {
Some(y) => y,
None => return (None, None),
};
let value: serde_yml::Value = match serde_yml::from_str(yaml) {
Ok(v) => v,
Err(_) => return (None, None),
};
let read = |key: &str| {
value
.get(key)
.and_then(yaml_scalar_string)
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s.trim()).ok())
};
(read("created"), read("updated"))
}
fn frontmatter_block(text: &str) -> Option<&str> {
let body = text.strip_prefix('\u{feff}').unwrap_or(text);
let rest = body
.strip_prefix("---\n")
.or_else(|| body.strip_prefix("---\r\n"))?;
let mut idx = 0usize;
for line in rest.split_inclusive('\n') {
if line.trim_end_matches(['\r', '\n']) == "---" {
return Some(&rest[..idx]);
}
idx += line.len();
}
None
}
fn yaml_scalar_string(value: &serde_yml::Value) -> Option<String> {
if let Some(s) = value.as_str() {
return Some(s.to_string());
}
match value {
serde_yml::Value::Null | serde_yml::Value::Mapping(_) | serde_yml::Value::Sequence(_) => {
None
}
other => serde_yml::to_string(other)
.ok()
.map(|s| s.trim().to_string()),
}
}
fn resolve_content_file(store: &Store, rel: &Path) -> Option<PathBuf> {
let as_written = store.abs_path(rel);
if as_written.is_file() {
return store.rel_path(&as_written).filter(|r| is_content_file(r));
}
let with_md = PathBuf::from(format!("{}.md", path_to_str(rel)));
let abs = store.abs_path(&with_md);
if abs.is_file() {
return store.rel_path(&abs).filter(|r| is_content_file(r));
}
None
}
fn path_in_layer(rel: &Path, layer: Layer) -> bool {
rel.components().next().and_then(|c| c.as_os_str().to_str()) == Some(layer.dir_name())
}
fn is_content_file(rel: &Path) -> bool {
let first = rel.components().next().and_then(|c| c.as_os_str().to_str());
if !matches!(first, Some("sources" | "records" | "wiki")) {
return false;
}
if rel.extension().and_then(|e| e.to_str()) != Some("md") {
return false;
}
rel.file_name().and_then(|n| n.to_str()) != Some("index.md")
}
fn build_searcher() -> Searcher {
SearcherBuilder::new()
.binary_detection(BinaryDetection::quit(b'\x00'))
.line_number(true)
.build()
}
fn intersect(acc: Option<BTreeSet<PathBuf>>, next: BTreeSet<PathBuf>) -> BTreeSet<PathBuf> {
match acc {
None => next,
Some(prev) => prev.intersection(&next).cloned().collect(),
}
}
fn path_to_str(p: &Path) -> String {
p.to_string_lossy().replace('\\', "/")
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
use std::path::Path;
fn corpus_a() -> std::path::PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/corpora/corpus-a-canonical")
.canonicalize()
.expect("corpus-a-canonical must exist")
}
fn args(query: &str) -> SearchArgs {
SearchArgs {
query: query.to_string(),
r#type: None,
r#in: None,
r#where: Vec::new(),
linked_from: None,
linked_to: None,
updated_after: None,
updated_before: None,
created_after: None,
created_before: None,
limit: None,
dir: corpus_a().to_string_lossy().into_owned(),
}
}
fn with_flags(mut a: SearchArgs, flags: &[&str]) -> SearchArgs {
let mut i = 0;
while i < flags.len() {
match flags[i] {
"--type" => a.r#type = Some(flags[i + 1].to_string()),
"--in" => a.r#in = Some(flags[i + 1].to_string()),
"--where" => a.r#where.push(flags[i + 1].to_string()),
"--linked-from" => a.linked_from = Some(flags[i + 1].to_string()),
"--linked-to" => a.linked_to = Some(flags[i + 1].to_string()),
"--updated-after" => a.updated_after = Some(flags[i + 1].to_string()),
"--updated-before" => a.updated_before = Some(flags[i + 1].to_string()),
"--created-after" => a.created_after = Some(flags[i + 1].to_string()),
"--created-before" => a.created_before = Some(flags[i + 1].to_string()),
"--limit" => a.limit = Some(flags[i + 1].parse().expect("limit is a number")),
other => panic!("unhandled test flag {other}"),
}
i += 2;
}
a
}
fn search_files(query: &str, flags: &[&str]) -> BTreeSet<String> {
let store = Store::open(&corpus_a()).expect("open corpus-a");
let a = with_flags(args(query), flags);
let matches = collect_matches(&store, &a).expect("search must succeed");
matches.into_iter().map(|m| m.file).collect()
}
fn set(items: &[&str]) -> BTreeSet<String> {
items.iter().map(|s| s.to_string()).collect()
}
#[test]
fn golden_plain_phrase_no_filter() {
assert_eq!(
search_files("volume discount", &[]),
set(&[
"records/meetings/2026/05/2026-05-22-northstar-renewal-call.md",
"wiki/projects/northstar-renewal.md",
])
);
}
#[test]
fn golden_distinctive_phrase_single_source() {
assert_eq!(
search_files("master services agreement", &[]),
set(&["sources/docs/2026-03-15-northstar-msa.md"])
);
}
#[test]
fn golden_case_sensitive_default() {
assert_eq!(
search_files("async", &[]),
set(&[
"records/contacts/elena-rodriguez.md",
"wiki/people/elena-rodriguez.md",
])
);
}
#[test]
fn golden_layer_scope_in_wiki() {
assert_eq!(
search_files("renewal", &["--in", "wiki"]),
set(&[
"wiki/people/elena-rodriguez.md",
"wiki/people/sarah-chen.md",
"wiki/projects/northstar-renewal.md",
"wiki/synthesis/2026-renewal-plan.md",
])
);
}
#[test]
fn golden_type_scope_meeting() {
assert_eq!(
search_files("175", &["--type", "meeting"]),
set(&["records/meetings/2026/05/2026-05-22-northstar-renewal-call.md"])
);
}
#[test]
fn golden_type_scope_contact_all_four() {
assert_eq!(
search_files("Northstar", &["--type", "contact"]),
set(&[
"records/contacts/david-kim.md",
"records/contacts/elena-rodriguez.md",
"records/contacts/marcus-okafor.md",
"records/contacts/sarah-chen.md",
])
);
}
#[test]
fn golden_regex_alternation_type_invoice() {
assert_eq!(
search_files("(unpaid|void)", &["--type", "invoice"]),
set(&["records/invoices/2026/05/2026-05-31-aws-may.md"])
);
}
#[test]
fn golden_type_scope_company_excludes_other_layers() {
assert_eq!(
search_files("Figma", &["--type", "company"]),
set(&["records/companies/figma.md"])
);
}
#[test]
fn golden_type_scope_expense_single_shard_date() {
assert_eq!(
search_files("2026-05-31", &["--type", "expense"]),
set(&[
"records/expenses/2026/05/2026-05-31-aws-0031.md",
"records/expenses/2026/05/2026-05-31-aws-0124.md",
"records/expenses/2026/05/2026-05-31-aws-0217.md",
"records/expenses/2026/05/2026-05-31-figma-0062.md",
"records/expenses/2026/05/2026-05-31-figma-0155.md",
"records/expenses/2026/05/2026-05-31-github-0093.md",
"records/expenses/2026/05/2026-05-31-github-0186.md",
])
);
}
#[test]
fn golden_type_plus_updated_after() {
assert_eq!(
search_files(
"renewal",
&["--type", "contact", "--updated-after", "2026-05-15"]
),
set(&[
"records/contacts/elena-rodriguez.md",
"records/contacts/sarah-chen.md",
])
);
}
#[test]
fn meta_files_are_never_matched() {
for q in ["db-md", "Knowledge base index"] {
let hits = search_files(q, &[]);
for path in &hits {
assert!(
!path.ends_with("DB.md")
&& !path.ends_with("/index.md")
&& !path.ends_with("index.jsonl")
&& !path.ends_with("log.md"),
"search `{q}` returned meta file {path}"
);
}
}
}
#[test]
fn frontmatter_block_is_searched_not_just_body() {
let hits = search_files("status: active", &["--type", "contact"]);
assert!(
hits.contains("records/contacts/sarah-chen.md"),
"frontmatter line should be searchable, got {hits:?}"
);
}
#[test]
fn no_match_is_empty_success_not_error() {
let store = Store::open(&corpus_a()).unwrap();
let matches = collect_matches(&store, &args("zzz-nonexistent-term-zzz")).unwrap();
assert!(matches.is_empty(), "expected no matches, got {matches:?}");
}
#[test]
fn match_carries_file_line_and_text() {
let store = Store::open(&corpus_a()).unwrap();
let matches = collect_matches(&store, &args("master services agreement")).unwrap();
assert!(!matches.is_empty(), "expected at least one match");
for m in &matches {
assert_eq!(m.file, "sources/docs/2026-03-15-northstar-msa.md");
assert!(m.line >= 1, "line numbers are 1-based, got {}", m.line);
assert!(
m.text.contains("master services agreement"),
"the match text must be the matching line, got {:?}",
m.text
);
assert!(
!m.text.ends_with('\n') && !m.text.ends_with('\r'),
"trailing newline must be trimmed: {:?}",
m.text
);
}
}
#[test]
fn limit_caps_match_count() {
let store = Store::open(&corpus_a()).unwrap();
let a = with_flags(args("Northstar"), &["--type", "contact", "--limit", "1"]);
let matches = collect_matches(&store, &a).unwrap();
assert_eq!(matches.len(), 1, "limit must cap the match count");
}
#[test]
fn linked_to_filters_to_backlinkers() {
let hits = search_files("renewal", &["--linked-to", "records/companies/northstar"]);
assert!(
hits.contains("records/contacts/sarah-chen.md"),
"a backlinker containing the term should match, got {hits:?}"
);
assert!(
!hits.contains("records/companies/northstar.md"),
"the target itself is not its own backlinker: {hits:?}"
);
}
#[test]
fn linked_from_filters_to_forward_targets() {
let seed = "records/meetings/2026/05/2026-05-22-northstar-renewal-call";
let hits = search_files("Operations", &["--linked-from", seed]);
assert!(
hits.contains("records/contacts/sarah-chen.md")
|| hits.contains("records/contacts/elena-rodriguez.md"),
"a forward-linked contact containing the term should match, got {hits:?}"
);
assert!(
!hits
.iter()
.any(|p| p.ends_with("northstar-renewal-call.md")),
"the seed file is not among its own forward targets: {hits:?}"
);
}
#[test]
fn invalid_layer_is_an_error_with_code() {
let store = Store::open(&corpus_a()).unwrap();
let a = with_flags(args("x"), &["--in", "nope"]);
let err = collect_matches(&store, &a).expect_err("bad --in must error");
assert_eq!(err.code, "BAD_LAYER", "got {err:?}");
}
#[test]
fn invalid_query_regex_is_reported() {
let store = Store::open(&corpus_a()).unwrap();
let err =
collect_matches(&store, &args("(unterminated")).expect_err("bad regex must error");
assert_eq!(err.code, "BAD_QUERY_REGEX", "got {err:?}");
}
#[test]
fn invalid_where_clause_is_reported() {
let store = Store::open(&corpus_a()).unwrap();
let a = with_flags(args("x"), &["--type", "contact", "--where", "no-equals"]);
let err = collect_matches(&store, &a).expect_err("bad --where must error");
assert_eq!(err.code, "BAD_WHERE", "got {err:?}");
}
#[test]
fn invalid_timestamp_is_reported() {
let store = Store::open(&corpus_a()).unwrap();
let a = with_flags(
args("x"),
&["--type", "contact", "--updated-after", "yesterday"],
);
let err = collect_matches(&store, &a).expect_err("bad timestamp must error");
assert_eq!(err.code, "BAD_TIMESTAMP", "got {err:?}");
}
#[test]
fn not_a_store_is_rejected() {
let tmp = tempfile::tempdir().unwrap();
let ctx = Context {
json: true,
color: crate::context::ColorChoice::Never,
};
let a = SearchArgs {
dir: tmp.path().to_string_lossy().into_owned(),
..args("x")
};
let err = run(&ctx, &a).expect_err("non-store must error");
assert_eq!(err.code, "NOT_A_STORE", "got {err:?}");
}
#[test]
fn parse_layer_maps_known_and_rejects_unknown() {
assert_eq!(parse_layer(None).unwrap(), None);
assert_eq!(parse_layer(Some("sources")).unwrap(), Some(Layer::Sources));
assert_eq!(parse_layer(Some("records")).unwrap(), Some(Layer::Records));
assert_eq!(parse_layer(Some("wiki")).unwrap(), Some(Layer::Wiki));
assert!(parse_layer(Some("Sources")).is_err(), "case-sensitive");
assert!(parse_layer(Some("log")).is_err());
}
#[test]
fn split_kv_requires_equals() {
assert_eq!(split_kv("status=active").unwrap(), ("status", "active"));
assert_eq!(split_kv("k=a=b").unwrap(), ("k", "a=b"));
assert!(split_kv("no-equals").is_err());
}
#[test]
fn parse_ts_accepts_rfc3339_and_bare_date() {
assert!(parse_ts("2026-05-15T09:00:00Z").is_some());
assert!(parse_ts("2026-05-15T09:00:00-07:00").is_some());
let d = parse_ts("2026-05-15").unwrap();
assert_eq!(d.to_rfc3339(), "2026-05-15T00:00:00+00:00");
assert!(parse_ts("not-a-date").is_none());
}
#[test]
fn time_window_bounds_are_inclusive_and_exclude_undated() {
let w = TimeWindows {
updated_after: parse_ts("2026-05-15"),
updated_before: None,
created_after: None,
created_before: None,
};
let on_cutoff = chrono::DateTime::parse_from_rfc3339("2026-05-15T00:00:00Z").unwrap();
let after = chrono::DateTime::parse_from_rfc3339("2026-05-22T00:00:00Z").unwrap();
let before = chrono::DateTime::parse_from_rfc3339("2026-05-01T00:00:00Z").unwrap();
assert!(w.matches((None, Some(on_cutoff))));
assert!(w.matches((None, Some(after))));
assert!(!w.matches((None, Some(before))));
assert!(!w.matches((None, None)));
}
#[test]
fn time_window_inactive_matches_everything() {
let w = TimeWindows {
updated_after: None,
updated_before: None,
created_after: None,
created_before: None,
};
assert!(!w.is_active());
assert!(w.matches((None, None)), "no bounds → no filtering");
}
#[test]
fn path_in_layer_keys_off_first_component() {
assert!(path_in_layer(Path::new("wiki/people/x.md"), Layer::Wiki));
assert!(!path_in_layer(
Path::new("wiki/people/x.md"),
Layer::Records
));
assert!(path_in_layer(
Path::new("records/contacts/c.md"),
Layer::Records
));
assert!(path_in_layer(
Path::new("sources/emails/2026/05/e.md"),
Layer::Sources
));
assert!(!path_in_layer(
Path::new("records/contacts/c.md"),
Layer::Wiki
));
}
#[test]
fn is_content_file_excludes_meta_and_non_layer() {
assert!(is_content_file(Path::new("records/contacts/sarah.md")));
assert!(is_content_file(Path::new("sources/emails/2026/05/e.md")));
assert!(is_content_file(Path::new("wiki/people/x.md")));
assert!(!is_content_file(Path::new("records/contacts/index.md")));
assert!(!is_content_file(Path::new("records/contacts/index.jsonl")));
assert!(!is_content_file(Path::new("DB.md")));
assert!(!is_content_file(Path::new("log.md")));
assert!(!is_content_file(Path::new("log/2026-04.md")));
}
#[test]
fn intersect_seeds_then_ands() {
let a: BTreeSet<PathBuf> = ["x", "y", "z"].iter().map(PathBuf::from).collect();
let b: BTreeSet<PathBuf> = ["y", "z", "w"].iter().map(PathBuf::from).collect();
assert_eq!(intersect(None, a.clone()), a);
let both = intersect(Some(a), b);
let expected: BTreeSet<PathBuf> = ["y", "z"].iter().map(PathBuf::from).collect();
assert_eq!(both, expected);
}
#[test]
fn frontmatter_block_extracts_between_fences() {
let text = "---\ntype: contact\nupdated: 2026-05-01T00:00:00Z\n---\nbody\n";
let yaml = frontmatter_block(text).unwrap();
assert!(yaml.contains("type: contact"));
assert!(yaml.contains("updated:"));
assert!(!yaml.contains("body"));
assert!(frontmatter_block("no frontmatter\n").is_none());
}
}