use super::*;
use hyalo_core::index::{ScanOptions, ScannedIndex};
use std::fs;
#[allow(clippy::too_many_arguments)]
fn run_find(
dir: &std::path::Path,
site_prefix: Option<&str>,
pattern: Option<&str>,
regexp: Option<&str>,
property_filters: &[PropertyFilter],
tag_filters: &[String],
task_filter: Option<&FindTaskFilter>,
section_filters: &[SectionFilter],
files_arg: &[String],
globs: &[String],
fields: &Fields,
sort: Option<&SortField>,
reverse: bool,
limit: Option<usize>,
broken_links: bool,
title_filter: Option<&str>,
format: Format,
) -> anyhow::Result<CommandOutcome> {
run_find_ext(
dir,
site_prefix,
pattern,
regexp,
property_filters,
tag_filters,
task_filter,
section_filters,
files_arg,
globs,
fields,
sort,
reverse,
limit,
broken_links,
false,
false,
title_filter,
format,
)
}
#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
fn run_find_ext(
dir: &std::path::Path,
site_prefix: Option<&str>,
pattern: Option<&str>,
regexp: Option<&str>,
property_filters: &[PropertyFilter],
tag_filters: &[String],
task_filter: Option<&FindTaskFilter>,
section_filters: &[SectionFilter],
files_arg: &[String],
globs: &[String],
fields: &Fields,
sort: Option<&SortField>,
reverse: bool,
limit: Option<usize>,
broken_links: bool,
orphan: bool,
dead_end: bool,
title_filter: Option<&str>,
format: Format,
) -> anyhow::Result<CommandOutcome> {
let all = hyalo_core::discovery::discover_files(dir)?;
let file_pairs: Vec<(std::path::PathBuf, String)> = all
.into_iter()
.map(|p| {
let rel = hyalo_core::discovery::relative_path(dir, &p);
(p, rel)
})
.collect();
let build = ScannedIndex::build(
&file_pairs,
site_prefix,
&ScanOptions {
scan_body: true,
bm25_tokenize: false,
default_language: None,
frontmatter_link_props: None,
},
)?;
find(
&build.index,
dir,
site_prefix,
pattern,
regexp,
property_filters,
tag_filters,
task_filter,
section_filters,
files_arg,
globs,
fields,
sort,
reverse,
limit,
broken_links,
orphan,
dead_end,
title_filter,
format,
None, None, None, )
}
macro_rules! md {
($s:expr) => {
$s.strip_prefix('\n').unwrap_or($s)
};
}
fn unwrap_success(outcome: CommandOutcome) -> String {
match outcome {
CommandOutcome::Success { output: s, .. } | CommandOutcome::RawOutput(s) => s,
CommandOutcome::UserError(s) => panic!("expected success, got user error: {s}"),
}
}
fn setup_vault() -> tempfile::TempDir {
let tmp = tempfile::tempdir().unwrap();
fs::write(
tmp.path().join("alpha.md"),
md!(r"
---
title: Alpha Note
status: planned
tags:
- rust
- cli
---
# Introduction
See [[beta]] for context.
## Tasks
- [ ] Write tests
- [x] Write code
"),
)
.unwrap();
fs::write(
tmp.path().join("beta.md"),
md!(r"
---
title: Beta Note
status: completed
tags:
- rust
---
# Beta Content
Some content about Rust programming.
"),
)
.unwrap();
fs::write(
tmp.path().join("gamma.md"),
md!(r"
# Gamma (no frontmatter)
Just some text here.
"),
)
.unwrap();
tmp
}
#[test]
fn find_all_files_returns_array() {
let tmp = setup_vault();
let fields = Fields::default();
let out = unwrap_success(
run_find(
tmp.path(),
None,
None,
None,
&[],
&[],
None,
&[],
&[],
&[],
&fields,
None,
false,
None,
false,
None,
Format::Json,
)
.unwrap(),
);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let arr = parsed.as_array().unwrap();
assert_eq!(arr.len(), 3);
}
#[test]
fn find_returns_sorted_by_file_by_default() {
let tmp = setup_vault();
let fields = Fields::default();
let out = unwrap_success(
run_find(
tmp.path(),
None,
None,
None,
&[],
&[],
None,
&[],
&[],
&[],
&fields,
None,
false,
None,
false,
None,
Format::Json,
)
.unwrap(),
);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let arr = parsed.as_array().unwrap();
let files: Vec<&str> = arr.iter().map(|v| v["file"].as_str().unwrap()).collect();
let mut sorted = files.clone();
sorted.sort_unstable();
assert_eq!(files, sorted);
}
#[test]
fn find_always_includes_file_and_modified() {
let tmp = setup_vault();
let fields = Fields::default();
let out = unwrap_success(
run_find(
tmp.path(),
None,
None,
None,
&[],
&[],
None,
&[],
&[],
&[],
&fields,
None,
false,
None,
false,
None,
Format::Json,
)
.unwrap(),
);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let arr = parsed.as_array().unwrap();
for entry in arr {
assert!(entry["file"].as_str().is_some());
let modified = entry["modified"].as_str().unwrap();
assert_eq!(modified.len(), 20, "unexpected modified: {modified}");
assert!(modified.ends_with('Z'));
}
}
#[test]
fn find_property_filter_eq() {
let tmp = setup_vault();
let filter = hyalo_core::filter::parse_property_filter("status=planned").unwrap();
let fields = Fields::default();
let out = unwrap_success(
run_find(
tmp.path(),
None,
None,
None,
&[filter],
&[],
None,
&[],
&[],
&[],
&fields,
None,
false,
None,
false,
None,
Format::Json,
)
.unwrap(),
);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let arr = parsed.as_array().unwrap();
assert_eq!(arr.len(), 1);
assert!(arr[0]["file"].as_str().unwrap().contains("alpha"));
}
#[test]
fn find_property_filter_exists() {
let tmp = setup_vault();
let filter = hyalo_core::filter::parse_property_filter("title").unwrap();
let fields = Fields::default();
let out = unwrap_success(
run_find(
tmp.path(),
None,
None,
None,
&[filter],
&[],
None,
&[],
&[],
&[],
&fields,
None,
false,
None,
false,
None,
Format::Json,
)
.unwrap(),
);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let arr = parsed.as_array().unwrap();
assert_eq!(arr.len(), 3);
}
#[test]
fn find_tag_filter_matches() {
let tmp = setup_vault();
let fields = Fields::default();
let out = unwrap_success(
run_find(
tmp.path(),
None,
None,
None,
&[],
&["cli".to_owned()],
None,
&[],
&[],
&[],
&fields,
None,
false,
None,
false,
None,
Format::Json,
)
.unwrap(),
);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let arr = parsed.as_array().unwrap();
assert_eq!(arr.len(), 1);
assert!(arr[0]["file"].as_str().unwrap().contains("alpha"));
}
#[test]
fn find_tag_filter_no_match() {
let tmp = setup_vault();
let fields = Fields::default();
let out = unwrap_success(
run_find(
tmp.path(),
None,
None,
None,
&[],
&["nonexistent-tag".to_owned()],
None,
&[],
&[],
&[],
&fields,
None,
false,
None,
false,
None,
Format::Json,
)
.unwrap(),
);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let arr = parsed.as_array().unwrap();
assert!(arr.is_empty());
}
#[test]
fn find_pattern_matches_content() {
let tmp = setup_vault();
let fields = Fields::default();
let out = unwrap_success(
run_find(
tmp.path(),
None,
Some("Rust programming"),
None,
&[],
&[],
None,
&[],
&[],
&[],
&fields,
None,
false,
None,
false,
None,
Format::Json,
)
.unwrap(),
);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let arr = parsed.as_array().unwrap();
assert_eq!(arr.len(), 1);
assert!(arr[0]["file"].as_str().unwrap().contains("beta"));
}
#[test]
fn find_pattern_includes_score_field() {
let tmp = setup_vault();
let fields = Fields::default();
let out = unwrap_success(
run_find(
tmp.path(),
None,
Some("Rust"),
None,
&[],
&[],
None,
&[],
&[],
&[],
&fields,
None,
false,
None,
false,
None,
Format::Json,
)
.unwrap(),
);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let arr = parsed.as_array().unwrap();
assert!(!arr.is_empty(), "should have at least one result");
for entry in arr {
let score = entry["score"].as_f64();
assert!(score.is_some(), "BM25 result should have a score field");
assert!(score.unwrap() > 0.0, "score should be positive");
}
}
#[test]
fn find_no_pattern_no_matches_field() {
let tmp = setup_vault();
let fields = Fields::default();
let out = unwrap_success(
run_find(
tmp.path(),
None,
None,
None,
&[],
&[],
None,
&[],
&[],
&[],
&fields,
None,
false,
None,
false,
None,
Format::Json,
)
.unwrap(),
);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let arr = parsed.as_array().unwrap();
for entry in arr {
assert!(entry["matches"].is_null(), "matches should be absent");
}
}
fn setup_vault_with_empty_body() -> tempfile::TempDir {
let tmp = tempfile::tempdir().unwrap();
fs::write(
tmp.path().join("has_body.md"),
md!(r"
---
title: Has Body
---
Some content here.
"),
)
.unwrap();
fs::write(
tmp.path().join("empty_body.md"),
md!(r"
---
title: Empty Body
---
"),
)
.unwrap();
tmp
}
#[test]
fn find_empty_string_pattern_errors() {
let tmp = setup_vault_with_empty_body();
let fields = Fields::default();
let outcome = run_find(
tmp.path(),
None,
Some(""),
None,
&[],
&[],
None,
&[],
&[],
&[],
&fields,
None,
false,
None,
false,
None,
Format::Json,
)
.unwrap();
match outcome {
CommandOutcome::UserError(msg) => {
assert!(
msg.contains("body pattern must not be empty"),
"error message should mention empty pattern, got: {msg}"
);
}
_ => panic!("expected user error for empty pattern"),
}
}
#[test]
fn find_whitespace_only_pattern_errors() {
let tmp = setup_vault_with_empty_body();
let fields = Fields::default();
let outcome = run_find(
tmp.path(),
None,
Some(" "),
None,
&[],
&[],
None,
&[],
&[],
&[],
&fields,
None,
false,
None,
false,
None,
Format::Json,
)
.unwrap();
match outcome {
CommandOutcome::UserError(msg) => {
assert!(
msg.contains("body pattern must not be empty"),
"error message should mention empty pattern, got: {msg}"
);
}
_ => panic!("expected user error for whitespace-only pattern"),
}
}
#[test]
fn find_task_filter_todo() {
let tmp = setup_vault();
let fields = Fields::default();
let out = unwrap_success(
run_find(
tmp.path(),
None,
None,
None,
&[],
&[],
Some(&FindTaskFilter::Todo),
&[],
&[],
&[],
&fields,
None,
false,
None,
false,
None,
Format::Json,
)
.unwrap(),
);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let arr = parsed.as_array().unwrap();
assert_eq!(arr.len(), 1);
assert!(arr[0]["file"].as_str().unwrap().contains("alpha"));
}
#[test]
fn find_task_filter_any() {
let tmp = setup_vault();
let fields = Fields::default();
let out = unwrap_success(
run_find(
tmp.path(),
None,
None,
None,
&[],
&[],
Some(&FindTaskFilter::Any),
&[],
&[],
&[],
&fields,
None,
false,
None,
false,
None,
Format::Json,
)
.unwrap(),
);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let arr = parsed.as_array().unwrap();
assert_eq!(arr.len(), 1);
assert!(arr[0]["file"].as_str().unwrap().contains("alpha"));
}
#[test]
fn find_fields_subset_properties_only() {
let tmp = setup_vault();
let fields = Fields::parse(&["properties".to_owned()]).unwrap();
let out = unwrap_success(
run_find(
tmp.path(),
None,
None,
None,
&[],
&[],
None,
&[],
&[],
&[],
&fields,
None,
false,
None,
false,
None,
Format::Json,
)
.unwrap(),
);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let arr = parsed.as_array().unwrap();
for entry in arr {
assert!(entry["properties"].is_object());
assert!(entry["tags"].is_null());
assert!(entry["sections"].is_null());
assert!(entry["tasks"].is_null());
assert!(entry["links"].is_null());
}
}
#[test]
fn find_fields_tasks_included() {
let tmp = setup_vault();
let fields = Fields::parse(&["tasks".to_owned()]).unwrap();
let out = unwrap_success(
run_find(
tmp.path(),
None,
None,
None,
&[],
&[],
None,
&[],
&[],
&[],
&fields,
None,
false,
None,
false,
None,
Format::Json,
)
.unwrap(),
);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let arr = parsed.as_array().unwrap();
let alpha = arr
.iter()
.find(|e| e["file"].as_str().unwrap().contains("alpha"))
.unwrap();
let tasks = alpha["tasks"].as_array().unwrap();
assert_eq!(tasks.len(), 2);
let open = tasks.iter().find(|t| t["status"] == " ").unwrap();
assert!(!open["done"].as_bool().unwrap());
let done = tasks.iter().find(|t| t["done"].as_bool().unwrap()).unwrap();
assert!(done["done"].as_bool().unwrap());
}
#[test]
fn find_fields_links_resolved() {
let tmp = setup_vault();
let fields = Fields::parse(&["links".to_owned()]).unwrap();
let out = unwrap_success(
run_find(
tmp.path(),
None,
None,
None,
&[],
&[],
None,
&[],
&["alpha.md".to_owned()],
&[],
&fields,
None,
false,
None,
false,
None,
Format::Json,
)
.unwrap(),
);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let arr = parsed.as_array().unwrap();
let alpha = &arr[0];
let links = alpha["links"].as_array().unwrap();
assert!(!links.is_empty());
let beta_link = links.iter().find(|l| l["target"] == "beta").unwrap();
assert_eq!(beta_link["path"], "beta.md");
}
#[test]
fn find_limit_truncates_results() {
let tmp = setup_vault();
let fields = Fields::default();
let out = unwrap_success(
run_find(
tmp.path(),
None,
None,
None,
&[],
&[],
None,
&[],
&[],
&[],
&fields,
None,
false,
Some(1),
false,
None,
Format::Json,
)
.unwrap(),
);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let results = parsed.as_array().unwrap();
assert_eq!(results.len(), 1);
}
#[test]
fn find_sort_by_modified() {
let tmp = setup_vault();
let fields = Fields::default();
let out = unwrap_success(
run_find(
tmp.path(),
None,
None,
None,
&[],
&[],
None,
&[],
&[],
&[],
&fields,
Some(&SortField::Modified),
false,
None,
false,
None,
Format::Json,
)
.unwrap(),
);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let arr = parsed.as_array().unwrap();
let times: Vec<&str> = arr
.iter()
.map(|v| v["modified"].as_str().unwrap())
.collect();
let mut sorted = times.clone();
sorted.sort_unstable();
assert_eq!(times, sorted);
}
#[test]
fn find_no_match_returns_empty_array() {
let tmp = setup_vault();
let filter = hyalo_core::filter::parse_property_filter("status=nonexistent").unwrap();
let fields = Fields::default();
let out = unwrap_success(
run_find(
tmp.path(),
None,
None,
None,
&[filter],
&[],
None,
&[],
&[],
&[],
&fields,
None,
false,
None,
false,
None,
Format::Json,
)
.unwrap(),
);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert!(parsed.as_array().unwrap().is_empty());
}
#[test]
fn find_file_not_found_returns_empty_results() {
let tmp = setup_vault();
let fields = Fields::default();
let result = run_find(
tmp.path(),
None,
None,
None,
&[],
&[],
None,
&[],
&["does-not-exist.md".to_owned()],
&[],
&fields,
None,
false,
None,
false,
None,
Format::Json,
)
.unwrap();
let out = unwrap_success(result);
let json: serde_json::Value = serde_json::from_str(&out).unwrap();
assert!(json.as_array().unwrap().is_empty());
}
fn make_task(done: bool, status: char) -> FindTaskInfo {
FindTaskInfo {
line: 1,
section: String::new(),
status,
text: "task text".to_owned(),
done,
}
}
#[test]
fn task_filter_any_empty() {
assert!(!matches_task_filter(&[], &FindTaskFilter::Any));
}
#[test]
fn task_filter_any_with_tasks() {
let tasks = vec![make_task(false, ' ')];
assert!(matches_task_filter(&tasks, &FindTaskFilter::Any));
}
#[test]
fn task_filter_todo_open_task() {
let tasks = vec![make_task(false, ' ')];
assert!(matches_task_filter(&tasks, &FindTaskFilter::Todo));
}
#[test]
fn task_filter_todo_no_open_tasks() {
let tasks = vec![make_task(true, 'x')];
assert!(!matches_task_filter(&tasks, &FindTaskFilter::Todo));
}
#[test]
fn task_filter_done_with_done_task() {
let tasks = vec![make_task(true, 'x')];
assert!(matches_task_filter(&tasks, &FindTaskFilter::Done));
}
#[test]
fn task_filter_status_char_match() {
let tasks = vec![make_task(false, '~')];
assert!(matches_task_filter(&tasks, &FindTaskFilter::Status('~')));
assert!(!matches_task_filter(&tasks, &FindTaskFilter::Status('!')));
}
fn setup_sectioned_vault() -> tempfile::TempDir {
let tmp = tempfile::tempdir().unwrap();
fs::write(
tmp.path().join("notes.md"),
md!(r"
---
title: Notes
status: active
---
# Introduction
Some intro text.
## Tasks
- [ ] First task
- [x] Done task
## Background
Background information here.
"),
)
.unwrap();
fs::write(
tmp.path().join("empty.md"),
md!(r"
---
title: Empty
---
# Introduction
Just intro, no tasks section.
"),
)
.unwrap();
tmp
}
#[test]
fn find_section_filter_restricts_tasks_to_matching_section() {
let tmp = setup_sectioned_vault();
let fields = Fields::parse(&["tasks".to_owned()]).unwrap();
let section_filters = vec![SectionFilter::parse("Tasks").unwrap()];
let out = unwrap_success(
run_find(
tmp.path(),
None,
None,
None,
&[],
&[],
None,
§ion_filters,
&["notes.md".to_owned()],
&[],
&fields,
None,
false,
None,
false,
None,
Format::Json,
)
.unwrap(),
);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let arr = parsed.as_array().unwrap();
assert_eq!(arr.len(), 1);
let tasks = arr[0]["tasks"].as_array().unwrap();
assert_eq!(tasks.len(), 2, "should have 2 tasks in Tasks section");
}
#[test]
fn find_section_filter_no_match_excludes_tasks() {
let tmp = setup_sectioned_vault();
let fields = Fields::parse(&["tasks".to_owned()]).unwrap();
let section_filters = vec![SectionFilter::parse("Nonexistent").unwrap()];
let out = unwrap_success(
run_find(
tmp.path(),
None,
None,
None,
&[],
&[],
None,
§ion_filters,
&["notes.md".to_owned()],
&[],
&fields,
None,
false,
None,
false,
None,
Format::Json,
)
.unwrap(),
);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let arr = parsed.as_array().unwrap();
assert!(
arr.is_empty(),
"file with no matching section should be excluded"
);
}
#[test]
fn find_section_filter_restricts_content_matches() {
let tmp = setup_sectioned_vault();
let fields = Fields::default();
let section_filters = vec![SectionFilter::parse("Tasks").unwrap()];
let out = unwrap_success(
run_find(
tmp.path(),
None,
Some("background"),
None,
&[],
&[],
None,
§ion_filters,
&[],
&[],
&fields,
None,
false,
None,
false,
None,
Format::Json,
)
.unwrap(),
);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let arr = parsed.as_array().unwrap();
assert!(
arr.is_empty(),
"no files should match: 'background' is not in the Tasks section"
);
}
#[test]
fn find_section_filter_sections_field_filtered() {
let tmp = setup_sectioned_vault();
let fields = Fields::parse(&["sections".to_owned()]).unwrap();
let section_filters = vec![SectionFilter::parse("Tasks").unwrap()];
let out = unwrap_success(
run_find(
tmp.path(),
None,
None,
None,
&[],
&[],
None,
§ion_filters,
&["notes.md".to_owned()],
&[],
&fields,
None,
false,
None,
false,
None,
Format::Json,
)
.unwrap(),
);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let arr = parsed.as_array().unwrap();
assert_eq!(arr.len(), 1);
let sections = arr[0]["sections"].as_array().unwrap();
assert!(
sections.iter().all(|s| {
s["heading"]
.as_str()
.is_some_and(|h| h.eq_ignore_ascii_case("tasks"))
}),
"sections output should only contain the matched section"
);
}
#[test]
fn find_section_filter_empty_no_section_excludes_file() {
let tmp = setup_sectioned_vault();
let fields = Fields::parse(&["tasks".to_owned()]).unwrap();
let section_filters = vec![SectionFilter::parse("Tasks").unwrap()];
let out = unwrap_success(
run_find(
tmp.path(),
None,
None,
None,
&[],
&[],
None,
§ion_filters,
&["empty.md".to_owned()],
&[],
&fields,
None,
false,
None,
false,
None,
Format::Json,
)
.unwrap(),
);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let arr = parsed.as_array().unwrap();
assert!(
arr.is_empty(),
"file with no matching section should be excluded"
);
}
#[test]
fn find_skips_broken_frontmatter_file() {
let tmp = setup_vault();
fs::write(
tmp.path().join("broken.md"),
"---\ntitle: Broken\nNo closing delimiter.\n",
)
.unwrap();
let fields = Fields::default();
let result = run_find(
tmp.path(),
None,
None,
None,
&[],
&[],
None,
&[],
&[],
&[],
&fields,
None,
false,
None,
false,
None,
Format::Json,
)
.unwrap();
let out = unwrap_success(result);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let arr = parsed.as_array().unwrap();
assert_eq!(arr.len(), 3);
assert!(
!arr.iter()
.any(|e| e["file"].as_str().unwrap().contains("broken"))
);
}
#[test]
fn find_fields_properties_typed_is_array() {
let tmp = setup_vault();
let fields = Fields::parse(&["properties-typed".to_owned()]).unwrap();
let out = unwrap_success(
run_find(
tmp.path(),
None,
None,
None,
&[],
&[],
None,
&[],
&["alpha.md".to_owned()],
&[],
&fields,
None,
false,
None,
false,
None,
Format::Json,
)
.unwrap(),
);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let arr = parsed.as_array().unwrap();
let entry = &arr[0];
let typed = entry["properties_typed"]
.as_array()
.expect("properties_typed should be an array");
assert!(!typed.is_empty());
for item in typed {
assert!(
item["name"].is_string(),
"each item must have a string 'name'"
);
assert!(
item["type"].is_string(),
"each item must have a string 'type'"
);
assert!(!item["value"].is_null(), "each item must have a 'value'");
}
assert!(
typed.iter().all(|p| p["name"] != "tags"),
"tags should not appear in properties_typed"
);
assert!(
entry["properties"].is_null(),
"properties map should be absent"
);
}
#[test]
fn find_fields_properties_and_properties_typed_together() {
let tmp = setup_vault();
let fields = Fields::parse(&["properties,properties-typed".to_owned()]).unwrap();
let out = unwrap_success(
run_find(
tmp.path(),
None,
None,
None,
&[],
&[],
None,
&[],
&["alpha.md".to_owned()],
&[],
&fields,
None,
false,
None,
false,
None,
Format::Json,
)
.unwrap(),
);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let arr = parsed.as_array().unwrap();
let entry = &arr[0];
assert!(
entry["properties"].is_object(),
"properties map should be present"
);
assert!(
entry["properties_typed"].is_array(),
"properties_typed should be present"
);
}
#[test]
fn find_fields_properties_typed_type_values() {
let tmp = setup_vault();
let fields = Fields::parse(&["properties-typed".to_owned()]).unwrap();
let out = unwrap_success(
run_find(
tmp.path(),
None,
None,
None,
&[],
&[],
None,
&[],
&["alpha.md".to_owned()],
&[],
&fields,
None,
false,
None,
false,
None,
Format::Json,
)
.unwrap(),
);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let arr = parsed.as_array().unwrap();
let typed = arr[0]["properties_typed"].as_array().unwrap();
let status = typed
.iter()
.find(|p| p["name"] == "status")
.expect("status property missing");
assert_eq!(status["type"], "text");
assert_eq!(status["value"], "planned");
}
#[test]
fn find_fields_backlinks_shows_incoming_links() {
let tmp = setup_vault();
let fields = Fields::parse(&["backlinks".to_owned()]).unwrap();
let out = unwrap_success(
run_find(
tmp.path(),
None,
None,
None,
&[],
&[],
None,
&[],
&["beta.md".to_owned()],
&[],
&fields,
None,
false,
None,
false,
None,
Format::Json,
)
.unwrap(),
);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let arr = parsed.as_array().unwrap();
let beta = &arr[0];
let backlinks = beta["backlinks"].as_array().unwrap();
assert_eq!(backlinks.len(), 1);
assert_eq!(backlinks[0]["source"], "alpha.md");
assert!(backlinks[0]["line"].as_u64().unwrap() > 0);
}
#[test]
fn find_fields_backlinks_not_included_by_default() {
let tmp = setup_vault();
let fields = Fields::default();
let out = unwrap_success(
run_find(
tmp.path(),
None,
None,
None,
&[],
&[],
None,
&[],
&[],
&[],
&fields,
None,
false,
None,
false,
None,
Format::Json,
)
.unwrap(),
);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let arr = parsed.as_array().unwrap();
for entry in arr {
assert!(
!entry.as_object().unwrap().contains_key("backlinks"),
"backlinks key should be absent by default, not just null"
);
}
}
#[test]
fn find_fields_backlinks_empty_when_no_incoming() {
let tmp = setup_vault();
let fields = Fields::parse(&["backlinks".to_owned()]).unwrap();
let out = unwrap_success(
run_find(
tmp.path(),
None,
None,
None,
&[],
&[],
None,
&[],
&["gamma.md".to_owned()],
&[],
&fields,
None,
false,
None,
false,
None,
Format::Json,
)
.unwrap(),
);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let arr = parsed.as_array().unwrap();
let gamma = &arr[0];
let backlinks = gamma["backlinks"].as_array().unwrap();
assert!(backlinks.is_empty());
}
#[test]
fn content_search_works_with_frontmatter_only_index() {
let tmp = setup_vault();
let all = hyalo_core::discovery::discover_files(tmp.path()).unwrap();
let file_pairs: Vec<(std::path::PathBuf, String)> = all
.into_iter()
.map(|p| {
let rel = hyalo_core::discovery::relative_path(tmp.path(), &p);
(p, rel)
})
.collect();
let build = ScannedIndex::build(
&file_pairs,
None,
&ScanOptions {
scan_body: false,
bm25_tokenize: false,
default_language: None,
frontmatter_link_props: None,
},
)
.unwrap();
let fields = Fields::default();
let out = unwrap_success(
find(
&build.index,
tmp.path(),
None,
Some("Rust programming"),
None,
&[],
&[],
None,
&[],
&[],
&[],
&fields,
None,
false,
None,
false,
false,
false,
None,
Format::Json,
None,
None,
None, )
.unwrap(),
);
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let arr = parsed.as_array().unwrap();
assert_eq!(arr.len(), 1, "should find beta.md via BM25 content search");
assert!(arr[0]["file"].as_str().unwrap().contains("beta"));
let score = arr[0]["score"].as_f64();
assert!(
score.is_some(),
"BM25 result should include a relevance score"
);
assert!(score.unwrap() > 0.0, "score should be positive");
}
fn setup_link_vault() -> tempfile::TempDir {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("a.md"), "---\ntitle: A\n---\n[[b]]\n").unwrap();
fs::write(
tmp.path().join("b.md"),
"---\ntitle: B\n---\nNo outbound.\n",
)
.unwrap();
fs::write(
tmp.path().join("c.md"),
"---\ntitle: C\n---\nNo links at all.\n",
)
.unwrap();
tmp
}
#[test]
fn find_orphan_returns_isolated_files() {
let tmp = setup_link_vault();
let out = unwrap_success(
run_find_ext(
tmp.path(),
None,
None,
None,
&[],
&[],
None,
&[],
&[],
&[],
&Fields::default(),
None,
false,
None,
false,
true, false, None,
Format::Json,
)
.unwrap(),
);
let arr = serde_json::from_str::<serde_json::Value>(&out)
.unwrap()
.as_array()
.unwrap()
.clone();
let files: Vec<&str> = arr.iter().map(|v| v["file"].as_str().unwrap()).collect();
assert_eq!(files, vec!["c.md"], "orphan files: {files:?}");
assert!(arr[0]["backlinks"].is_array());
}
#[test]
fn find_dead_end_returns_files_with_inbound_only() {
let tmp = setup_link_vault();
let out = unwrap_success(
run_find_ext(
tmp.path(),
None,
None,
None,
&[],
&[],
None,
&[],
&[],
&[],
&Fields::default(),
None,
false,
None,
false,
false, true, None,
Format::Json,
)
.unwrap(),
);
let arr = serde_json::from_str::<serde_json::Value>(&out)
.unwrap()
.as_array()
.unwrap()
.clone();
let files: Vec<&str> = arr.iter().map(|v| v["file"].as_str().unwrap()).collect();
assert_eq!(files, vec!["b.md"], "dead-end files: {files:?}");
assert!(arr[0]["links"].is_array());
}
#[test]
fn find_orphan_composes_with_sort_and_limit() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("a.md"), "---\ntitle: A\n---\nNo links.\n").unwrap();
fs::write(tmp.path().join("b.md"), "---\ntitle: B\n---\nNo links.\n").unwrap();
fs::write(tmp.path().join("c.md"), "---\ntitle: C\n---\nNo links.\n").unwrap();
let out = unwrap_success(
run_find_ext(
tmp.path(),
None,
None,
None,
&[],
&[],
None,
&[],
&[],
&[],
&Fields::default(),
Some(&SortField::File),
false,
Some(2), false,
true, false, None,
Format::Json,
)
.unwrap(),
);
let arr = serde_json::from_str::<serde_json::Value>(&out)
.unwrap()
.as_array()
.unwrap()
.clone();
assert_eq!(arr.len(), 2, "limit=2 should cap results");
let files: Vec<&str> = arr.iter().map(|v| v["file"].as_str().unwrap()).collect();
assert_eq!(files, vec!["a.md", "b.md"], "sorted by file, limited to 2");
}