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