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