Skip to main content

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