use super::common::{hyalo, md, write_md};
use tempfile::TempDir;
fn setup_vault() -> TempDir {
let tmp = TempDir::new().unwrap();
write_md(
tmp.path(),
"notes/alpha.md",
md!(r"
---
title: Alpha
status: in-progress
tags:
- rust
- cli
---
# Alpha
- [ ] Open task
- [x] Done task
"),
);
write_md(
tmp.path(),
"notes/beta.md",
md!(r"
---
title: Beta
status: completed
tags:
- rust
---
# Beta
- [x] Completed
"),
);
write_md(
tmp.path(),
"docs/readme.md",
md!(r"
---
title: Readme
status: planned
tags:
- docs
---
# Readme
No tasks here.
"),
);
tmp
}
#[test]
fn summary_hints_json_has_data_and_hints() {
let tmp = setup_vault();
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args(["summary", "--hints", "--format", "json"])
.output()
.unwrap();
let stdout = String::from_utf8(output.stdout).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert!(parsed.get("results").is_some(), "missing 'results' key");
assert!(parsed.get("hints").is_some(), "missing 'hints' key");
assert!(parsed["results"]["files"]["total"].as_u64().unwrap() > 0);
let hints = parsed["hints"].as_array().unwrap();
assert!(!hints.is_empty());
for hint in hints {
assert!(
hint["cmd"].as_str().unwrap().starts_with("hyalo"),
"hint cmd should start with hyalo: {hint}"
);
assert!(
hint.get("description").and_then(|d| d.as_str()).is_some(),
"hint should have description: {hint}"
);
}
}
#[test]
fn summary_hints_text_has_arrow_lines() {
let tmp = setup_vault();
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args(["summary", "--hints", "--format", "text"])
.output()
.unwrap();
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(
stdout.contains("Files:"),
"should have normal summary output"
);
assert!(
stdout.contains(" -> hyalo"),
"should have hint lines with arrow prefix"
);
assert!(
stdout.contains("properties"),
"should suggest properties command: {stdout}"
);
assert!(
stdout.contains("tags"),
"should suggest tags command: {stdout}"
);
}
#[test]
fn summary_hints_suggests_tasks_todo_when_open_tasks() {
let tmp = setup_vault();
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args(["summary", "--hints", "--format", "text"])
.output()
.unwrap();
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(
stdout.contains("find --task todo"),
"should suggest find --task todo when there are open tasks: {stdout}"
);
}
#[test]
fn summary_hints_prefers_interesting_status() {
let tmp = setup_vault();
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args(["summary", "--hints", "--format", "text"])
.output()
.unwrap();
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(
stdout.contains("in-progress"),
"should suggest in-progress status: {stdout}"
);
}
#[test]
fn properties_hints_text() {
let tmp = setup_vault();
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args(["properties", "summary", "--hints", "--format", "text"])
.output()
.unwrap();
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(
stdout.contains("find --property"),
"should suggest find --property: {stdout}"
);
}
#[test]
fn tags_hints_text() {
let tmp = setup_vault();
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args(["tags", "summary", "--hints", "--format", "text"])
.output()
.unwrap();
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(
stdout.contains("find --tag rust"),
"should suggest find --tag for top tag: {stdout}"
);
}
#[test]
fn summary_hints_active_by_default_text() {
let tmp = setup_vault();
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args(["summary", "--format", "text"])
.output()
.unwrap();
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(
stdout.contains(" -> hyalo"),
"hints should appear by default without --hints flag: {stdout}"
);
}
#[test]
fn find_hints_active_by_default_json() {
let tmp = setup_vault();
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args(["find", "--format", "json"])
.output()
.unwrap();
let stdout = String::from_utf8(output.stdout).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert!(
parsed.get("results").is_some(),
"JSON should have hints envelope by default: {stdout}"
);
assert!(
parsed.get("hints").is_some(),
"JSON should have hints array by default: {stdout}"
);
}
#[test]
fn summary_without_hints_no_arrows() {
let tmp = setup_vault();
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args(["summary", "--no-hints", "--format", "text"])
.output()
.unwrap();
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(!stdout.contains(" -> "), "should not have hint arrows");
}
#[test]
fn summary_without_hints_json_no_envelope() {
let tmp = setup_vault();
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args(["summary", "--no-hints", "--format", "json"])
.output()
.unwrap();
let stdout = String::from_utf8(output.stdout).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert!(parsed.get("results").is_some(), "should have 'results' key");
let hints = parsed["hints"].as_array().expect("hints should be array");
assert!(hints.is_empty(), "hints should be empty with --no-hints");
assert!(parsed["results"].get("files").is_some());
}
#[test]
fn hints_suppressed_with_jq() {
let tmp = setup_vault();
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args(["summary", "--hints", "--jq", ".results.tasks.total"])
.output()
.unwrap();
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(
!stdout.contains("hints"),
"hints should be suppressed with --jq"
);
assert!(!stdout.contains("->"), "no arrow lines with --jq");
let val: u64 = stdout.trim().parse().unwrap();
assert!(val > 0);
}
#[test]
fn hints_propagate_dir_flag() {
let tmp = setup_vault();
let dir_str = tmp.path().to_str().unwrap();
let output = hyalo()
.args(["--dir", dir_str])
.args(["summary", "--hints", "--format", "text"])
.output()
.unwrap();
let stdout = String::from_utf8(output.stdout).unwrap();
for line in stdout.lines() {
if line.starts_with(" -> ") {
assert!(
line.contains("--dir"),
"hint should include --dir flag: {line}"
);
}
}
}
#[test]
fn hints_propagate_glob_for_aggregate_commands() {
let tmp = setup_vault();
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args([
"properties",
"summary",
"--glob",
"notes/*.md",
"--hints",
"--format",
"text",
])
.output()
.unwrap();
let stdout = String::from_utf8(output.stdout).unwrap();
let hint_lines: Vec<&str> = stdout.lines().filter(|l| l.starts_with(" -> ")).collect();
assert!(!hint_lines.is_empty(), "should have hints");
for line in &hint_lines {
assert!(
line.contains("--glob"),
"aggregate hint should propagate --glob: {line}"
);
}
}
#[test]
fn find_hints_with_task_filter() {
let tmp = setup_vault();
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args(["find", "--task", "todo", "--hints", "--format", "text"])
.output()
.unwrap();
assert!(output.status.success());
}
#[test]
fn find_format_text_with_hints_outputs_text_not_json() {
let tmp = setup_vault();
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args(["find", "--format", "text", "--hints"])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(output.status.success(), "find --format text --hints failed");
let trimmed = stdout.trim_start();
assert!(
!trimmed.starts_with('[') && !trimmed.starts_with('{'),
"expected text output but got JSON: {}",
&stdout[..stdout.len().min(200)]
);
}
#[test]
fn set_format_text_with_hints_outputs_text_not_json() {
let tmp = setup_vault();
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args([
"set",
"--property",
"status=updated",
"--file",
"notes/alpha.md",
"--format",
"text",
"--hints",
])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success(),
"set --format text --hints failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let trimmed = stdout.trim_start();
assert!(
!trimmed.starts_with('[') && !trimmed.starts_with('{'),
"expected text output but got JSON: {}",
&stdout[..stdout.len().min(200)]
);
}
#[test]
fn set_hints_accepted_produces_valid_json() {
let tmp = setup_vault();
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args(["--hints"])
.args([
"set",
"--property",
"status=updated",
"--file",
"notes/alpha.md",
])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("expected valid JSON, got: {stdout}\nerr: {e}"));
assert!(
parsed.get("results").is_some(),
"should have results envelope: {parsed}"
);
assert!(
parsed["results"].get("modified").is_some(),
"data should have modified field: {parsed}"
);
assert!(
parsed
.get("hints")
.and_then(|h| h.as_array())
.is_some_and(|a| !a.is_empty()),
"mutation commands should generate hints: {parsed}"
);
}
#[test]
fn remove_hints_accepted_produces_valid_json() {
let tmp = setup_vault();
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args(["--hints"])
.args(["remove", "--property", "status", "--file", "notes/alpha.md"])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("expected valid JSON, got: {stdout}\nerr: {e}"));
assert!(
parsed.get("results").is_some(),
"should have results envelope: {parsed}"
);
assert!(
parsed["results"].get("modified").is_some(),
"data should have modified field: {parsed}"
);
assert!(
parsed
.get("hints")
.and_then(|h| h.as_array())
.is_some_and(|a| !a.is_empty()),
"mutation commands should generate hints: {parsed}"
);
}
#[test]
fn append_hints_accepted_produces_valid_json() {
let tmp = setup_vault();
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args(["--hints"])
.args([
"append",
"--property",
"aliases=alpha-note",
"--file",
"notes/alpha.md",
])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("expected valid JSON, got: {stdout}\nerr: {e}"));
assert!(
parsed.get("results").is_some(),
"should have results envelope: {parsed}"
);
assert!(
parsed["results"].get("modified").is_some(),
"data should have modified field: {parsed}"
);
assert!(
parsed
.get("hints")
.and_then(|h| h.as_array())
.is_some_and(|a| !a.is_empty()),
"mutation commands should generate hints: {parsed}"
);
}
#[test]
fn find_with_hints_shows_suggestions() {
let tmp = setup_vault();
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args(["find", "--hints", "--format", "text"])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(
stdout.contains(" -> hyalo"),
"should have hint lines with arrow prefix: {stdout}"
);
assert!(
stdout.contains("hyalo read ") && stdout.contains(".md"),
"should suggest read <file> for first result: {stdout}"
);
assert!(
!stdout.contains("read --file"),
"read hint should use positional form, not --file: {stdout}"
);
assert!(
stdout.contains("hyalo backlinks ") && stdout.contains(".md"),
"should suggest backlinks <file> for first result: {stdout}"
);
assert!(
!stdout.contains("backlinks --file"),
"backlinks hint should use positional form, not --file: {stdout}"
);
}
#[test]
fn find_with_hints_json_envelope() {
let tmp = setup_vault();
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args(["find", "--hints", "--format", "json"])
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert!(parsed.get("results").is_some(), "should have 'results' key");
let hints = parsed["hints"].as_array().unwrap();
assert!(!hints.is_empty(), "should have at least one hint");
for hint in hints {
assert!(
hint["cmd"].as_str().unwrap().starts_with("hyalo"),
"hint cmd should start with hyalo: {hint}"
);
}
}
#[test]
fn find_with_hints_empty_results_no_hints() {
let tmp = setup_vault();
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args([
"find",
"--tag",
"nonexistent-tag-xyz",
"--hints",
"--format",
"json",
])
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
if let Some(hints) = parsed.get("hints") {
assert!(
hints.as_array().is_some_and(std::vec::Vec::is_empty),
"expected empty hints for empty results: {parsed}"
);
}
}
#[test]
fn mutation_with_hints_warns() {
let tmp = setup_vault();
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args([
"set",
"--hints",
"--property",
"status=updated",
"--file",
"notes/alpha.md",
])
.output()
.unwrap();
assert!(
output.status.success(),
"set --hints should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(
!stderr.contains("--hints has no effect"),
"should not warn about --hints on mutation commands: {stderr}"
);
}
#[test]
fn remove_with_hints_warns() {
let tmp = setup_vault();
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args([
"remove",
"--hints",
"--property",
"status",
"--file",
"notes/beta.md",
])
.output()
.unwrap();
assert!(
output.status.success(),
"remove --hints should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(
!stderr.contains("--hints has no effect"),
"should not warn about --hints on mutation commands: {stderr}"
);
}
#[test]
fn append_with_hints_warns() {
let tmp = setup_vault();
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args([
"append",
"--hints",
"--property",
"aliases=hint-test",
"--file",
"notes/alpha.md",
])
.output()
.unwrap();
assert!(
output.status.success(),
"append --hints should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(
!stderr.contains("--hints has no effect"),
"should not warn about --hints on mutation commands: {stderr}"
);
}
fn setup_large_vault() -> TempDir {
let tmp = TempDir::new().unwrap();
for (name, status, tags) in &[
("a", "planned", vec!["rust", "cli"]),
("b", "planned", vec!["rust"]),
("c", "planned", vec!["rust"]),
("d", "planned", vec!["rust"]),
("e", "completed", vec!["cli"]),
("f", "completed", vec!["docs"]),
] {
let tags_yaml: String = tags
.iter()
.map(|t| format!(" - {t}"))
.collect::<Vec<_>>()
.join("\n");
write_md(
tmp.path(),
&format!("{name}.md"),
md!(&format!(
"---\ntitle: {name}\nstatus: {status}\ntags:\n{tags_yaml}\n---\n# {name}\n"
)),
);
}
tmp
}
#[test]
fn find_hints_suggests_top_tag_from_results() {
let tmp = setup_large_vault();
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args(["find", "--hints", "--format", "text"])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(
stdout.contains("find --tag rust"),
"should suggest --tag rust (most common tag): {stdout}"
);
}
#[test]
fn find_hints_suggests_interesting_status_from_results() {
let tmp = setup_large_vault();
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args(["find", "--hints", "--format", "text"])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(
stdout.contains("status=planned"),
"should suggest status=planned (interesting status): {stdout}"
);
assert!(
!stdout.contains("status=completed"),
"should not suggest completed when planned is available: {stdout}"
);
}
#[test]
fn find_hints_no_hardcoded_draft() {
let tmp = setup_large_vault();
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args(["find", "--hints", "--format", "text"])
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(
!stdout.contains("status=draft"),
"should not suggest hardcoded status=draft: {stdout}"
);
assert!(
!stdout.contains("--tag draft"),
"should not suggest hardcoded tag draft: {stdout}"
);
}
fn setup_array_status_vault() -> TempDir {
let tmp = TempDir::new().unwrap();
for i in 1..=2 {
write_md(
tmp.path(),
&format!("note-{i}.md"),
&format!("---\ntitle: Note {i}\nstatus: completed\ntags:\n - docs\n---\nBody.\n"),
);
}
for (i, extra) in [(3, "experimental"), (4, "legacy"), (5, "wip")] {
write_md(
tmp.path(),
&format!("note-{i}.md"),
&format!(
"---\ntitle: Note {i}\nstatus:\n - deprecated\n - {extra}\ntags:\n - docs\n---\nBody.\n"
),
);
}
write_md(
tmp.path(),
"note-6.md",
"---\ntitle: Note 6\ntags:\n - docs\n---\nBody.\n",
);
tmp
}
#[test]
fn summary_hints_flatten_array_status() {
let tmp = setup_array_status_vault();
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args(["summary", "--hints", "--format", "text"])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(
!stdout.contains("status=["),
"hints should not contain stringified array syntax: {stdout}"
);
}
#[test]
fn find_hints_flatten_array_status() {
let tmp = setup_array_status_vault();
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args(["find", "--hints", "--format", "text"])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(
!stdout.contains("status=["),
"find hints should not contain stringified array syntax: {stdout}"
);
assert!(
stdout.contains("status=deprecated"),
"find hints should suggest status derived from array-valued fields: {stdout}"
);
}
fn assert_hints_present(parsed: &serde_json::Value) {
let hints = parsed["hints"]
.as_array()
.unwrap_or_else(|| panic!("expected 'hints' array in: {parsed}"));
assert!(!hints.is_empty(), "expected at least one hint in: {parsed}");
for hint in hints {
assert!(
hint.get("description").and_then(|d| d.as_str()).is_some(),
"hint missing 'description': {hint}"
);
assert!(
hint.get("cmd").and_then(|c| c.as_str()).is_some(),
"hint missing 'cmd': {hint}"
);
}
}
#[test]
fn read_hints() {
let tmp = TempDir::new().unwrap();
write_md(
tmp.path(),
"note.md",
md!(r"
---
title: Note
---
# Note
Body content here.
"),
);
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args(["read", "--file", "note.md", "--format", "json"])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).unwrap();
let parsed: serde_json::Value =
serde_json::from_str(&stdout).unwrap_or_else(|e| panic!("invalid JSON: {e}\n{stdout}"));
assert!(
parsed.get("results").is_some(),
"expected 'results' key: {parsed}"
);
assert_hints_present(&parsed);
let hints_str = serde_json::to_string(&parsed["hints"]).unwrap();
assert!(
!hints_str.contains("read --file"),
"read hint should use positional form, not --file: {hints_str}"
);
assert!(
!hints_str.contains("backlinks --file"),
"backlinks hint should use positional form, not --file: {hints_str}"
);
}
#[test]
fn backlinks_hints() {
let tmp = TempDir::new().unwrap();
write_md(
tmp.path(),
"target.md",
md!(r"
---
title: Target
---
# Target
"),
);
write_md(
tmp.path(),
"source.md",
md!(r"
---
title: Source
---
# Source
See [[target]].
"),
);
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args(["backlinks", "--file", "target.md", "--format", "json"])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).unwrap();
let parsed: serde_json::Value =
serde_json::from_str(&stdout).unwrap_or_else(|e| panic!("invalid JSON: {e}\n{stdout}"));
assert!(
parsed.get("results").is_some(),
"expected 'results' key: {parsed}"
);
assert_hints_present(&parsed);
let hints_str = serde_json::to_string(&parsed["hints"]).unwrap();
assert!(
!hints_str.contains("read --file"),
"read hint should use positional form, not --file: {hints_str}"
);
assert!(
!hints_str.contains("backlinks --file"),
"backlinks hint should use positional form, not --file: {hints_str}"
);
}
#[test]
fn mv_dry_run_hints() {
let tmp = TempDir::new().unwrap();
write_md(
tmp.path(),
"original.md",
md!(r"
---
title: Original
---
# Original
"),
);
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args([
"mv",
"--file",
"original.md",
"--to",
"renamed.md",
"--dry-run",
"--format",
"json",
])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let parsed: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap_or_else(|e| {
panic!(
"invalid JSON: {e}\n{}",
String::from_utf8_lossy(&output.stdout)
)
});
assert!(
parsed.get("results").is_some(),
"expected 'results' key: {parsed}"
);
let hints = parsed["hints"].as_array().unwrap();
assert!(
!hints.is_empty(),
"expected hints for mv --dry-run: {parsed}"
);
let apply_hint = hints.iter().find(|h| {
h.get("cmd")
.and_then(|c| c.as_str())
.is_some_and(|s| s.contains("mv") && !s.contains("--dry-run"))
});
assert!(
apply_hint.is_some(),
"expected a hint suggesting mv without --dry-run: {parsed}"
);
}
#[test]
fn task_toggle_hints() {
let tmp = TempDir::new().unwrap();
write_md(
tmp.path(),
"tasks.md",
md!(r"
---
title: Tasks
---
# Tasks
- [ ] task one
"),
);
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args([
"task", "toggle", "--file", "tasks.md", "--line", "6", "--format", "json",
])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let parsed: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap_or_else(|e| {
panic!(
"invalid JSON: {e}\n{}",
String::from_utf8_lossy(&output.stdout)
)
});
assert!(
parsed.get("results").is_some(),
"expected 'results' key: {parsed}"
);
assert_hints_present(&parsed);
}
#[test]
fn links_fix_hints() {
let tmp = TempDir::new().unwrap();
write_md(
tmp.path(),
"source.md",
md!(r"
---
title: Source
---
# Source
See [[ActualNote]] for details.
"),
);
write_md(
tmp.path(),
"actual-note.md",
md!(r"
---
title: ActualNote
---
# ActualNote
"),
);
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args(["links", "fix", "--format", "json"])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let parsed: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap_or_else(|e| {
panic!(
"invalid JSON: {e}\n{}",
String::from_utf8_lossy(&output.stdout)
)
});
assert!(
parsed.get("results").is_some(),
"expected 'results' key: {parsed}"
);
assert_hints_present(&parsed);
}
#[test]
fn create_index_hints() {
let tmp = TempDir::new().unwrap();
write_md(
tmp.path(),
"note.md",
md!(r"
---
title: Note
---
# Note
"),
);
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args(["create-index", "--format", "json"])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let parsed: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap_or_else(|e| {
panic!(
"invalid JSON: {e}\n{}",
String::from_utf8_lossy(&output.stdout)
)
});
assert!(
parsed.get("results").is_some(),
"expected 'results' key: {parsed}"
);
let hints = parsed["hints"].as_array().unwrap();
assert!(
!hints.is_empty(),
"expected hints after create-index: {parsed}"
);
let cmds: Vec<&str> = hints
.iter()
.filter_map(|h| h.get("cmd").and_then(|c| c.as_str()))
.collect();
assert!(
cmds.iter().any(|c| c.contains("--index")),
"expected a hint suggesting --index flag: {parsed}"
);
assert!(
cmds.iter().any(|c| c.contains("drop-index")),
"expected a hint suggesting drop-index: {parsed}"
);
}
#[test]
fn drop_index_hints() {
let tmp = TempDir::new().unwrap();
write_md(
tmp.path(),
"note.md",
md!(r"
---
title: Note
---
# Note
"),
);
hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args(["create-index", "--no-hints"])
.output()
.unwrap();
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args(["drop-index", "--format", "json"])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let parsed: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap_or_else(|e| {
panic!(
"invalid JSON: {e}\n{}",
String::from_utf8_lossy(&output.stdout)
)
});
assert!(
parsed.get("results").is_some(),
"expected 'results' key: {parsed}"
);
let hints = parsed["hints"].as_array().unwrap();
assert!(
!hints.is_empty(),
"expected hints after drop-index: {parsed}"
);
let cmds: Vec<&str> = hints
.iter()
.filter_map(|h| h.get("cmd").and_then(|c| c.as_str()))
.collect();
assert!(
cmds.iter().any(|c| c.contains("create-index")),
"expected a hint suggesting create-index: {parsed}"
);
}
#[test]
fn properties_summary_hints_json_envelope() {
let tmp = setup_vault();
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args(["properties", "summary", "--hints", "--format", "json"])
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert!(parsed.get("results").is_some(), "should have 'results' key");
let hints = parsed["hints"].as_array().unwrap();
assert!(!hints.is_empty(), "should have hints");
for hint in hints {
assert!(
hint["cmd"].as_str().unwrap().starts_with("hyalo"),
"hint cmd should start with hyalo: {hint}"
);
}
}
#[test]
fn properties_summary_hints_suggest_top_properties() {
let tmp = setup_vault();
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args(["properties", "summary", "--hints", "--format", "text"])
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(
stdout.contains("find --property title") || stdout.contains("find --property status"),
"should suggest find --property for common properties: {stdout}"
);
}
#[test]
fn tags_summary_hints_json_envelope() {
let tmp = setup_vault();
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args(["tags", "summary", "--hints", "--format", "json"])
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert!(parsed.get("results").is_some(), "should have 'results' key");
let hints = parsed["hints"].as_array().unwrap();
assert!(!hints.is_empty(), "should have hints");
for hint in hints {
assert!(
hint["cmd"].as_str().unwrap().starts_with("hyalo"),
"hint cmd should start with hyalo: {hint}"
);
}
}
#[test]
fn tags_summary_hints_suggest_top_tag_by_count() {
let tmp = setup_vault();
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args(["tags", "summary", "--hints", "--format", "text"])
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(
stdout.contains("find --tag rust"),
"should suggest find --tag for top tag (rust): {stdout}"
);
}
#[test]
fn tags_summary_hints_empty_vault_no_crash() {
let tmp = TempDir::new().unwrap();
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args(["tags", "summary", "--hints", "--format", "json"])
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
if let Some(hints) = parsed.get("hints") {
assert!(
hints.as_array().is_some_and(std::vec::Vec::is_empty),
"empty vault should produce no hints: {parsed}"
);
}
}
#[test]
fn find_broken_links_hints_suggest_links_fix() {
let tmp = TempDir::new().unwrap();
write_md(
tmp.path(),
"source.md",
md!(r"
---
title: Source
---
Link to [[nonexistent-page]].
"),
);
write_md(
tmp.path(),
"other.md",
md!(r"
---
title: Other
---
No broken links here.
"),
);
let output = hyalo()
.args(["--dir", tmp.path().to_str().unwrap()])
.args([
"find",
"--broken-links",
"--fields",
"links",
"--format",
"json",
])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let parsed: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap_or_else(|e| {
panic!(
"invalid JSON: {e}\n{}",
String::from_utf8_lossy(&output.stdout)
)
});
let hints = parsed["hints"]
.as_array()
.unwrap_or_else(|| panic!("expected 'hints' array: {parsed}"));
assert!(
hints
.iter()
.any(|h| h["cmd"].as_str().is_some_and(|c| c.contains("links fix"))),
"find --broken-links should hint at 'links fix': {hints:?}"
);
}
fn setup_vault_with_schema() -> TempDir {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join(".hyalo.toml"),
r#"
[schema.default]
required = ["title"]
[schema.default.properties.title]
type = "string"
[schema.default.properties.status]
type = "enum"
values = ["planned", "in-progress", "completed"]
[schema.types.note]
required = ["title", "tags"]
[schema.types.note.properties.tags]
type = "list"
"#,
)
.unwrap();
write_md(
tmp.path(),
"good.md",
md!(r"
---
title: Good File
status: planned
type: note
tags: [test]
---
# Good
"),
);
write_md(
tmp.path(),
"bad.md",
md!(r"
---
status: invalid-status
type: note
---
# Bad File
"),
);
tmp
}
#[test]
fn lint_hints_json_has_hints_envelope() {
let tmp = setup_vault_with_schema();
let output = hyalo()
.current_dir(tmp.path())
.args(["lint", "--hints", "--format", "json"])
.output()
.unwrap();
let stdout = String::from_utf8(output.stdout).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert!(parsed.get("results").is_some(), "missing 'results' key");
assert!(parsed.get("hints").is_some(), "missing 'hints' key");
let hints = parsed["hints"].as_array().unwrap();
assert!(
hints.len() >= 2,
"lint should produce at least 2 hints, got {}: {hints:?}",
hints.len()
);
for hint in hints {
assert!(
hint["cmd"].as_str().unwrap().starts_with("hyalo"),
"hint cmd should start with hyalo: {hint}"
);
}
}
#[test]
fn lint_hints_text_has_arrow_lines() {
let tmp = setup_vault_with_schema();
let output = hyalo()
.current_dir(tmp.path())
.args(["lint", "--hints", "--format", "text"])
.output()
.unwrap();
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(
stdout.contains(" -> hyalo"),
"should have hint lines with arrow prefix: {stdout}"
);
}
#[test]
fn lint_hints_suggest_fix() {
let tmp = setup_vault_with_schema();
let output = hyalo()
.current_dir(tmp.path())
.args(["lint", "--hints", "--format", "text"])
.output()
.unwrap();
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(
stdout.contains("lint --fix"),
"should suggest lint --fix when there are violations: {stdout}"
);
}
#[test]
fn types_list_hints_json_has_hints_envelope() {
let tmp = setup_vault_with_schema();
let output = hyalo()
.current_dir(tmp.path())
.args(["types", "list", "--hints", "--format", "json"])
.output()
.unwrap();
let stdout = String::from_utf8(output.stdout).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert!(parsed.get("results").is_some(), "missing 'results' key");
assert!(parsed.get("hints").is_some(), "missing 'hints' key");
let hints = parsed["hints"].as_array().unwrap();
assert!(
hints.len() >= 2,
"types list should produce at least 2 hints, got {}: {hints:?}",
hints.len()
);
}
#[test]
fn types_list_hints_text_suggests_show() {
let tmp = setup_vault_with_schema();
let output = hyalo()
.current_dir(tmp.path())
.args(["types", "list", "--hints", "--format", "text"])
.output()
.unwrap();
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(
stdout.contains("types show"),
"should suggest types show: {stdout}"
);
assert!(stdout.contains("lint"), "should suggest lint: {stdout}");
}
#[test]
fn types_show_hints_text_suggests_lint() {
let tmp = setup_vault_with_schema();
let output = hyalo()
.current_dir(tmp.path())
.args(["types", "show", "note", "--hints", "--format", "text"])
.output()
.unwrap();
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(
stdout.contains("lint"),
"types show should suggest lint: {stdout}"
);
}
#[test]
fn summary_hints_mention_lint_when_schema_defined() {
let tmp = setup_vault_with_schema();
let output = hyalo()
.current_dir(tmp.path())
.args(["summary", "--hints", "--format", "text"])
.output()
.unwrap();
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(
stdout.contains("lint") || stdout.contains("Lint"),
"summary should mention lint when schema is defined: {stdout}"
);
}
#[test]
fn summary_hints_include_lint_when_violations_not_pushed_out() {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join(".hyalo.toml"),
r#"dir = "."
[schema.default]
required = ["title"]
"#,
)
.unwrap();
for i in 0..3 {
write_md(
tmp.path(),
&format!("orphan{i}.md"),
&format!(
"---\ntitle: Orphan {i}\nstatus: in-progress\ntags:\n - x\n---\n- [ ] Task\n"
),
);
}
write_md(tmp.path(), "bad.md", "---\nfoo: bar\n---\nBody\n");
let output = hyalo()
.current_dir(tmp.path())
.args(["summary", "--hints", "--format", "json"])
.output()
.unwrap();
let stdout = String::from_utf8(output.stdout).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let hints = parsed["hints"].as_array().expect("hints should be array");
let has_lint_hint = hints
.iter()
.any(|h| h["cmd"].as_str().unwrap_or("").contains("lint"));
assert!(
has_lint_hint,
"summary hints should include 'lint' when there are schema violations, \
even when many other hints compete for slots: {hints:#?}"
);
}
#[test]
fn lint_fix_dry_run_hints_suggest_apply_without_dry_run() {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join(".hyalo.toml"),
r#"dir = "."
[schema.default]
required = ["title", "status"]
[schema.default.defaults]
status = "draft"
"#,
)
.unwrap();
write_md(tmp.path(), "fixable.md", "---\ntitle: My Note\n---\nBody\n");
let output = hyalo()
.current_dir(tmp.path())
.args(["lint", "--fix", "--dry-run", "--hints", "--format", "json"])
.output()
.unwrap();
let stdout = String::from_utf8(output.stdout).unwrap();
let parsed: serde_json::Value =
serde_json::from_str(&stdout).unwrap_or_else(|e| panic!("JSON parse: {e}\n{stdout}"));
let results = &parsed["results"];
assert_eq!(
results["dry_run"], true,
"expected dry_run=true in results: {results}"
);
assert!(
results["fixes"].as_array().is_some_and(|a| !a.is_empty()),
"expected non-empty fixes in dry-run output: {results}"
);
let hints = parsed["hints"].as_array().expect("hints should be array");
let apply_hint = hints.iter().find(|h| {
let cmd = h["cmd"].as_str().unwrap_or("");
cmd.contains("lint --fix") && !cmd.contains("--dry-run")
});
assert!(
apply_hint.is_some(),
"lint --fix --dry-run should hint `lint --fix` (without --dry-run) to apply fixes: {hints:#?}"
);
}