use super::common::{hyalo_no_hints, md, write_md};
use std::fs;
use tempfile::TempDir;
fn set_json(
tmp: &TempDir,
extra_args: &[&str],
) -> (std::process::ExitStatus, serde_json::Value, String) {
let mut cmd = hyalo_no_hints();
cmd.args(["--dir", tmp.path().to_str().unwrap()]);
cmd.arg("set");
cmd.args(extra_args);
let output = cmd.output().unwrap();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let json: serde_json::Value = if output.status.success() {
let envelope: serde_json::Value =
serde_json::from_slice(&output.stdout).unwrap_or_else(|e| {
let stdout = String::from_utf8_lossy(&output.stdout);
panic!("invalid JSON: {e}\nstdout: {stdout}\nstderr: {stderr}")
});
envelope["results"].clone()
} else {
serde_json::Value::Null
};
(output.status, json, stderr)
}
#[test]
fn set_property_creates_new() {
let tmp = TempDir::new().unwrap();
write_md(
tmp.path(),
"note.md",
md!(r"
---
title: Note
---
"),
);
let (status, json, stderr) =
set_json(&tmp, &["--property", "status=done", "--file", "note.md"]);
assert!(status.success(), "stderr: {stderr}");
assert_eq!(json["property"], "status");
assert_eq!(json["value"], "done");
assert_eq!(json["modified"].as_array().unwrap().len(), 1);
assert_eq!(json["skipped"].as_array().unwrap().len(), 0);
let content = fs::read_to_string(tmp.path().join("note.md")).unwrap();
assert!(content.contains("status: done"), "content:\n{content}");
}
#[test]
fn set_property_overwrites_existing() {
let tmp = TempDir::new().unwrap();
write_md(
tmp.path(),
"note.md",
md!(r"
---
status: draft
---
"),
);
let (status, json, stderr) = set_json(
&tmp,
&["--property", "status=published", "--file", "note.md"],
);
assert!(status.success(), "stderr: {stderr}");
assert_eq!(json["modified"].as_array().unwrap().len(), 1);
let content = fs::read_to_string(tmp.path().join("note.md")).unwrap();
assert!(content.contains("status: published"), "content:\n{content}");
assert!(
!content.contains("draft"),
"old value still present:\n{content}"
);
}
#[test]
fn set_property_skips_when_identical() {
let tmp = TempDir::new().unwrap();
write_md(
tmp.path(),
"note.md",
md!(r"
---
status: done
---
"),
);
let (status, json, stderr) =
set_json(&tmp, &["--property", "status=done", "--file", "note.md"]);
assert!(status.success(), "stderr: {stderr}");
assert_eq!(json["modified"].as_array().unwrap().len(), 0);
assert_eq!(json["skipped"].as_array().unwrap().len(), 1);
assert_eq!(
json["skipped"].as_array().unwrap()[0].as_str().unwrap(),
"note.md"
);
}
#[test]
fn set_tag_adds_new_tag() {
let tmp = TempDir::new().unwrap();
write_md(
tmp.path(),
"note.md",
md!(r"
---
title: Note
---
"),
);
let (status, json, stderr) = set_json(&tmp, &["--tag", "rust", "--file", "note.md"]);
assert!(status.success(), "stderr: {stderr}");
assert_eq!(json["tag"], "rust");
assert_eq!(json["modified"].as_array().unwrap().len(), 1);
assert_eq!(json["skipped"].as_array().unwrap().len(), 0);
let content = fs::read_to_string(tmp.path().join("note.md")).unwrap();
assert!(content.contains("rust"), "tag not written:\n{content}");
}
#[test]
fn set_tag_idempotent_when_already_present() {
let tmp = TempDir::new().unwrap();
write_md(
tmp.path(),
"note.md",
md!(r"
---
tags:
- rust
---
"),
);
let (status, json, stderr) = set_json(&tmp, &["--tag", "rust", "--file", "note.md"]);
assert!(status.success(), "stderr: {stderr}");
assert_eq!(json["modified"].as_array().unwrap().len(), 0);
assert_eq!(json["skipped"].as_array().unwrap().len(), 1);
}
#[test]
fn set_multiple_mutations_returns_array() {
let tmp = TempDir::new().unwrap();
write_md(
tmp.path(),
"note.md",
md!(r"
---
title: Note
---
"),
);
let (status, json, stderr) = set_json(
&tmp,
&[
"--property",
"status=done",
"--tag",
"rust",
"--file",
"note.md",
],
);
assert!(status.success(), "stderr: {stderr}");
assert!(
json.is_array(),
"expected array for multiple mutations: {json}"
);
assert_eq!(json.as_array().unwrap().len(), 2);
}
#[test]
fn set_requires_file_or_glob() {
let tmp = TempDir::new().unwrap();
let mut cmd = hyalo_no_hints();
cmd.args(["--dir", tmp.path().to_str().unwrap()]);
cmd.args(["set", "--property", "status=done"]);
let output = cmd.output().unwrap();
assert!(!output.status.success());
assert_eq!(output.status.code(), Some(1));
}
#[test]
fn set_requires_at_least_one_mutation() {
let tmp = TempDir::new().unwrap();
write_md(tmp.path(), "note.md", "---\ntitle: x\n---\n");
let mut cmd = hyalo_no_hints();
cmd.args(["--dir", tmp.path().to_str().unwrap()]);
cmd.args(["set", "--file", "note.md"]);
let output = cmd.output().unwrap();
assert!(!output.status.success());
assert_eq!(output.status.code(), Some(1));
}
#[test]
fn set_empty_property_name_returns_error() {
let tmp = TempDir::new().unwrap();
write_md(tmp.path(), "note.md", "---\ntitle: x\n---\n");
let mut cmd = hyalo_no_hints();
cmd.args(["--dir", tmp.path().to_str().unwrap()]);
cmd.args(["set", "--property", "=value", "--file", "note.md"]);
let output = cmd.output().unwrap();
assert!(!output.status.success());
assert_eq!(output.status.code(), Some(1));
}
#[test]
fn set_invalid_kv_no_equals_returns_error() {
let tmp = TempDir::new().unwrap();
write_md(tmp.path(), "note.md", "---\ntitle: x\n---\n");
let mut cmd = hyalo_no_hints();
cmd.args(["--dir", tmp.path().to_str().unwrap()]);
cmd.args(["set", "--property", "no-equals-sign", "--file", "note.md"]);
let output = cmd.output().unwrap();
assert!(!output.status.success());
assert_eq!(output.status.code(), Some(1));
}
#[test]
fn set_preserves_file_body() {
let tmp = TempDir::new().unwrap();
let body = "# Heading\n\nSome body content here.\n";
write_md(
tmp.path(),
"note.md",
&format!("---\ntitle: Note\n---\n{body}"),
);
let (status, _json, stderr) =
set_json(&tmp, &["--property", "status=done", "--file", "note.md"]);
assert!(status.success(), "stderr: {stderr}");
let content = fs::read_to_string(tmp.path().join("note.md")).unwrap();
assert!(content.contains(body), "body was corrupted:\n{content}");
}
#[test]
fn set_with_glob_modifies_multiple_files() {
let tmp = TempDir::new().unwrap();
write_md(
tmp.path(),
"a.md",
md!(r"
---
title: A
---
"),
);
write_md(
tmp.path(),
"b.md",
md!(r"
---
title: B
---
"),
);
let (status, json, stderr) = set_json(&tmp, &["--property", "status=done", "--glob", "*.md"]);
assert!(status.success(), "stderr: {stderr}");
assert_eq!(json["total"], 2, "expected total=2: {json}");
assert_eq!(json["modified"].as_array().unwrap().len(), 2);
let a = fs::read_to_string(tmp.path().join("a.md")).unwrap();
let b = fs::read_to_string(tmp.path().join("b.md")).unwrap();
assert!(a.contains("status: done"), "a.md:\n{a}");
assert!(b.contains("status: done"), "b.md:\n{b}");
}
#[test]
fn set_format_text_produces_output() {
let tmp = TempDir::new().unwrap();
write_md(
tmp.path(),
"note.md",
md!(r"
---
title: Note
---
"),
);
let output = hyalo_no_hints()
.args(["--dir", tmp.path().to_str().unwrap()])
.args(["--format", "text"])
.args(["set", "--property", "status=done", "--file", "note.md"])
.output()
.unwrap();
assert!(
output.status.success(),
"stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(!stdout.trim().is_empty(), "expected non-empty text output");
}
#[test]
fn set_where_property_scalar_match() {
let tmp = TempDir::new().unwrap();
write_md(
tmp.path(),
"done.md",
md!(r"
---
title: Done
status: done
---
"),
);
write_md(
tmp.path(),
"active.md",
md!(r"
---
title: Active
status: active
---
"),
);
let (status, json, stderr) = set_json(
&tmp,
&[
"--property",
"status=completed",
"--where-property",
"status=done",
"--glob",
"**/*.md",
],
);
assert!(status.success(), "stderr: {stderr}");
assert_eq!(
json["modified"].as_array().unwrap().len(),
1,
"expected 1 modified: {json}"
);
assert_eq!(
json["skipped"].as_array().unwrap().len(),
0,
"expected 0 skipped: {json}"
);
let done_content = fs::read_to_string(tmp.path().join("done.md")).unwrap();
assert!(
done_content.contains("status: completed"),
"done.md should be updated:\n{done_content}"
);
let active_content = fs::read_to_string(tmp.path().join("active.md")).unwrap();
assert!(
active_content.contains("status: active"),
"active.md should be untouched:\n{active_content}"
);
assert!(
!active_content.contains("completed"),
"active.md should not have been modified:\n{active_content}"
);
}
#[test]
fn set_where_property_list_match() {
let tmp = TempDir::new().unwrap();
write_md(
tmp.path(),
"note.md",
md!(r"
---
title: Note
tags:
- cli
- rust
---
"),
);
let (status, json, stderr) = set_json(
&tmp,
&[
"--property",
"reviewed=true",
"--where-property",
"tags=cli",
"--glob",
"**/*.md",
],
);
assert!(status.success(), "stderr: {stderr}");
assert_eq!(
json["modified"].as_array().unwrap().len(),
1,
"expected list element match to produce 1 modified: {json}"
);
let content = fs::read_to_string(tmp.path().join("note.md")).unwrap();
assert!(
content.contains("reviewed: true"),
"file should have been mutated:\n{content}"
);
}
#[test]
fn set_where_tag_nested_match() {
let tmp = TempDir::new().unwrap();
write_md(
tmp.path(),
"backend.md",
md!(r"
---
title: Backend
tags:
- project/backend
---
"),
);
write_md(
tmp.path(),
"research.md",
md!(r"
---
title: Research
tags:
- research
---
"),
);
let (status, json, stderr) = set_json(
&tmp,
&[
"--property",
"checked=true",
"--where-tag",
"project",
"--glob",
"**/*.md",
],
);
assert!(status.success(), "stderr: {stderr}");
assert_eq!(
json["modified"].as_array().unwrap().len(),
1,
"expected only the nested-tag file to be modified: {json}"
);
let backend_content = fs::read_to_string(tmp.path().join("backend.md")).unwrap();
assert!(
backend_content.contains("checked: true"),
"backend.md should be modified:\n{backend_content}"
);
let research_content = fs::read_to_string(tmp.path().join("research.md")).unwrap();
assert!(
!research_content.contains("checked"),
"research.md should be untouched:\n{research_content}"
);
}
#[test]
fn set_where_combined_and() {
let tmp = TempDir::new().unwrap();
write_md(
tmp.path(),
"both.md",
md!(r"
---
title: Both
status: active
tags:
- rust
---
"),
);
write_md(
tmp.path(),
"prop_only.md",
md!(r"
---
title: PropOnly
status: active
tags:
- python
---
"),
);
write_md(
tmp.path(),
"tag_only.md",
md!(r"
---
title: TagOnly
status: draft
tags:
- rust
---
"),
);
let (status, json, stderr) = set_json(
&tmp,
&[
"--property",
"processed=true",
"--where-property",
"status=active",
"--where-tag",
"rust",
"--glob",
"**/*.md",
],
);
assert!(status.success(), "stderr: {stderr}");
assert_eq!(
json["modified"].as_array().unwrap().len(),
1,
"expected only 1 file matching both filters: {json}"
);
let both_content = fs::read_to_string(tmp.path().join("both.md")).unwrap();
assert!(
both_content.contains("processed: true"),
"both.md should be modified:\n{both_content}"
);
let prop_only = fs::read_to_string(tmp.path().join("prop_only.md")).unwrap();
assert!(
!prop_only.contains("processed"),
"prop_only.md should be untouched:\n{prop_only}"
);
let tag_only = fs::read_to_string(tmp.path().join("tag_only.md")).unwrap();
assert!(
!tag_only.contains("processed"),
"tag_only.md should be untouched:\n{tag_only}"
);
}
#[test]
fn set_where_no_matches() {
let tmp = TempDir::new().unwrap();
write_md(
tmp.path(),
"note.md",
md!(r"
---
title: Note
status: active
---
"),
);
let (status, json, stderr) = set_json(
&tmp,
&[
"--property",
"status=done",
"--where-property",
"status=nonexistent",
"--glob",
"**/*.md",
],
);
assert!(status.success(), "stderr: {stderr}");
assert_eq!(
json["modified"].as_array().unwrap().len(),
0,
"expected 0 modified when no files match filter: {json}"
);
let content = fs::read_to_string(tmp.path().join("note.md")).unwrap();
assert!(
content.contains("status: active"),
"note.md should be untouched:\n{content}"
);
}
#[test]
fn set_where_property_operator() {
let tmp = TempDir::new().unwrap();
write_md(
tmp.path(),
"low.md",
md!(r"
---
title: Low
priority: 2
---
"),
);
write_md(
tmp.path(),
"high.md",
md!(r"
---
title: High
priority: 5
---
"),
);
let (status, json, stderr) = set_json(
&tmp,
&[
"--property",
"urgent=true",
"--where-property",
"priority>=3",
"--glob",
"**/*.md",
],
);
assert!(status.success(), "stderr: {stderr}");
assert_eq!(
json["modified"].as_array().unwrap().len(),
1,
"expected only high-priority file to be modified: {json}"
);
let high_content = fs::read_to_string(tmp.path().join("high.md")).unwrap();
assert!(
high_content.contains("urgent: true"),
"high.md should be modified:\n{high_content}"
);
let low_content = fs::read_to_string(tmp.path().join("low.md")).unwrap();
assert!(
!low_content.contains("urgent"),
"low.md should be untouched:\n{low_content}"
);
}
#[test]
fn set_property_infers_number_type() {
let tmp = TempDir::new().unwrap();
write_md(
tmp.path(),
"note.md",
md!(r"
---
title: Note
---
"),
);
let (status, _json, stderr) =
set_json(&tmp, &["--property", "priority=42", "--file", "note.md"]);
assert!(status.success(), "stderr: {stderr}");
let content = fs::read_to_string(tmp.path().join("note.md")).unwrap();
assert!(
content.contains("priority: 42"),
"expected unquoted number in YAML:\n{content}"
);
}
#[test]
fn set_property_infers_boolean_type() {
let tmp = TempDir::new().unwrap();
write_md(
tmp.path(),
"note.md",
md!(r"
---
title: Note
---
"),
);
let (status, _json, stderr) =
set_json(&tmp, &["--property", "draft=true", "--file", "note.md"]);
assert!(status.success(), "stderr: {stderr}");
let content = fs::read_to_string(tmp.path().join("note.md")).unwrap();
assert!(
content.contains("draft: true"),
"expected unquoted boolean in YAML:\n{content}"
);
}
#[test]
fn set_property_infers_text_type() {
let tmp = TempDir::new().unwrap();
write_md(
tmp.path(),
"note.md",
md!(r"
---
title: Note
---
"),
);
let (status, _json, stderr) = set_json(
&tmp,
&["--property", "status=in-progress", "--file", "note.md"],
);
assert!(status.success(), "stderr: {stderr}");
let content = fs::read_to_string(tmp.path().join("note.md")).unwrap();
assert!(
content.contains("status: in-progress"),
"expected text value in YAML:\n{content}"
);
}
#[test]
fn set_list_property_creates_yaml_sequence() {
let tmp = TempDir::new().unwrap();
write_md(
tmp.path(),
"note.md",
md!(r"
---
title: Note
---
"),
);
let (status, json, _) = set_json(
&tmp,
&[
"--property",
"keywords=[rust, cli, tools]",
"--file",
"note.md",
],
);
assert!(status.success());
assert_eq!(json["modified"].as_array().unwrap().len(), 1);
let content = fs::read_to_string(tmp.path().join("note.md")).unwrap();
assert!(
content.contains("- rust"),
"expected YAML list item, got:\n{content}"
);
assert!(content.contains("- cli"));
assert!(content.contains("- tools"));
}
#[test]
fn set_empty_list_property() {
let tmp = TempDir::new().unwrap();
write_md(tmp.path(), "note.md", "---\ntitle: Note\n---\n");
let (status, _, _) = set_json(&tmp, &["--property", "keywords=[]", "--file", "note.md"]);
assert!(status.success());
let content = fs::read_to_string(tmp.path().join("note.md")).unwrap();
assert!(content.contains("keywords: []"));
}
#[test]
fn set_multi_file_modifies_all() {
let tmp = TempDir::new().unwrap();
write_md(
tmp.path(),
"a.md",
md!(r"
---
title: A
---
"),
);
write_md(
tmp.path(),
"b.md",
md!(r"
---
title: B
---
"),
);
let (status, json, _) = set_json(
&tmp,
&[
"--property",
"status=done",
"--file",
"a.md",
"--file",
"b.md",
],
);
assert!(status.success());
assert_eq!(json["modified"].as_array().unwrap().len(), 2);
let a = fs::read_to_string(tmp.path().join("a.md")).unwrap();
let b = fs::read_to_string(tmp.path().join("b.md")).unwrap();
assert!(a.contains("status: done"));
assert!(b.contains("status: done"));
}
#[test]
fn set_multi_file_partial_failure() {
let tmp = TempDir::new().unwrap();
write_md(
tmp.path(),
"exists.md",
md!(r"
---
title: Exists
---
"),
);
let (status, json, stderr) = set_json(
&tmp,
&[
"--property",
"status=done",
"--file",
"exists.md",
"--file",
"missing.md",
],
);
assert!(status.success(), "should succeed with partial failure");
assert!(stderr.contains("warning"), "should warn about missing file");
assert_eq!(json["modified"].as_array().unwrap().len(), 1);
}
#[test]
fn set_skips_malformed_yaml_file() {
let tmp = TempDir::new().unwrap();
write_md(
tmp.path(),
"good.md",
md!(r"
---
title: Good
---
# Good
"),
);
write_md(
tmp.path(),
"bad.md",
"---\n: invalid yaml [[[{\n---\n# Bad\n",
);
let output = hyalo_no_hints()
.args(["--dir", tmp.path().to_str().unwrap()])
.args(["set", "--property", "status=done", "--glob", "*.md"])
.output()
.unwrap();
assert!(
output.status.success(),
"expected success; stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let envelope: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
let json = &envelope["results"];
assert_eq!(
json["modified"].as_array().unwrap().len(),
1,
"only one file should be modified; json: {json}"
);
assert_eq!(json["modified"][0], "good.md");
let content = fs::read_to_string(tmp.path().join("good.md")).unwrap();
assert!(content.contains("status: done"), "content:\n{content}");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("warning: skipping"),
"expected warning on stderr; got: {stderr}"
);
assert!(
stderr.contains("bad.md"),
"warning should name the bad file; got: {stderr}"
);
}
#[test]
fn set_rejects_gte_filter_in_property() {
let tmp = TempDir::new().unwrap();
write_md(tmp.path(), "note.md", "---\ntitle: x\n---\n");
let (status, _, stderr) = set_json(&tmp, &["--property", "priority>=3", "--file", "note.md"]);
assert!(!status.success(), "should fail; stderr: {stderr}");
assert!(
stderr.contains("--where-property"),
"hint missing; stderr: {stderr}"
);
}
#[test]
fn set_rejects_lte_filter_in_property() {
let tmp = TempDir::new().unwrap();
write_md(tmp.path(), "note.md", "---\ntitle: x\n---\n");
let (status, _, stderr) = set_json(&tmp, &["--property", "priority<=3", "--file", "note.md"]);
assert!(!status.success(), "should fail; stderr: {stderr}");
assert!(
stderr.contains("--where-property"),
"hint missing; stderr: {stderr}"
);
}
#[test]
fn set_rejects_neq_filter_in_property() {
let tmp = TempDir::new().unwrap();
write_md(tmp.path(), "note.md", "---\ntitle: x\n---\n");
let (status, _, stderr) = set_json(&tmp, &["--property", "status!=draft", "--file", "note.md"]);
assert!(!status.success(), "should fail; stderr: {stderr}");
assert!(
stderr.contains("--where-property"),
"hint missing; stderr: {stderr}"
);
}
#[test]
fn set_rejects_regex_filter_in_property() {
let tmp = TempDir::new().unwrap();
write_md(tmp.path(), "note.md", "---\ntitle: x\n---\n");
let (status, _, stderr) = set_json(&tmp, &["--property", "name~=pattern", "--file", "note.md"]);
assert!(!status.success(), "should fail; stderr: {stderr}");
assert!(
stderr.contains("--where-property"),
"hint missing; stderr: {stderr}"
);
}
#[test]
fn set_accepts_plain_kv_property() {
let tmp = TempDir::new().unwrap();
write_md(tmp.path(), "note.md", "---\ntitle: x\n---\n");
let (status, json, stderr) =
set_json(&tmp, &["--property", "status=done", "--file", "note.md"]);
assert!(status.success(), "stderr: {stderr}");
assert_eq!(json["property"], "status");
}
#[test]
fn set_accepts_list_property() {
let tmp = TempDir::new().unwrap();
write_md(tmp.path(), "note.md", "---\ntitle: x\n---\n");
let (status, json, stderr) =
set_json(&tmp, &["--property", "tags=[a, b, c]", "--file", "note.md"]);
assert!(status.success(), "stderr: {stderr}");
assert_eq!(json["property"], "tags");
}
#[test]
fn set_dry_run_does_not_modify() {
let tmp = TempDir::new().unwrap();
write_md(
tmp.path(),
"note.md",
md!(r"
---
title: Note
---
"),
);
let (status, json, stderr) = set_json(
&tmp,
&[
"--property",
"status=done",
"--file",
"note.md",
"--dry-run",
],
);
assert!(status.success(), "stderr: {stderr}");
assert_eq!(json["dry_run"], true);
assert_eq!(json["modified"].as_array().unwrap().len(), 1);
let content = fs::read_to_string(tmp.path().join("note.md")).unwrap();
assert!(
!content.contains("status"),
"file was modified despite --dry-run:\n{content}"
);
}
#[test]
fn set_dry_run_tag_does_not_modify() {
let tmp = TempDir::new().unwrap();
write_md(tmp.path(), "note.md", "---\ntitle: Note\n---\n");
let (status, json, stderr) =
set_json(&tmp, &["--tag", "rust", "--file", "note.md", "--dry-run"]);
assert!(status.success(), "stderr: {stderr}");
assert_eq!(json["dry_run"], true);
assert_eq!(json["modified"].as_array().unwrap().len(), 1);
let content = fs::read_to_string(tmp.path().join("note.md")).unwrap();
assert!(
!content.contains("rust"),
"file was modified despite --dry-run:\n{content}"
);
}
#[test]
fn set_without_dry_run_has_dry_run_false() {
let tmp = TempDir::new().unwrap();
write_md(tmp.path(), "note.md", "---\ntitle: x\n---\n");
let (status, json, stderr) =
set_json(&tmp, &["--property", "status=done", "--file", "note.md"]);
assert!(status.success(), "stderr: {stderr}");
assert_eq!(json["dry_run"], false);
let content = fs::read_to_string(tmp.path().join("note.md")).unwrap();
assert!(
content.contains("status: done"),
"file was not written:\n{content}"
);
}