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