#![allow(clippy::missing_errors_doc)]
use anyhow::Result;
use serde::Serialize;
use serde_json::Value;
use std::path::Path;
use crate::commands::{FilesOrOutcome, collect_files, mutation, require_file_or_glob};
use crate::output::{CommandOutcome, Format};
use hyalo_core::filter::{self, PropertyFilter};
use hyalo_core::frontmatter;
use hyalo_core::index::SnapshotIndex;
#[derive(Debug, Serialize)]
pub(crate) struct SetPropertyResult {
pub(crate) property: String,
pub(crate) value: String,
pub(crate) modified: Vec<String>,
pub(crate) skipped: Vec<String>,
pub(crate) total: usize,
pub(crate) scanned: usize,
pub(crate) dry_run: bool,
}
#[derive(Debug, Serialize)]
pub(crate) struct SetTagResult {
pub(crate) tag: String,
pub(crate) modified: Vec<String>,
pub(crate) skipped: Vec<String>,
pub(crate) total: usize,
pub(crate) scanned: usize,
pub(crate) dry_run: bool,
}
pub fn parse_kv(s: &str) -> Result<(&str, &str), String> {
match s.find('=') {
Some(pos) => {
let key = &s[..pos];
if key.trim().is_empty() {
return Err(format!(
"invalid property argument '{s}': property name cannot be empty"
));
}
Ok((key, &s[pos + 1..]))
}
None => Err(format!(
"invalid property argument '{s}': expected K=V format (e.g. status=completed)"
)),
}
}
fn add_tag_in_memory(props: &mut indexmap::IndexMap<String, Value>, tag: &str) -> Result<bool> {
const KEY: &str = "tags";
match props.get(KEY) {
None | Some(Value::Null | Value::String(_) | Value::Array(_)) => {}
Some(existing) => {
let kind = match existing {
Value::Bool(_) => "boolean",
Value::Number(_) => "number",
Value::Object(_) => "mapping",
_ => "unknown",
};
anyhow::bail!(
"property 'tags' is a {kind} value, not a list — \
use `set --property` to overwrite it explicitly"
);
}
}
if let Some(Value::Array(seq)) = props.get_mut(KEY) {
let already = seq.iter().any(|v| match v {
Value::String(s) => s.eq_ignore_ascii_case(tag),
Value::Number(n) => n.to_string().eq_ignore_ascii_case(tag),
Value::Bool(b) => b.to_string().eq_ignore_ascii_case(tag),
_ => false,
});
if already {
return Ok(false);
}
seq.push(Value::String(tag.to_owned()));
Ok(true)
} else {
let existing_str = match props.get(KEY) {
Some(Value::String(s)) if !s.is_empty() => Some(s.clone()),
_ => None,
};
if let Some(ref s) = existing_str
&& s.eq_ignore_ascii_case(tag)
{
return Ok(false);
}
let mut list: Vec<Value> = existing_str.map(Value::String).into_iter().collect();
list.push(Value::String(tag.to_owned()));
props.insert(KEY.to_owned(), Value::Array(list));
Ok(true)
}
}
#[allow(clippy::too_many_arguments)]
pub fn set(
dir: &Path,
property_args: &[String],
tag_args: &[String],
files: &[String],
globs: &[String],
where_property_filters: &[PropertyFilter],
where_tag_filters: &[String],
format: Format,
snapshot_index: &mut Option<SnapshotIndex>,
index_path: Option<&Path>,
dry_run: bool,
) -> Result<CommandOutcome> {
if property_args.is_empty() && tag_args.is_empty() {
let out = crate::output::format_error(
format,
"set requires at least one --property K=V or --tag T",
None,
Some("example: hyalo set --property status=completed --file note.md"),
None,
);
return Ok(CommandOutcome::UserError(out));
}
if let Some(outcome) = require_file_or_glob(files, globs, "set", format) {
return Ok(outcome);
}
for arg in property_args {
match parse_kv(arg) {
Err(msg) => {
let out = crate::output::format_error(format, &msg, None, None, None);
return Ok(CommandOutcome::UserError(out));
}
Ok((key, _)) => {
if let Some(outcome) = super::reject_filter_in_mutation_property(key, format) {
return Ok(outcome);
}
}
}
}
for tag in tag_args {
if let Err(msg) = crate::commands::tags::validate_tag(tag) {
let out = crate::output::format_error(
format,
&msg,
None,
Some(
"tag names may contain letters, digits, _, -, / and must have at least one non-numeric character",
),
None,
);
return Ok(CommandOutcome::UserError(out));
}
}
let parsed_props: Vec<(&str, &str, Value)> = {
let mut v = Vec::with_capacity(property_args.len());
for arg in property_args {
let (name, raw_value) =
parse_kv(arg).map_err(|e| anyhow::anyhow!("invalid property argument: {e}"))?;
let value = match frontmatter::parse_value(raw_value, None) {
Ok(val) => val,
Err(e) => {
let out = crate::output::format_error(
format,
&format!("failed to parse value for property '{name}': {e}"),
None,
None,
None,
);
return Ok(CommandOutcome::UserError(out));
}
};
v.push((name, raw_value, value));
}
v
};
let files = collect_files(dir, files, globs, format)?;
let files = match files {
FilesOrOutcome::Files(f) => f,
FilesOrOutcome::Outcome(o) => return Ok(o),
};
let scanned = files.len();
let mut prop_results: Vec<(Vec<String>, Vec<String>)> =
vec![(Vec::new(), Vec::new()); parsed_props.len()];
let mut tag_results: Vec<(Vec<String>, Vec<String>)> =
vec![(Vec::new(), Vec::new()); tag_args.len()];
let mut index_dirty = false;
for (full_path, rel_path) in &files {
let mut props = match frontmatter::read_frontmatter(full_path) {
Ok(p) => p,
Err(e) if frontmatter::is_parse_error(&e) => {
crate::warn::warn(format!("skipping {rel_path}: {e}"));
continue;
}
Err(e) => return Err(e),
};
if !filter::matches_frontmatter_filters(&props, where_property_filters, where_tag_filters) {
continue;
}
let mut file_changed = false;
for (i, (name, _, value)) in parsed_props.iter().enumerate() {
let already_same = props.get(*name) == Some(value);
if already_same {
prop_results[i].1.push(rel_path.clone()); } else {
props.insert((*name).to_owned(), value.clone());
prop_results[i].0.push(rel_path.clone()); file_changed = true;
}
}
for (i, tag) in tag_args.iter().enumerate() {
match add_tag_in_memory(&mut props, tag) {
Ok(true) => {
tag_results[i].0.push(rel_path.clone()); file_changed = true;
}
Ok(false) => {
tag_results[i].1.push(rel_path.clone()); }
Err(e) => return Err(e),
}
}
if file_changed && !dry_run {
frontmatter::write_frontmatter(full_path, &props)?;
mutation::update_index_entry(
snapshot_index,
rel_path,
props,
full_path,
&mut index_dirty,
)?;
}
}
if !dry_run {
mutation::save_index_if_dirty(snapshot_index, index_path, index_dirty)?;
}
let mut results: Vec<serde_json::Value> = Vec::new();
for ((name, raw_value, _), (modified, skipped)) in
parsed_props.iter().zip(prop_results.into_iter())
{
let total = modified.len() + skipped.len();
let result = SetPropertyResult {
property: (*name).to_owned(),
value: (*raw_value).to_owned(),
modified,
skipped,
total,
scanned,
dry_run,
};
results
.push(serde_json::to_value(&result).expect("derived Serialize impl should not fail"));
}
for (tag, (modified, skipped)) in tag_args.iter().zip(tag_results.into_iter()) {
let total = modified.len() + skipped.len();
let result = SetTagResult {
tag: tag.clone(),
modified,
skipped,
total,
scanned,
dry_run,
};
results
.push(serde_json::to_value(&result).expect("derived Serialize impl should not fail"));
}
let output = mutation::unwrap_single_result(results);
Ok(CommandOutcome::success(crate::output::format_success(
format, &output,
)))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
macro_rules! md {
($s:expr) => {
$s.strip_prefix('\n').unwrap_or($s)
};
}
#[test]
fn parse_kv_simple() {
assert_eq!(parse_kv("status=done").unwrap(), ("status", "done"));
}
#[test]
fn parse_kv_first_equals_only() {
assert_eq!(parse_kv("url=http://x=y").unwrap(), ("url", "http://x=y"));
}
#[test]
fn parse_kv_no_equals() {
assert!(parse_kv("nodot").is_err());
}
#[test]
fn parse_kv_empty_key_returns_error() {
let err = parse_kv("=value").unwrap_err();
assert!(
err.contains("property name cannot be empty"),
"unexpected error: {err}"
);
}
#[test]
fn parse_kv_empty_value() {
assert_eq!(parse_kv("key=").unwrap(), ("key", ""));
}
#[test]
fn set_property_creates_new() {
let tmp = tempfile::tempdir().unwrap();
fs::write(
tmp.path().join("note.md"),
md!(r"
---
title: Note
---
"),
)
.unwrap();
let outcome = set(
tmp.path(),
&["status=done".to_owned()],
&[],
&["note.md".to_owned()],
&[],
&[],
&[],
Format::Json,
&mut None,
None,
false,
)
.unwrap();
let out = match outcome {
CommandOutcome::Success { output: s, .. } | CommandOutcome::RawOutput(s) => s,
CommandOutcome::UserError(s) => panic!("unexpected error: {s}"),
};
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(parsed["property"], "status");
assert_eq!(parsed["value"], "done");
assert_eq!(parsed["modified"].as_array().unwrap().len(), 1);
assert_eq!(parsed["scanned"].as_u64().unwrap(), 1);
assert_eq!(parsed["scanned"], parsed["total"]);
let content = fs::read_to_string(tmp.path().join("note.md")).unwrap();
assert!(content.contains("status: done"));
}
#[test]
fn set_property_overwrites_existing() {
let tmp = tempfile::tempdir().unwrap();
fs::write(
tmp.path().join("note.md"),
md!(r"
---
status: draft
---
"),
)
.unwrap();
set(
tmp.path(),
&["status=published".to_owned()],
&[],
&["note.md".to_owned()],
&[],
&[],
&[],
Format::Json,
&mut None,
None,
false,
)
.unwrap();
let content = fs::read_to_string(tmp.path().join("note.md")).unwrap();
assert!(content.contains("status: published"));
assert!(!content.contains("draft"));
}
#[test]
fn set_property_skips_when_identical() {
let tmp = tempfile::tempdir().unwrap();
fs::write(
tmp.path().join("note.md"),
md!(r"
---
status: done
---
"),
)
.unwrap();
let outcome = set(
tmp.path(),
&["status=done".to_owned()],
&[],
&["note.md".to_owned()],
&[],
&[],
&[],
Format::Json,
&mut None,
None,
false,
)
.unwrap();
let CommandOutcome::Success { output: out, .. } = outcome else {
panic!("expected success")
};
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(parsed["modified"].as_array().unwrap().len(), 0);
assert_eq!(parsed["skipped"].as_array().unwrap().len(), 1);
assert_eq!(parsed["scanned"], parsed["total"]);
}
#[test]
fn set_tag_adds_tag() {
let tmp = tempfile::tempdir().unwrap();
fs::write(
tmp.path().join("note.md"),
md!(r"
---
title: Note
---
"),
)
.unwrap();
let outcome = set(
tmp.path(),
&[],
&["rust".to_owned()],
&["note.md".to_owned()],
&[],
&[],
&[],
Format::Json,
&mut None,
None,
false,
)
.unwrap();
let CommandOutcome::Success { output: out, .. } = outcome else {
panic!("expected success")
};
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(parsed["tag"], "rust");
assert_eq!(parsed["modified"].as_array().unwrap().len(), 1);
let content = fs::read_to_string(tmp.path().join("note.md")).unwrap();
assert!(content.contains("rust"));
}
#[test]
fn set_tag_idempotent() {
let tmp = tempfile::tempdir().unwrap();
fs::write(
tmp.path().join("note.md"),
md!(r"
---
tags:
- rust
---
"),
)
.unwrap();
let outcome = set(
tmp.path(),
&[],
&["rust".to_owned()],
&["note.md".to_owned()],
&[],
&[],
&[],
Format::Json,
&mut None,
None,
false,
)
.unwrap();
let CommandOutcome::Success { output: out, .. } = outcome else {
panic!("expected success")
};
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(parsed["skipped"].as_array().unwrap().len(), 1);
}
#[test]
fn set_multiple_mutations_returns_array() {
let tmp = tempfile::tempdir().unwrap();
fs::write(
tmp.path().join("note.md"),
md!(r"
---
title: Note
---
"),
)
.unwrap();
let outcome = set(
tmp.path(),
&["status=done".to_owned()],
&["rust".to_owned()],
&["note.md".to_owned()],
&[],
&[],
&[],
Format::Json,
&mut None,
None,
false,
)
.unwrap();
let CommandOutcome::Success { output: out, .. } = outcome else {
panic!("expected success")
};
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert!(parsed.is_array(), "multiple mutations should return array");
assert_eq!(parsed.as_array().unwrap().len(), 2);
}
#[test]
fn set_requires_file_or_glob() {
let tmp = tempfile::tempdir().unwrap();
let outcome = set(
tmp.path(),
&["status=done".to_owned()],
&[],
&[],
&[],
&[],
&[],
Format::Json,
&mut None,
None,
false,
)
.unwrap();
assert!(matches!(outcome, CommandOutcome::UserError(_)));
}
#[test]
fn set_requires_at_least_one_arg() {
let tmp = tempfile::tempdir().unwrap();
let outcome = set(
tmp.path(),
&[],
&[],
&["note.md".to_owned()],
&[],
&[],
&[],
Format::Json,
&mut None,
None,
false,
)
.unwrap();
assert!(matches!(outcome, CommandOutcome::UserError(_)));
}
#[test]
fn set_invalid_kv_returns_user_error() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("note.md"), "---\ntitle: x\n---\n").unwrap();
let outcome = set(
tmp.path(),
&["no-equals-sign".to_owned()],
&[],
&["note.md".to_owned()],
&[],
&[],
&[],
Format::Json,
&mut None,
None,
false,
)
.unwrap();
assert!(matches!(outcome, CommandOutcome::UserError(_)));
}
#[test]
fn set_invalid_tag_returns_user_error() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("note.md"), "---\ntitle: x\n---\n").unwrap();
let outcome = set(
tmp.path(),
&[],
&["1984".to_owned()],
&["note.md".to_owned()],
&[],
&[],
&[],
Format::Json,
&mut None,
None,
false,
)
.unwrap();
assert!(matches!(outcome, CommandOutcome::UserError(_)));
}
#[test]
fn set_preserves_body() {
let tmp = tempfile::tempdir().unwrap();
let body = "# Heading\n\nSome content.\n";
fs::write(
tmp.path().join("note.md"),
format!("---\ntitle: Note\n---\n{body}"),
)
.unwrap();
set(
tmp.path(),
&["status=done".to_owned()],
&[],
&["note.md".to_owned()],
&[],
&[],
&[],
Format::Json,
&mut None,
None,
false,
)
.unwrap();
let content = fs::read_to_string(tmp.path().join("note.md")).unwrap();
assert!(content.contains(body), "body was corrupted:\n{content}");
}
#[test]
fn set_multiple_properties_single_read_write() {
let tmp = tempfile::tempdir().unwrap();
fs::write(
tmp.path().join("note.md"),
md!(r"
---
title: Note
---
"),
)
.unwrap();
let outcome = set(
tmp.path(),
&["status=done".to_owned(), "priority=high".to_owned()],
&[],
&["note.md".to_owned()],
&[],
&[],
&[],
Format::Json,
&mut None,
None,
false,
)
.unwrap();
let CommandOutcome::Success { output: out, .. } = outcome else {
panic!("expected success")
};
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert!(parsed.is_array());
let arr = parsed.as_array().unwrap();
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["modified"].as_array().unwrap().len(), 1);
assert_eq!(arr[1]["modified"].as_array().unwrap().len(), 1);
let content = fs::read_to_string(tmp.path().join("note.md")).unwrap();
assert!(content.contains("status: done"));
assert!(content.contains("priority: high"));
}
#[test]
fn set_property_and_tag_single_read_write() {
let tmp = tempfile::tempdir().unwrap();
fs::write(
tmp.path().join("note.md"),
md!(r"
---
title: Note
---
"),
)
.unwrap();
let outcome = set(
tmp.path(),
&["status=done".to_owned()],
&["rust".to_owned()],
&["note.md".to_owned()],
&[],
&[],
&[],
Format::Json,
&mut None,
None,
false,
)
.unwrap();
let CommandOutcome::Success { output: out, .. } = outcome else {
panic!("expected success")
};
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert!(parsed.is_array());
let content = fs::read_to_string(tmp.path().join("note.md")).unwrap();
assert!(content.contains("status: done"));
assert!(content.contains("rust"));
}
#[test]
fn set_where_property_filter_skips_nonmatching() {
use hyalo_core::filter::parse_property_filter;
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("match.md"), "---\nstatus: draft\n---\n").unwrap();
fs::write(
tmp.path().join("no-match.md"),
"---\nstatus: published\n---\n",
)
.unwrap();
let filter = parse_property_filter("status=draft").unwrap();
let outcome = set(
tmp.path(),
&["priority=high".to_owned()],
&[],
&[],
&["*.md".to_owned()],
&[filter],
&[],
Format::Json,
&mut None,
None,
false,
)
.unwrap();
let CommandOutcome::Success { output: out, .. } = outcome else {
panic!("expected success")
};
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(parsed["modified"].as_array().unwrap().len(), 1);
assert_eq!(parsed["skipped"].as_array().unwrap().len(), 0);
assert_eq!(parsed["scanned"].as_u64().unwrap(), 2);
assert!(parsed["scanned"].as_u64().unwrap() > parsed["total"].as_u64().unwrap());
let match_content = fs::read_to_string(tmp.path().join("match.md")).unwrap();
assert!(match_content.contains("priority: high"));
let no_match_content = fs::read_to_string(tmp.path().join("no-match.md")).unwrap();
assert!(!no_match_content.contains("priority"));
}
#[test]
fn set_where_tag_filter_skips_nonmatching() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("tagged.md"), "---\ntags:\n - rust\n---\n").unwrap();
fs::write(tmp.path().join("untagged.md"), "---\ntitle: Other\n---\n").unwrap();
let outcome = set(
tmp.path(),
&["status=reviewed".to_owned()],
&[],
&[],
&["*.md".to_owned()],
&[],
&["rust".to_owned()],
Format::Json,
&mut None,
None,
false,
)
.unwrap();
let CommandOutcome::Success { output: out, .. } = outcome else {
panic!("expected success")
};
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(parsed["modified"].as_array().unwrap().len(), 1);
assert_eq!(parsed["scanned"].as_u64().unwrap(), 2);
assert!(parsed["scanned"].as_u64().unwrap() > parsed["total"].as_u64().unwrap());
let tagged_content = fs::read_to_string(tmp.path().join("tagged.md")).unwrap();
assert!(tagged_content.contains("status: reviewed"));
let untagged_content = fs::read_to_string(tmp.path().join("untagged.md")).unwrap();
assert!(!untagged_content.contains("status"));
}
#[test]
fn set_rejects_gte_filter_in_property() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("note.md"), "---\ntitle: x\n---\n").unwrap();
let outcome = set(
tmp.path(),
&["priority>=3".to_owned()],
&[],
&["note.md".to_owned()],
&[],
&[],
&[],
Format::Json,
&mut None,
None,
false,
)
.unwrap();
match outcome {
CommandOutcome::UserError(msg) => {
assert!(msg.contains("--where-property"), "msg: {msg}");
}
other => panic!("expected UserError, got: {other:?}"),
}
}
#[test]
fn set_rejects_lte_filter_in_property() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("note.md"), "---\ntitle: x\n---\n").unwrap();
let outcome = set(
tmp.path(),
&["priority<=3".to_owned()],
&[],
&["note.md".to_owned()],
&[],
&[],
&[],
Format::Json,
&mut None,
None,
false,
)
.unwrap();
assert!(matches!(outcome, CommandOutcome::UserError(_)));
}
#[test]
fn set_rejects_neq_filter_in_property() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("note.md"), "---\ntitle: x\n---\n").unwrap();
let outcome = set(
tmp.path(),
&["status!=draft".to_owned()],
&[],
&["note.md".to_owned()],
&[],
&[],
&[],
Format::Json,
&mut None,
None,
false,
)
.unwrap();
assert!(matches!(outcome, CommandOutcome::UserError(_)));
}
#[test]
fn set_rejects_regex_filter_in_property() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("note.md"), "---\ntitle: x\n---\n").unwrap();
let outcome = set(
tmp.path(),
&["name~=pattern".to_owned()],
&[],
&["note.md".to_owned()],
&[],
&[],
&[],
Format::Json,
&mut None,
None,
false,
)
.unwrap();
assert!(matches!(outcome, CommandOutcome::UserError(_)));
}
}