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#[derive(Debug, Serialize)]
20pub struct RemovePropertyResult {
21 pub property: String,
22 #[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#[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
43pub 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
66fn remove_key_in_memory(props: &mut indexmap::IndexMap<String, Value>, name: &str) -> bool {
74 props.shift_remove(name).is_some()
75}
76
77fn remove_value_in_memory(
87 props: &mut indexmap::IndexMap<String, Value>,
88 name: &str,
89 target: &str,
90) -> bool {
91 let is_sequence = matches!(props.get(name), Some(Value::Array(_)));
93
94 if is_sequence {
95 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, });
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 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 | Some(_) => false,
144 }
145}
146
147fn remove_tag_in_memory(props: &mut indexmap::IndexMap<String, Value>, tag: &str) -> bool {
151 remove_value_in_memory(props, "tags", tag)
152}
153
154#[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 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 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 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 let mut prop_results: Vec<(Vec<String>, Vec<String>)> =
241 vec![(Vec::new(), Vec::new()); parsed_props.len()];
242 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 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 if !filter::matches_frontmatter_filters(&props, where_property_filters, where_tag_filters) {
261 continue;
262 }
263
264 let mut file_changed = false;
265
266 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()); file_changed = true;
275 } else {
276 prop_results[i].1.push(rel_path.clone()); }
278 }
279
280 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()); file_changed = true;
285 } else {
286 tag_results[i].1.push(rel_path.clone()); }
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 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 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#[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 #[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 #[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 #[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 let content = fs::read_to_string(tmp.path().join("note.md")).unwrap();
546 assert!(content.contains("published"));
547 }
548
549 #[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 #[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 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 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 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 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 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 #[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}