Skip to main content

hyalo_cli/commands/
set.rs

1#![allow(clippy::missing_errors_doc)]
2use anyhow::Result;
3use serde::Serialize;
4use serde_json::Value;
5use std::path::Path;
6
7use crate::commands::{FilesOrOutcome, collect_files, mutation, require_file_or_glob};
8use crate::output::{CommandOutcome, Format};
9use hyalo_core::filter::{self, PropertyFilter};
10use hyalo_core::frontmatter;
11use hyalo_core::index::SnapshotIndex;
12use hyalo_core::schema::SchemaConfig;
13
14// ---------------------------------------------------------------------------
15// Output types
16// ---------------------------------------------------------------------------
17
18/// Result of a `set --property K=V` operation across files.
19#[derive(Debug, Serialize)]
20pub(crate) struct SetPropertyResult {
21    pub(crate) property: String,
22    pub(crate) value: String,
23    pub(crate) modified: Vec<String>,
24    pub(crate) skipped: Vec<String>,
25    pub(crate) total: usize,
26    pub(crate) scanned: usize,
27    pub(crate) dry_run: bool,
28}
29
30/// Result of a `set --tag T` operation across files.
31#[derive(Debug, Serialize)]
32pub(crate) struct SetTagResult {
33    pub(crate) tag: String,
34    pub(crate) modified: Vec<String>,
35    pub(crate) skipped: Vec<String>,
36    pub(crate) total: usize,
37    pub(crate) scanned: usize,
38    pub(crate) dry_run: bool,
39}
40
41// ---------------------------------------------------------------------------
42// Parsing helper
43// ---------------------------------------------------------------------------
44
45/// Parse a `K=V` string into `(name, raw_value)`.
46///
47/// Returns a user-visible error if no `=` is found.
48pub fn parse_kv(s: &str) -> Result<(&str, &str), String> {
49    match s.find('=') {
50        Some(pos) => {
51            let key = &s[..pos];
52            if key.trim().is_empty() {
53                return Err(format!(
54                    "invalid property argument '{s}': property name cannot be empty"
55                ));
56            }
57            Ok((key, &s[pos + 1..]))
58        }
59        None => Err(format!(
60            "invalid property argument '{s}': expected K=V format (e.g. status=completed)"
61        )),
62    }
63}
64
65// ---------------------------------------------------------------------------
66// In-memory tag mutation helper
67// ---------------------------------------------------------------------------
68
69/// Add `tag` to the `tags` list in `props` (in memory only, no I/O).
70///
71/// Returns `true` if the tag was actually added (i.e. was not already present).
72///
73/// Mirrors the logic in `add_values_to_list_property` for the `tags` key, but
74/// operates on an already-loaded `IndexMap` to avoid a second `read_frontmatter`
75/// call when processing multiple mutations for the same file.
76fn add_tag_in_memory(props: &mut indexmap::IndexMap<String, Value>, tag: &str) -> Result<bool> {
77    const KEY: &str = "tags";
78
79    // Guard: reject non-list scalar types that are neither string nor sequence.
80    match props.get(KEY) {
81        None | Some(Value::Null | Value::String(_) | Value::Array(_)) => {}
82        Some(existing) => {
83            let kind = match existing {
84                Value::Bool(_) => "boolean",
85                Value::Number(_) => "number",
86                Value::Object(_) => "mapping",
87                _ => "unknown",
88            };
89            anyhow::bail!(
90                "property 'tags' is a {kind} value, not a list — \
91                 use `set --property` to overwrite it explicitly"
92            );
93        }
94    }
95
96    if let Some(Value::Array(seq)) = props.get_mut(KEY) {
97        let already = seq.iter().any(|v| match v {
98            Value::String(s) => s.eq_ignore_ascii_case(tag),
99            Value::Number(n) => n.to_string().eq_ignore_ascii_case(tag),
100            Value::Bool(b) => b.to_string().eq_ignore_ascii_case(tag),
101            _ => false,
102        });
103        if already {
104            return Ok(false);
105        }
106        seq.push(Value::String(tag.to_owned()));
107        Ok(true)
108    } else {
109        // Absent / null / scalar-string: build a new list.
110        let existing_str = match props.get(KEY) {
111            Some(Value::String(s)) if !s.is_empty() => Some(s.clone()),
112            _ => None,
113        };
114
115        // Duplicate check against existing scalar string (if any).
116        if let Some(ref s) = existing_str
117            && s.eq_ignore_ascii_case(tag)
118        {
119            return Ok(false);
120        }
121
122        let mut list: Vec<Value> = existing_str.map(Value::String).into_iter().collect();
123        list.push(Value::String(tag.to_owned()));
124        props.insert(KEY.to_owned(), Value::Array(list));
125        Ok(true)
126    }
127}
128
129// ---------------------------------------------------------------------------
130// `hyalo set` command
131// ---------------------------------------------------------------------------
132
133/// Set properties and/or tags across matched files.
134///
135/// - `property_args`: zero or more `"K=V"` strings (type is inferred from V)
136/// - `tag_args`:      zero or more tag name strings
137/// - Requires `--file` or `--glob`
138/// - At least one of `property_args` or `tag_args` must be non-empty
139/// - `validate`: when `true`, validates new property values against the schema
140///   before writing; rejects violations with a `UserError`.
141#[allow(clippy::too_many_arguments)]
142pub fn set(
143    dir: &Path,
144    property_args: &[String],
145    tag_args: &[String],
146    files: &[String],
147    globs: &[String],
148    where_property_filters: &[PropertyFilter],
149    where_tag_filters: &[String],
150    format: Format,
151    snapshot_index: &mut Option<SnapshotIndex>,
152    index_path: Option<&Path>,
153    dry_run: bool,
154    validate: bool,
155    schema: Option<&SchemaConfig>,
156) -> Result<CommandOutcome> {
157    // At least one mutation target required
158    if property_args.is_empty() && tag_args.is_empty() {
159        let out = crate::output::format_error(
160            format,
161            "set requires at least one --property K=V or --tag T",
162            None,
163            Some("example: hyalo set --property status=completed --file note.md"),
164            None,
165        );
166        return Ok(CommandOutcome::UserError(out));
167    }
168
169    // Mutation commands require --file or --glob, UNLESS --where-property or --where-tag
170    // is provided — in that case, default to all vault files and apply the filters.
171    let has_where = !where_property_filters.is_empty() || !where_tag_filters.is_empty();
172    if !has_where && let Some(outcome) = require_file_or_glob(files, globs, "set", format) {
173        return Ok(outcome);
174    }
175
176    // Validate all K=V args before touching files
177    for arg in property_args {
178        match parse_kv(arg) {
179            Err(msg) => {
180                let out = crate::output::format_error(format, &msg, None, None, None);
181                return Ok(CommandOutcome::UserError(out));
182            }
183            Ok((key, _)) => {
184                if let Some(outcome) = super::reject_filter_in_mutation_property(key, format) {
185                    return Ok(outcome);
186                }
187            }
188        }
189    }
190
191    // Validate tag names
192    for tag in tag_args {
193        if let Err(msg) = crate::commands::tags::validate_tag(tag) {
194            let out = crate::output::format_error(
195                format,
196                &msg,
197                None,
198                Some(
199                    "tag names may contain letters, digits, _, -, / and must have at least one non-numeric character",
200                ),
201                None,
202            );
203            return Ok(CommandOutcome::UserError(out));
204        }
205    }
206
207    // Pre-parse all property values before touching files
208    // Each entry is (name, raw_value, parsed_value)
209    let parsed_props: Vec<(&str, &str, Value)> = {
210        let mut v = Vec::with_capacity(property_args.len());
211        for arg in property_args {
212            let (name, raw_value) =
213                parse_kv(arg).map_err(|e| anyhow::anyhow!("invalid property argument: {e}"))?;
214            let value = match frontmatter::parse_value(raw_value, None) {
215                Ok(val) => val,
216                Err(e) => {
217                    let out = crate::output::format_error(
218                        format,
219                        &format!("failed to parse value for property '{name}': {e}"),
220                        None,
221                        None,
222                        None,
223                    );
224                    return Ok(CommandOutcome::UserError(out));
225                }
226            };
227            v.push((name, raw_value, value));
228        }
229        v
230    };
231
232    let files = collect_files(dir, files, globs, format)?;
233    let files = match files {
234        FilesOrOutcome::Files(f) => f,
235        FilesOrOutcome::Outcome(o) => return Ok(o),
236    };
237    let scanned = files.len();
238
239    // Per-property result accumulators: (modified, skipped)
240    let mut prop_results: Vec<(Vec<String>, Vec<String>)> =
241        vec![(Vec::new(), Vec::new()); parsed_props.len()];
242    // Per-tag result accumulators: (modified, skipped)
243    let mut tag_results: Vec<(Vec<String>, Vec<String>)> =
244        vec![(Vec::new(), Vec::new()); tag_args.len()];
245
246    // --- Pre-validation pass (BUG-D): validate all proposed writes before any file
247    //     is modified. This keeps batch mutations atomic — if any file would fail
248    //     validation, no files are written. The schema is chosen from the merged
249    //     `type` property (post-mutation), so `--property type=X` selects X's schema.
250    if validate && let Some(schema) = schema {
251        for (full_path, rel_path) in &files {
252            let props = match frontmatter::read_frontmatter(full_path) {
253                Ok(p) => p,
254                // Parse errors are reported as warnings during the write loop; skip here.
255                Err(e) if frontmatter::is_parse_error(&e) => continue,
256                Err(e) => return Err(e),
257            };
258            if !filter::matches_frontmatter_filters(
259                &props,
260                where_property_filters,
261                where_tag_filters,
262            ) {
263                continue;
264            }
265            // Apply set mutations in-memory to compute the post-mutation props.
266            let mut merged = props.clone();
267            for (name, _, value) in &parsed_props {
268                merged.insert((*name).to_owned(), value.clone());
269            }
270            let doc_type = merged.get("type").and_then(|v| match v {
271                serde_json::Value::String(s) => Some(s.as_str()),
272                _ => None,
273            });
274            let effective_schema = match doc_type {
275                Some(t) => schema.merged_schema_for_type(t),
276                None => schema.default_schema().clone(),
277            };
278            for (name, raw_value, _) in &parsed_props {
279                if let Some(constraint) = effective_schema.properties.get(*name)
280                    && let Some(merged_value) = merged.get(*name)
281                    && let Some(violation) = crate::commands::lint::validate_constraint_simple(
282                        name,
283                        merged_value,
284                        constraint,
285                    )
286                {
287                    let out = crate::output::format_error(
288                        format,
289                        &format!("{rel_path}: {violation}"),
290                        None,
291                        Some(&format!(
292                            "rerun without --validate or fix the value (provided: {raw_value:?})"
293                        )),
294                        None,
295                    );
296                    return Ok(CommandOutcome::UserError(out));
297                }
298            }
299        }
300    }
301
302    let mut index_dirty = false;
303
304    // Outer loop: one read-modify-write per file
305    for (full_path, rel_path) in &files {
306        let mtime = frontmatter::read_mtime(full_path)?;
307        let mut props = match frontmatter::read_frontmatter(full_path) {
308            Ok(p) => p,
309            Err(e) if frontmatter::is_parse_error(&e) => {
310                crate::warn::warn(format!("skipping {rel_path}: {e}"));
311                continue;
312            }
313            Err(e) => return Err(e),
314        };
315
316        // Apply --where-* filters: skip files that don't match
317        if !filter::matches_frontmatter_filters(&props, where_property_filters, where_tag_filters) {
318            continue;
319        }
320
321        let mut file_changed = false;
322
323        // Apply all --property mutations
324        for (i, (name, _, value)) in parsed_props.iter().enumerate() {
325            let already_same = props.get(*name) == Some(value);
326            if already_same {
327                prop_results[i].1.push(rel_path.clone()); // skipped
328            } else {
329                props.insert((*name).to_owned(), value.clone());
330                prop_results[i].0.push(rel_path.clone()); // modified
331                file_changed = true;
332            }
333        }
334
335        // Apply all --tag mutations
336        for (i, tag) in tag_args.iter().enumerate() {
337            match add_tag_in_memory(&mut props, tag) {
338                Ok(true) => {
339                    tag_results[i].0.push(rel_path.clone()); // modified
340                    file_changed = true;
341                }
342                Ok(false) => {
343                    tag_results[i].1.push(rel_path.clone()); // skipped
344                }
345                Err(e) => return Err(e),
346            }
347        }
348
349        if file_changed && !dry_run {
350            frontmatter::check_mtime(full_path, mtime)?;
351            frontmatter::write_frontmatter(full_path, &props)?;
352            mutation::update_index_entry(
353                snapshot_index,
354                rel_path,
355                props,
356                full_path,
357                &mut index_dirty,
358            )?;
359        }
360    }
361
362    if !dry_run {
363        mutation::save_index_if_dirty(snapshot_index, index_path, index_dirty)?;
364    }
365
366    let mut results: Vec<serde_json::Value> = Vec::new();
367
368    for ((name, raw_value, _), (modified, skipped)) in parsed_props.iter().zip(prop_results) {
369        let total = modified.len() + skipped.len();
370        let result = SetPropertyResult {
371            property: (*name).to_owned(),
372            value: (*raw_value).to_owned(),
373            modified,
374            skipped,
375            total,
376            scanned,
377            dry_run,
378        };
379        results
380            .push(serde_json::to_value(&result).expect("derived Serialize impl should not fail"));
381    }
382
383    for (tag, (modified, skipped)) in tag_args.iter().zip(tag_results) {
384        let total = modified.len() + skipped.len();
385        let result = SetTagResult {
386            tag: tag.clone(),
387            modified,
388            skipped,
389            total,
390            scanned,
391            dry_run,
392        };
393        results
394            .push(serde_json::to_value(&result).expect("derived Serialize impl should not fail"));
395    }
396
397    // Return array if multiple mutations, single object if one
398    let output = mutation::unwrap_single_result(results);
399
400    Ok(CommandOutcome::success(crate::output::format_success(
401        format, &output,
402    )))
403}
404
405// ---------------------------------------------------------------------------
406// Unit tests
407// ---------------------------------------------------------------------------
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412    use std::fs;
413
414    macro_rules! md {
415        ($s:expr) => {
416            $s.strip_prefix('\n').unwrap_or($s)
417        };
418    }
419
420    // --- parse_kv ---
421
422    #[test]
423    fn parse_kv_simple() {
424        assert_eq!(parse_kv("status=done").unwrap(), ("status", "done"));
425    }
426
427    #[test]
428    fn parse_kv_first_equals_only() {
429        // Only the first `=` is the separator; value may contain `=`
430        assert_eq!(parse_kv("url=http://x=y").unwrap(), ("url", "http://x=y"));
431    }
432
433    #[test]
434    fn parse_kv_no_equals() {
435        assert!(parse_kv("nodot").is_err());
436    }
437
438    #[test]
439    fn parse_kv_empty_key_returns_error() {
440        let err = parse_kv("=value").unwrap_err();
441        assert!(
442            err.contains("property name cannot be empty"),
443            "unexpected error: {err}"
444        );
445    }
446
447    #[test]
448    fn parse_kv_empty_value() {
449        assert_eq!(parse_kv("key=").unwrap(), ("key", ""));
450    }
451
452    // --- set command ---
453
454    #[test]
455    fn set_property_creates_new() {
456        let tmp = tempfile::tempdir().unwrap();
457        fs::write(
458            tmp.path().join("note.md"),
459            md!(r"
460---
461title: Note
462---
463"),
464        )
465        .unwrap();
466
467        let outcome = set(
468            tmp.path(),
469            &["status=done".to_owned()],
470            &[],
471            &["note.md".to_owned()],
472            &[],
473            &[],
474            &[],
475            Format::Json,
476            &mut None,
477            None,
478            false,
479            false,
480            None,
481        )
482        .unwrap();
483        let out = match outcome {
484            CommandOutcome::Success { output: s, .. } | CommandOutcome::RawOutput(s) => s,
485            CommandOutcome::UserError(s) => panic!("unexpected error: {s}"),
486        };
487        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
488        assert_eq!(parsed["property"], "status");
489        assert_eq!(parsed["value"], "done");
490        assert_eq!(parsed["modified"].as_array().unwrap().len(), 1);
491        assert_eq!(parsed["scanned"].as_u64().unwrap(), 1);
492        assert_eq!(parsed["scanned"], parsed["total"]);
493
494        let content = fs::read_to_string(tmp.path().join("note.md")).unwrap();
495        assert!(content.contains("status: done"));
496    }
497
498    #[test]
499    fn set_property_overwrites_existing() {
500        let tmp = tempfile::tempdir().unwrap();
501        fs::write(
502            tmp.path().join("note.md"),
503            md!(r"
504---
505status: draft
506---
507"),
508        )
509        .unwrap();
510
511        set(
512            tmp.path(),
513            &["status=published".to_owned()],
514            &[],
515            &["note.md".to_owned()],
516            &[],
517            &[],
518            &[],
519            Format::Json,
520            &mut None,
521            None,
522            false,
523            false,
524            None,
525        )
526        .unwrap();
527
528        let content = fs::read_to_string(tmp.path().join("note.md")).unwrap();
529        assert!(content.contains("status: published"));
530        assert!(!content.contains("draft"));
531    }
532
533    #[test]
534    fn set_property_skips_when_identical() {
535        let tmp = tempfile::tempdir().unwrap();
536        fs::write(
537            tmp.path().join("note.md"),
538            md!(r"
539---
540status: done
541---
542"),
543        )
544        .unwrap();
545
546        let outcome = set(
547            tmp.path(),
548            &["status=done".to_owned()],
549            &[],
550            &["note.md".to_owned()],
551            &[],
552            &[],
553            &[],
554            Format::Json,
555            &mut None,
556            None,
557            false,
558            false,
559            None,
560        )
561        .unwrap();
562        let CommandOutcome::Success { output: out, .. } = outcome else {
563            panic!("expected success")
564        };
565        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
566        assert_eq!(parsed["modified"].as_array().unwrap().len(), 0);
567        assert_eq!(parsed["skipped"].as_array().unwrap().len(), 1);
568        assert_eq!(parsed["scanned"], parsed["total"]);
569    }
570
571    #[test]
572    fn set_tag_adds_tag() {
573        let tmp = tempfile::tempdir().unwrap();
574        fs::write(
575            tmp.path().join("note.md"),
576            md!(r"
577---
578title: Note
579---
580"),
581        )
582        .unwrap();
583
584        let outcome = set(
585            tmp.path(),
586            &[],
587            &["rust".to_owned()],
588            &["note.md".to_owned()],
589            &[],
590            &[],
591            &[],
592            Format::Json,
593            &mut None,
594            None,
595            false,
596            false,
597            None,
598        )
599        .unwrap();
600        let CommandOutcome::Success { output: out, .. } = outcome else {
601            panic!("expected success")
602        };
603        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
604        assert_eq!(parsed["tag"], "rust");
605        assert_eq!(parsed["modified"].as_array().unwrap().len(), 1);
606
607        let content = fs::read_to_string(tmp.path().join("note.md")).unwrap();
608        assert!(content.contains("rust"));
609    }
610
611    #[test]
612    fn set_tag_idempotent() {
613        let tmp = tempfile::tempdir().unwrap();
614        fs::write(
615            tmp.path().join("note.md"),
616            md!(r"
617---
618tags:
619  - rust
620---
621"),
622        )
623        .unwrap();
624
625        let outcome = set(
626            tmp.path(),
627            &[],
628            &["rust".to_owned()],
629            &["note.md".to_owned()],
630            &[],
631            &[],
632            &[],
633            Format::Json,
634            &mut None,
635            None,
636            false,
637            false,
638            None,
639        )
640        .unwrap();
641        let CommandOutcome::Success { output: out, .. } = outcome else {
642            panic!("expected success")
643        };
644        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
645        assert_eq!(parsed["skipped"].as_array().unwrap().len(), 1);
646    }
647
648    #[test]
649    fn set_multiple_mutations_returns_array() {
650        let tmp = tempfile::tempdir().unwrap();
651        fs::write(
652            tmp.path().join("note.md"),
653            md!(r"
654---
655title: Note
656---
657"),
658        )
659        .unwrap();
660
661        let outcome = set(
662            tmp.path(),
663            &["status=done".to_owned()],
664            &["rust".to_owned()],
665            &["note.md".to_owned()],
666            &[],
667            &[],
668            &[],
669            Format::Json,
670            &mut None,
671            None,
672            false,
673            false,
674            None,
675        )
676        .unwrap();
677        let CommandOutcome::Success { output: out, .. } = outcome else {
678            panic!("expected success")
679        };
680        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
681        assert!(parsed.is_array(), "multiple mutations should return array");
682        assert_eq!(parsed.as_array().unwrap().len(), 2);
683    }
684
685    #[test]
686    fn set_requires_file_or_glob() {
687        let tmp = tempfile::tempdir().unwrap();
688        let outcome = set(
689            tmp.path(),
690            &["status=done".to_owned()],
691            &[],
692            &[],
693            &[],
694            &[],
695            &[],
696            Format::Json,
697            &mut None,
698            None,
699            false,
700            false,
701            None,
702        )
703        .unwrap();
704        assert!(matches!(outcome, CommandOutcome::UserError(_)));
705    }
706
707    #[test]
708    fn set_requires_at_least_one_arg() {
709        let tmp = tempfile::tempdir().unwrap();
710        let outcome = set(
711            tmp.path(),
712            &[],
713            &[],
714            &["note.md".to_owned()],
715            &[],
716            &[],
717            &[],
718            Format::Json,
719            &mut None,
720            None,
721            false,
722            false,
723            None,
724        )
725        .unwrap();
726        assert!(matches!(outcome, CommandOutcome::UserError(_)));
727    }
728
729    #[test]
730    fn set_invalid_kv_returns_user_error() {
731        let tmp = tempfile::tempdir().unwrap();
732        fs::write(tmp.path().join("note.md"), "---\ntitle: x\n---\n").unwrap();
733        let outcome = set(
734            tmp.path(),
735            &["no-equals-sign".to_owned()],
736            &[],
737            &["note.md".to_owned()],
738            &[],
739            &[],
740            &[],
741            Format::Json,
742            &mut None,
743            None,
744            false,
745            false,
746            None,
747        )
748        .unwrap();
749        assert!(matches!(outcome, CommandOutcome::UserError(_)));
750    }
751
752    #[test]
753    fn set_invalid_tag_returns_user_error() {
754        let tmp = tempfile::tempdir().unwrap();
755        fs::write(tmp.path().join("note.md"), "---\ntitle: x\n---\n").unwrap();
756        let outcome = set(
757            tmp.path(),
758            &[],
759            &["1984".to_owned()],
760            &["note.md".to_owned()],
761            &[],
762            &[],
763            &[],
764            Format::Json,
765            &mut None,
766            None,
767            false,
768            false,
769            None,
770        )
771        .unwrap();
772        assert!(matches!(outcome, CommandOutcome::UserError(_)));
773    }
774
775    #[test]
776    fn set_preserves_body() {
777        let tmp = tempfile::tempdir().unwrap();
778        let body = "# Heading\n\nSome content.\n";
779        fs::write(
780            tmp.path().join("note.md"),
781            format!("---\ntitle: Note\n---\n{body}"),
782        )
783        .unwrap();
784
785        set(
786            tmp.path(),
787            &["status=done".to_owned()],
788            &[],
789            &["note.md".to_owned()],
790            &[],
791            &[],
792            &[],
793            Format::Json,
794            &mut None,
795            None,
796            false,
797            false,
798            None,
799        )
800        .unwrap();
801
802        let content = fs::read_to_string(tmp.path().join("note.md")).unwrap();
803        assert!(content.contains(body), "body was corrupted:\n{content}");
804    }
805
806    #[test]
807    fn set_multiple_properties_single_read_write() {
808        // Setting two properties on the same file should produce both mutations
809        // from a single read-modify-write cycle.
810        let tmp = tempfile::tempdir().unwrap();
811        fs::write(
812            tmp.path().join("note.md"),
813            md!(r"
814---
815title: Note
816---
817"),
818        )
819        .unwrap();
820
821        let outcome = set(
822            tmp.path(),
823            &["status=done".to_owned(), "priority=high".to_owned()],
824            &[],
825            &["note.md".to_owned()],
826            &[],
827            &[],
828            &[],
829            Format::Json,
830            &mut None,
831            None,
832            false,
833            false,
834            None,
835        )
836        .unwrap();
837        let CommandOutcome::Success { output: out, .. } = outcome else {
838            panic!("expected success")
839        };
840        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
841        assert!(parsed.is_array());
842        let arr = parsed.as_array().unwrap();
843        assert_eq!(arr.len(), 2);
844        // Both properties modified
845        assert_eq!(arr[0]["modified"].as_array().unwrap().len(), 1);
846        assert_eq!(arr[1]["modified"].as_array().unwrap().len(), 1);
847
848        let content = fs::read_to_string(tmp.path().join("note.md")).unwrap();
849        assert!(content.contains("status: done"));
850        assert!(content.contains("priority: high"));
851    }
852
853    #[test]
854    fn set_property_and_tag_single_read_write() {
855        // Setting a property and a tag on the same file: both applied in one cycle.
856        let tmp = tempfile::tempdir().unwrap();
857        fs::write(
858            tmp.path().join("note.md"),
859            md!(r"
860---
861title: Note
862---
863"),
864        )
865        .unwrap();
866
867        let outcome = set(
868            tmp.path(),
869            &["status=done".to_owned()],
870            &["rust".to_owned()],
871            &["note.md".to_owned()],
872            &[],
873            &[],
874            &[],
875            Format::Json,
876            &mut None,
877            None,
878            false,
879            false,
880            None,
881        )
882        .unwrap();
883        let CommandOutcome::Success { output: out, .. } = outcome else {
884            panic!("expected success")
885        };
886        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
887        assert!(parsed.is_array());
888
889        let content = fs::read_to_string(tmp.path().join("note.md")).unwrap();
890        assert!(content.contains("status: done"));
891        assert!(content.contains("rust"));
892    }
893
894    #[test]
895    fn set_where_property_filter_skips_nonmatching() {
896        use hyalo_core::filter::parse_property_filter;
897        // Files that don't match --where-property are not mutated.
898        let tmp = tempfile::tempdir().unwrap();
899        fs::write(tmp.path().join("match.md"), "---\nstatus: draft\n---\n").unwrap();
900        fs::write(
901            tmp.path().join("no-match.md"),
902            "---\nstatus: published\n---\n",
903        )
904        .unwrap();
905
906        let filter = parse_property_filter("status=draft").unwrap();
907        let outcome = set(
908            tmp.path(),
909            &["priority=high".to_owned()],
910            &[],
911            &[],
912            &["*.md".to_owned()],
913            &[filter],
914            &[],
915            Format::Json,
916            &mut None,
917            None,
918            false,
919            false,
920            None,
921        )
922        .unwrap();
923        let CommandOutcome::Success { output: out, .. } = outcome else {
924            panic!("expected success")
925        };
926        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
927        assert_eq!(parsed["modified"].as_array().unwrap().len(), 1);
928        assert_eq!(parsed["skipped"].as_array().unwrap().len(), 0);
929        // 2 files scanned, 1 passed the where-filter (total = modified + skipped)
930        assert_eq!(parsed["scanned"].as_u64().unwrap(), 2);
931        assert!(parsed["scanned"].as_u64().unwrap() > parsed["total"].as_u64().unwrap());
932
933        let match_content = fs::read_to_string(tmp.path().join("match.md")).unwrap();
934        assert!(match_content.contains("priority: high"));
935        let no_match_content = fs::read_to_string(tmp.path().join("no-match.md")).unwrap();
936        assert!(!no_match_content.contains("priority"));
937    }
938
939    #[test]
940    fn set_where_tag_filter_skips_nonmatching() {
941        // Files without the required tag are not mutated.
942        let tmp = tempfile::tempdir().unwrap();
943        fs::write(tmp.path().join("tagged.md"), "---\ntags:\n  - rust\n---\n").unwrap();
944        fs::write(tmp.path().join("untagged.md"), "---\ntitle: Other\n---\n").unwrap();
945
946        let outcome = set(
947            tmp.path(),
948            &["status=reviewed".to_owned()],
949            &[],
950            &[],
951            &["*.md".to_owned()],
952            &[],
953            &["rust".to_owned()],
954            Format::Json,
955            &mut None,
956            None,
957            false,
958            false,
959            None,
960        )
961        .unwrap();
962        let CommandOutcome::Success { output: out, .. } = outcome else {
963            panic!("expected success")
964        };
965        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
966        assert_eq!(parsed["modified"].as_array().unwrap().len(), 1);
967        // 2 files scanned, 1 passed the where-filter
968        assert_eq!(parsed["scanned"].as_u64().unwrap(), 2);
969        assert!(parsed["scanned"].as_u64().unwrap() > parsed["total"].as_u64().unwrap());
970
971        let tagged_content = fs::read_to_string(tmp.path().join("tagged.md")).unwrap();
972        assert!(tagged_content.contains("status: reviewed"));
973        let untagged_content = fs::read_to_string(tmp.path().join("untagged.md")).unwrap();
974        assert!(!untagged_content.contains("status"));
975    }
976
977    // --- filter guard ---
978
979    #[test]
980    fn set_rejects_gte_filter_in_property() {
981        let tmp = tempfile::tempdir().unwrap();
982        fs::write(tmp.path().join("note.md"), "---\ntitle: x\n---\n").unwrap();
983        let outcome = set(
984            tmp.path(),
985            &["priority>=3".to_owned()],
986            &[],
987            &["note.md".to_owned()],
988            &[],
989            &[],
990            &[],
991            Format::Json,
992            &mut None,
993            None,
994            false,
995            false,
996            None,
997        )
998        .unwrap();
999        match outcome {
1000            CommandOutcome::UserError(msg) => {
1001                assert!(msg.contains("--where-property"), "msg: {msg}");
1002            }
1003            other => panic!("expected UserError, got: {other:?}"),
1004        }
1005    }
1006
1007    #[test]
1008    fn set_rejects_lte_filter_in_property() {
1009        let tmp = tempfile::tempdir().unwrap();
1010        fs::write(tmp.path().join("note.md"), "---\ntitle: x\n---\n").unwrap();
1011        let outcome = set(
1012            tmp.path(),
1013            &["priority<=3".to_owned()],
1014            &[],
1015            &["note.md".to_owned()],
1016            &[],
1017            &[],
1018            &[],
1019            Format::Json,
1020            &mut None,
1021            None,
1022            false,
1023            false,
1024            None,
1025        )
1026        .unwrap();
1027        assert!(matches!(outcome, CommandOutcome::UserError(_)));
1028    }
1029
1030    #[test]
1031    fn set_rejects_neq_filter_in_property() {
1032        let tmp = tempfile::tempdir().unwrap();
1033        fs::write(tmp.path().join("note.md"), "---\ntitle: x\n---\n").unwrap();
1034        let outcome = set(
1035            tmp.path(),
1036            &["status!=draft".to_owned()],
1037            &[],
1038            &["note.md".to_owned()],
1039            &[],
1040            &[],
1041            &[],
1042            Format::Json,
1043            &mut None,
1044            None,
1045            false,
1046            false,
1047            None,
1048        )
1049        .unwrap();
1050        assert!(matches!(outcome, CommandOutcome::UserError(_)));
1051    }
1052
1053    #[test]
1054    fn set_rejects_regex_filter_in_property() {
1055        let tmp = tempfile::tempdir().unwrap();
1056        fs::write(tmp.path().join("note.md"), "---\ntitle: x\n---\n").unwrap();
1057        let outcome = set(
1058            tmp.path(),
1059            &["name~=pattern".to_owned()],
1060            &[],
1061            &["note.md".to_owned()],
1062            &[],
1063            &[],
1064            &[],
1065            Format::Json,
1066            &mut None,
1067            None,
1068            false,
1069            false,
1070            None,
1071        )
1072        .unwrap();
1073        assert!(matches!(outcome, CommandOutcome::UserError(_)));
1074    }
1075
1076    // ---------------------------------------------------------------------------
1077    // BUG-D: --validate rejects values violating schema constraints
1078    // ---------------------------------------------------------------------------
1079
1080    #[test]
1081    fn set_validate_rejects_invalid_enum_value() {
1082        use hyalo_core::schema::{PropertyConstraint, SchemaConfig, TypeSchema};
1083        use std::collections::HashMap;
1084
1085        let tmp = tempfile::tempdir().unwrap();
1086        fs::write(
1087            tmp.path().join("note.md"),
1088            md!(r"
1089---
1090title: My Note
1091type: post
1092---
1093"),
1094        )
1095        .unwrap();
1096
1097        // Schema: post.status must be one of [draft, published]
1098        let mut type_props = HashMap::new();
1099        type_props.insert(
1100            "status".to_owned(),
1101            PropertyConstraint::Enum {
1102                values: vec!["draft".to_owned(), "published".to_owned()],
1103            },
1104        );
1105        let schema = SchemaConfig {
1106            default: TypeSchema::default(),
1107            types: {
1108                let mut m = HashMap::new();
1109                m.insert(
1110                    "post".to_owned(),
1111                    TypeSchema {
1112                        required: vec![],
1113                        properties: type_props,
1114                        filename_template: None,
1115                        defaults: HashMap::new(),
1116                    },
1117                );
1118                m
1119            },
1120        };
1121
1122        let outcome = set(
1123            tmp.path(),
1124            &["status=archived".to_owned()], // not in enum
1125            &[],
1126            &["note.md".to_owned()],
1127            &[],
1128            &[],
1129            &[],
1130            Format::Json,
1131            &mut None,
1132            None,
1133            false,
1134            true, // validate = true
1135            Some(&schema),
1136        )
1137        .unwrap();
1138        assert!(
1139            matches!(outcome, CommandOutcome::UserError(_)),
1140            "expected UserError for invalid enum value"
1141        );
1142    }
1143
1144    #[test]
1145    fn set_validate_accepts_valid_enum_value() {
1146        use hyalo_core::schema::{PropertyConstraint, SchemaConfig, TypeSchema};
1147        use std::collections::HashMap;
1148
1149        let tmp = tempfile::tempdir().unwrap();
1150        fs::write(
1151            tmp.path().join("note.md"),
1152            md!(r"
1153---
1154title: My Note
1155type: post
1156---
1157"),
1158        )
1159        .unwrap();
1160
1161        let mut type_props = HashMap::new();
1162        type_props.insert(
1163            "status".to_owned(),
1164            PropertyConstraint::Enum {
1165                values: vec!["draft".to_owned(), "published".to_owned()],
1166            },
1167        );
1168        let schema = SchemaConfig {
1169            default: TypeSchema::default(),
1170            types: {
1171                let mut m = HashMap::new();
1172                m.insert(
1173                    "post".to_owned(),
1174                    TypeSchema {
1175                        required: vec![],
1176                        properties: type_props,
1177                        filename_template: None,
1178                        defaults: HashMap::new(),
1179                    },
1180                );
1181                m
1182            },
1183        };
1184
1185        let outcome = set(
1186            tmp.path(),
1187            &["status=published".to_owned()], // valid
1188            &[],
1189            &["note.md".to_owned()],
1190            &[],
1191            &[],
1192            &[],
1193            Format::Json,
1194            &mut None,
1195            None,
1196            false,
1197            true, // validate = true
1198            Some(&schema),
1199        )
1200        .unwrap();
1201        assert!(
1202            matches!(outcome, CommandOutcome::Success { .. }),
1203            "expected success for valid enum value"
1204        );
1205    }
1206}