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#[derive(Debug, Serialize)]
19pub(crate) struct RemovePropertyResult {
20 pub(crate) property: String,
21 #[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#[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
42pub 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
65fn remove_key_in_memory(props: &mut indexmap::IndexMap<String, Value>, name: &str) -> bool {
73 props.shift_remove(name).is_some()
74}
75
76fn remove_value_in_memory(
86 props: &mut indexmap::IndexMap<String, Value>,
87 name: &str,
88 target: &str,
89) -> bool {
90 let is_sequence = matches!(props.get(name), Some(Value::Array(_)));
92
93 if is_sequence {
94 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, });
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 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 | Some(_) => false,
143 }
144}
145
146fn remove_tag_in_memory(props: &mut indexmap::IndexMap<String, Value>, tag: &str) -> bool {
150 remove_value_in_memory(props, "tags", tag)
151}
152
153#[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 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 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 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 let mut prop_results: Vec<(Vec<String>, Vec<String>)> =
231 vec![(Vec::new(), Vec::new()); parsed_props.len()];
232 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 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 if !filter::matches_frontmatter_filters(&props, where_property_filters, where_tag_filters) {
252 continue;
253 }
254
255 let mut file_changed = false;
256
257 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()); file_changed = true;
266 } else {
267 prop_results[i].1.push(rel_path.clone()); }
269 }
270
271 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()); file_changed = true;
276 } else {
277 tag_results[i].1.push(rel_path.clone()); }
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 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 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#[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 #[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 #[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 #[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 let content = fs::read_to_string(tmp.path().join("note.md")).unwrap();
536 assert!(content.contains("published"));
537 }
538
539 #[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 #[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 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 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 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 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 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 #[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 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}