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;
12use hyalo_core::schema::SchemaConfig;
13
14#[derive(Debug, Serialize)]
20pub(crate) struct SetPropertyResult {
21 pub(crate) property: String,
22 pub(crate) value: String,
23 pub(crate) modified: Vec<String>,
24 pub(crate) skipped: Vec<String>,
25 pub(crate) total: usize,
26 pub(crate) scanned: usize,
27 pub(crate) dry_run: bool,
28}
29
30#[derive(Debug, Serialize)]
32pub(crate) struct SetTagResult {
33 pub(crate) tag: String,
34 pub(crate) modified: Vec<String>,
35 pub(crate) skipped: Vec<String>,
36 pub(crate) total: usize,
37 pub(crate) scanned: usize,
38 pub(crate) dry_run: bool,
39}
40
41pub fn parse_kv(s: &str) -> Result<(&str, &str), String> {
49 match s.find('=') {
50 Some(pos) => {
51 let key = &s[..pos];
52 if key.trim().is_empty() {
53 return Err(format!(
54 "invalid property argument '{s}': property name cannot be empty"
55 ));
56 }
57 Ok((key, &s[pos + 1..]))
58 }
59 None => Err(format!(
60 "invalid property argument '{s}': expected K=V format (e.g. status=completed)"
61 )),
62 }
63}
64
65fn add_tag_in_memory(props: &mut indexmap::IndexMap<String, Value>, tag: &str) -> Result<bool> {
77 const KEY: &str = "tags";
78
79 match props.get(KEY) {
81 None | Some(Value::Null | Value::String(_) | Value::Array(_)) => {}
82 Some(existing) => {
83 let kind = match existing {
84 Value::Bool(_) => "boolean",
85 Value::Number(_) => "number",
86 Value::Object(_) => "mapping",
87 _ => "unknown",
88 };
89 anyhow::bail!(
90 "property 'tags' is a {kind} value, not a list — \
91 use `set --property` to overwrite it explicitly"
92 );
93 }
94 }
95
96 if let Some(Value::Array(seq)) = props.get_mut(KEY) {
97 let already = seq.iter().any(|v| match v {
98 Value::String(s) => s.eq_ignore_ascii_case(tag),
99 Value::Number(n) => n.to_string().eq_ignore_ascii_case(tag),
100 Value::Bool(b) => b.to_string().eq_ignore_ascii_case(tag),
101 _ => false,
102 });
103 if already {
104 return Ok(false);
105 }
106 seq.push(Value::String(tag.to_owned()));
107 Ok(true)
108 } else {
109 let existing_str = match props.get(KEY) {
111 Some(Value::String(s)) if !s.is_empty() => Some(s.clone()),
112 _ => None,
113 };
114
115 if let Some(ref s) = existing_str
117 && s.eq_ignore_ascii_case(tag)
118 {
119 return Ok(false);
120 }
121
122 let mut list: Vec<Value> = existing_str.map(Value::String).into_iter().collect();
123 list.push(Value::String(tag.to_owned()));
124 props.insert(KEY.to_owned(), Value::Array(list));
125 Ok(true)
126 }
127}
128
129#[allow(clippy::too_many_arguments)]
142pub fn set(
143 dir: &Path,
144 property_args: &[String],
145 tag_args: &[String],
146 files: &[String],
147 globs: &[String],
148 where_property_filters: &[PropertyFilter],
149 where_tag_filters: &[String],
150 format: Format,
151 snapshot_index: &mut Option<SnapshotIndex>,
152 index_path: Option<&Path>,
153 dry_run: bool,
154 validate: bool,
155 schema: Option<&SchemaConfig>,
156) -> Result<CommandOutcome> {
157 if property_args.is_empty() && tag_args.is_empty() {
159 let out = crate::output::format_error(
160 format,
161 "set requires at least one --property K=V or --tag T",
162 None,
163 Some("example: hyalo set --property status=completed --file note.md"),
164 None,
165 );
166 return Ok(CommandOutcome::UserError(out));
167 }
168
169 let has_where = !where_property_filters.is_empty() || !where_tag_filters.is_empty();
172 if !has_where && let Some(outcome) = require_file_or_glob(files, globs, "set", format) {
173 return Ok(outcome);
174 }
175
176 for arg in property_args {
178 match parse_kv(arg) {
179 Err(msg) => {
180 let out = crate::output::format_error(format, &msg, None, None, None);
181 return Ok(CommandOutcome::UserError(out));
182 }
183 Ok((key, _)) => {
184 if let Some(outcome) = super::reject_filter_in_mutation_property(key, format) {
185 return Ok(outcome);
186 }
187 }
188 }
189 }
190
191 for tag in tag_args {
193 if let Err(msg) = crate::commands::tags::validate_tag(tag) {
194 let out = crate::output::format_error(
195 format,
196 &msg,
197 None,
198 Some(
199 "tag names may contain letters, digits, _, -, / and must have at least one non-numeric character",
200 ),
201 None,
202 );
203 return Ok(CommandOutcome::UserError(out));
204 }
205 }
206
207 let parsed_props: Vec<(&str, &str, Value)> = {
210 let mut v = Vec::with_capacity(property_args.len());
211 for arg in property_args {
212 let (name, raw_value) =
213 parse_kv(arg).map_err(|e| anyhow::anyhow!("invalid property argument: {e}"))?;
214 let value = match frontmatter::parse_value(raw_value, None) {
215 Ok(val) => val,
216 Err(e) => {
217 let out = crate::output::format_error(
218 format,
219 &format!("failed to parse value for property '{name}': {e}"),
220 None,
221 None,
222 None,
223 );
224 return Ok(CommandOutcome::UserError(out));
225 }
226 };
227 v.push((name, raw_value, value));
228 }
229 v
230 };
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 if validate && let Some(schema) = schema {
251 for (full_path, rel_path) in &files {
252 let props = match frontmatter::read_frontmatter(full_path) {
253 Ok(p) => p,
254 Err(e) if frontmatter::is_parse_error(&e) => continue,
256 Err(e) => return Err(e),
257 };
258 if !filter::matches_frontmatter_filters(
259 &props,
260 where_property_filters,
261 where_tag_filters,
262 ) {
263 continue;
264 }
265 let mut merged = props.clone();
267 for (name, _, value) in &parsed_props {
268 merged.insert((*name).to_owned(), value.clone());
269 }
270 let doc_type = merged.get("type").and_then(|v| match v {
271 serde_json::Value::String(s) => Some(s.as_str()),
272 _ => None,
273 });
274 let effective_schema = match doc_type {
275 Some(t) => schema.merged_schema_for_type(t),
276 None => schema.default_schema().clone(),
277 };
278 for (name, raw_value, _) in &parsed_props {
279 if let Some(constraint) = effective_schema.properties.get(*name)
280 && let Some(merged_value) = merged.get(*name)
281 && let Some(violation) = crate::commands::lint::validate_constraint_simple(
282 name,
283 merged_value,
284 constraint,
285 )
286 {
287 let out = crate::output::format_error(
288 format,
289 &format!("{rel_path}: {violation}"),
290 None,
291 Some(&format!(
292 "rerun without --validate or fix the value (provided: {raw_value:?})"
293 )),
294 None,
295 );
296 return Ok(CommandOutcome::UserError(out));
297 }
298 }
299 }
300 }
301
302 let mut index_dirty = false;
303
304 for (full_path, rel_path) in &files {
306 let mtime = frontmatter::read_mtime(full_path)?;
307 let mut props = match frontmatter::read_frontmatter(full_path) {
308 Ok(p) => p,
309 Err(e) if frontmatter::is_parse_error(&e) => {
310 crate::warn::warn(format!("skipping {rel_path}: {e}"));
311 continue;
312 }
313 Err(e) => return Err(e),
314 };
315
316 if !filter::matches_frontmatter_filters(&props, where_property_filters, where_tag_filters) {
318 continue;
319 }
320
321 let mut file_changed = false;
322
323 for (i, (name, _, value)) in parsed_props.iter().enumerate() {
325 let already_same = props.get(*name) == Some(value);
326 if already_same {
327 prop_results[i].1.push(rel_path.clone()); } else {
329 props.insert((*name).to_owned(), value.clone());
330 prop_results[i].0.push(rel_path.clone()); file_changed = true;
332 }
333 }
334
335 for (i, tag) in tag_args.iter().enumerate() {
337 match add_tag_in_memory(&mut props, tag) {
338 Ok(true) => {
339 tag_results[i].0.push(rel_path.clone()); file_changed = true;
341 }
342 Ok(false) => {
343 tag_results[i].1.push(rel_path.clone()); }
345 Err(e) => return Err(e),
346 }
347 }
348
349 if file_changed && !dry_run {
350 frontmatter::check_mtime(full_path, mtime)?;
351 frontmatter::write_frontmatter(full_path, &props)?;
352 mutation::update_index_entry(
353 snapshot_index,
354 rel_path,
355 props,
356 full_path,
357 &mut index_dirty,
358 )?;
359 }
360 }
361
362 if !dry_run {
363 mutation::save_index_if_dirty(snapshot_index, index_path, index_dirty)?;
364 }
365
366 let mut results: Vec<serde_json::Value> = Vec::new();
367
368 for ((name, raw_value, _), (modified, skipped)) in parsed_props.iter().zip(prop_results) {
369 let total = modified.len() + skipped.len();
370 let result = SetPropertyResult {
371 property: (*name).to_owned(),
372 value: (*raw_value).to_owned(),
373 modified,
374 skipped,
375 total,
376 scanned,
377 dry_run,
378 };
379 results
380 .push(serde_json::to_value(&result).expect("derived Serialize impl should not fail"));
381 }
382
383 for (tag, (modified, skipped)) in tag_args.iter().zip(tag_results) {
384 let total = modified.len() + skipped.len();
385 let result = SetTagResult {
386 tag: tag.clone(),
387 modified,
388 skipped,
389 total,
390 scanned,
391 dry_run,
392 };
393 results
394 .push(serde_json::to_value(&result).expect("derived Serialize impl should not fail"));
395 }
396
397 let output = mutation::unwrap_single_result(results);
399
400 Ok(CommandOutcome::success(crate::output::format_success(
401 format, &output,
402 )))
403}
404
405#[cfg(test)]
410mod tests {
411 use super::*;
412 use std::fs;
413
414 macro_rules! md {
415 ($s:expr) => {
416 $s.strip_prefix('\n').unwrap_or($s)
417 };
418 }
419
420 #[test]
423 fn parse_kv_simple() {
424 assert_eq!(parse_kv("status=done").unwrap(), ("status", "done"));
425 }
426
427 #[test]
428 fn parse_kv_first_equals_only() {
429 assert_eq!(parse_kv("url=http://x=y").unwrap(), ("url", "http://x=y"));
431 }
432
433 #[test]
434 fn parse_kv_no_equals() {
435 assert!(parse_kv("nodot").is_err());
436 }
437
438 #[test]
439 fn parse_kv_empty_key_returns_error() {
440 let err = parse_kv("=value").unwrap_err();
441 assert!(
442 err.contains("property name cannot be empty"),
443 "unexpected error: {err}"
444 );
445 }
446
447 #[test]
448 fn parse_kv_empty_value() {
449 assert_eq!(parse_kv("key=").unwrap(), ("key", ""));
450 }
451
452 #[test]
455 fn set_property_creates_new() {
456 let tmp = tempfile::tempdir().unwrap();
457 fs::write(
458 tmp.path().join("note.md"),
459 md!(r"
460---
461title: Note
462---
463"),
464 )
465 .unwrap();
466
467 let outcome = set(
468 tmp.path(),
469 &["status=done".to_owned()],
470 &[],
471 &["note.md".to_owned()],
472 &[],
473 &[],
474 &[],
475 Format::Json,
476 &mut None,
477 None,
478 false,
479 false,
480 None,
481 )
482 .unwrap();
483 let out = match outcome {
484 CommandOutcome::Success { output: s, .. } | CommandOutcome::RawOutput(s) => s,
485 CommandOutcome::UserError(s) => panic!("unexpected error: {s}"),
486 };
487 let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
488 assert_eq!(parsed["property"], "status");
489 assert_eq!(parsed["value"], "done");
490 assert_eq!(parsed["modified"].as_array().unwrap().len(), 1);
491 assert_eq!(parsed["scanned"].as_u64().unwrap(), 1);
492 assert_eq!(parsed["scanned"], parsed["total"]);
493
494 let content = fs::read_to_string(tmp.path().join("note.md")).unwrap();
495 assert!(content.contains("status: done"));
496 }
497
498 #[test]
499 fn set_property_overwrites_existing() {
500 let tmp = tempfile::tempdir().unwrap();
501 fs::write(
502 tmp.path().join("note.md"),
503 md!(r"
504---
505status: draft
506---
507"),
508 )
509 .unwrap();
510
511 set(
512 tmp.path(),
513 &["status=published".to_owned()],
514 &[],
515 &["note.md".to_owned()],
516 &[],
517 &[],
518 &[],
519 Format::Json,
520 &mut None,
521 None,
522 false,
523 false,
524 None,
525 )
526 .unwrap();
527
528 let content = fs::read_to_string(tmp.path().join("note.md")).unwrap();
529 assert!(content.contains("status: published"));
530 assert!(!content.contains("draft"));
531 }
532
533 #[test]
534 fn set_property_skips_when_identical() {
535 let tmp = tempfile::tempdir().unwrap();
536 fs::write(
537 tmp.path().join("note.md"),
538 md!(r"
539---
540status: done
541---
542"),
543 )
544 .unwrap();
545
546 let outcome = set(
547 tmp.path(),
548 &["status=done".to_owned()],
549 &[],
550 &["note.md".to_owned()],
551 &[],
552 &[],
553 &[],
554 Format::Json,
555 &mut None,
556 None,
557 false,
558 false,
559 None,
560 )
561 .unwrap();
562 let CommandOutcome::Success { output: out, .. } = outcome else {
563 panic!("expected success")
564 };
565 let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
566 assert_eq!(parsed["modified"].as_array().unwrap().len(), 0);
567 assert_eq!(parsed["skipped"].as_array().unwrap().len(), 1);
568 assert_eq!(parsed["scanned"], parsed["total"]);
569 }
570
571 #[test]
572 fn set_tag_adds_tag() {
573 let tmp = tempfile::tempdir().unwrap();
574 fs::write(
575 tmp.path().join("note.md"),
576 md!(r"
577---
578title: Note
579---
580"),
581 )
582 .unwrap();
583
584 let outcome = set(
585 tmp.path(),
586 &[],
587 &["rust".to_owned()],
588 &["note.md".to_owned()],
589 &[],
590 &[],
591 &[],
592 Format::Json,
593 &mut None,
594 None,
595 false,
596 false,
597 None,
598 )
599 .unwrap();
600 let CommandOutcome::Success { output: out, .. } = outcome else {
601 panic!("expected success")
602 };
603 let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
604 assert_eq!(parsed["tag"], "rust");
605 assert_eq!(parsed["modified"].as_array().unwrap().len(), 1);
606
607 let content = fs::read_to_string(tmp.path().join("note.md")).unwrap();
608 assert!(content.contains("rust"));
609 }
610
611 #[test]
612 fn set_tag_idempotent() {
613 let tmp = tempfile::tempdir().unwrap();
614 fs::write(
615 tmp.path().join("note.md"),
616 md!(r"
617---
618tags:
619 - rust
620---
621"),
622 )
623 .unwrap();
624
625 let outcome = set(
626 tmp.path(),
627 &[],
628 &["rust".to_owned()],
629 &["note.md".to_owned()],
630 &[],
631 &[],
632 &[],
633 Format::Json,
634 &mut None,
635 None,
636 false,
637 false,
638 None,
639 )
640 .unwrap();
641 let CommandOutcome::Success { output: out, .. } = outcome else {
642 panic!("expected success")
643 };
644 let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
645 assert_eq!(parsed["skipped"].as_array().unwrap().len(), 1);
646 }
647
648 #[test]
649 fn set_multiple_mutations_returns_array() {
650 let tmp = tempfile::tempdir().unwrap();
651 fs::write(
652 tmp.path().join("note.md"),
653 md!(r"
654---
655title: Note
656---
657"),
658 )
659 .unwrap();
660
661 let outcome = set(
662 tmp.path(),
663 &["status=done".to_owned()],
664 &["rust".to_owned()],
665 &["note.md".to_owned()],
666 &[],
667 &[],
668 &[],
669 Format::Json,
670 &mut None,
671 None,
672 false,
673 false,
674 None,
675 )
676 .unwrap();
677 let CommandOutcome::Success { output: out, .. } = outcome else {
678 panic!("expected success")
679 };
680 let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
681 assert!(parsed.is_array(), "multiple mutations should return array");
682 assert_eq!(parsed.as_array().unwrap().len(), 2);
683 }
684
685 #[test]
686 fn set_requires_file_or_glob() {
687 let tmp = tempfile::tempdir().unwrap();
688 let outcome = set(
689 tmp.path(),
690 &["status=done".to_owned()],
691 &[],
692 &[],
693 &[],
694 &[],
695 &[],
696 Format::Json,
697 &mut None,
698 None,
699 false,
700 false,
701 None,
702 )
703 .unwrap();
704 assert!(matches!(outcome, CommandOutcome::UserError(_)));
705 }
706
707 #[test]
708 fn set_requires_at_least_one_arg() {
709 let tmp = tempfile::tempdir().unwrap();
710 let outcome = set(
711 tmp.path(),
712 &[],
713 &[],
714 &["note.md".to_owned()],
715 &[],
716 &[],
717 &[],
718 Format::Json,
719 &mut None,
720 None,
721 false,
722 false,
723 None,
724 )
725 .unwrap();
726 assert!(matches!(outcome, CommandOutcome::UserError(_)));
727 }
728
729 #[test]
730 fn set_invalid_kv_returns_user_error() {
731 let tmp = tempfile::tempdir().unwrap();
732 fs::write(tmp.path().join("note.md"), "---\ntitle: x\n---\n").unwrap();
733 let outcome = set(
734 tmp.path(),
735 &["no-equals-sign".to_owned()],
736 &[],
737 &["note.md".to_owned()],
738 &[],
739 &[],
740 &[],
741 Format::Json,
742 &mut None,
743 None,
744 false,
745 false,
746 None,
747 )
748 .unwrap();
749 assert!(matches!(outcome, CommandOutcome::UserError(_)));
750 }
751
752 #[test]
753 fn set_invalid_tag_returns_user_error() {
754 let tmp = tempfile::tempdir().unwrap();
755 fs::write(tmp.path().join("note.md"), "---\ntitle: x\n---\n").unwrap();
756 let outcome = set(
757 tmp.path(),
758 &[],
759 &["1984".to_owned()],
760 &["note.md".to_owned()],
761 &[],
762 &[],
763 &[],
764 Format::Json,
765 &mut None,
766 None,
767 false,
768 false,
769 None,
770 )
771 .unwrap();
772 assert!(matches!(outcome, CommandOutcome::UserError(_)));
773 }
774
775 #[test]
776 fn set_preserves_body() {
777 let tmp = tempfile::tempdir().unwrap();
778 let body = "# Heading\n\nSome content.\n";
779 fs::write(
780 tmp.path().join("note.md"),
781 format!("---\ntitle: Note\n---\n{body}"),
782 )
783 .unwrap();
784
785 set(
786 tmp.path(),
787 &["status=done".to_owned()],
788 &[],
789 &["note.md".to_owned()],
790 &[],
791 &[],
792 &[],
793 Format::Json,
794 &mut None,
795 None,
796 false,
797 false,
798 None,
799 )
800 .unwrap();
801
802 let content = fs::read_to_string(tmp.path().join("note.md")).unwrap();
803 assert!(content.contains(body), "body was corrupted:\n{content}");
804 }
805
806 #[test]
807 fn set_multiple_properties_single_read_write() {
808 let tmp = tempfile::tempdir().unwrap();
811 fs::write(
812 tmp.path().join("note.md"),
813 md!(r"
814---
815title: Note
816---
817"),
818 )
819 .unwrap();
820
821 let outcome = set(
822 tmp.path(),
823 &["status=done".to_owned(), "priority=high".to_owned()],
824 &[],
825 &["note.md".to_owned()],
826 &[],
827 &[],
828 &[],
829 Format::Json,
830 &mut None,
831 None,
832 false,
833 false,
834 None,
835 )
836 .unwrap();
837 let CommandOutcome::Success { output: out, .. } = outcome else {
838 panic!("expected success")
839 };
840 let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
841 assert!(parsed.is_array());
842 let arr = parsed.as_array().unwrap();
843 assert_eq!(arr.len(), 2);
844 assert_eq!(arr[0]["modified"].as_array().unwrap().len(), 1);
846 assert_eq!(arr[1]["modified"].as_array().unwrap().len(), 1);
847
848 let content = fs::read_to_string(tmp.path().join("note.md")).unwrap();
849 assert!(content.contains("status: done"));
850 assert!(content.contains("priority: high"));
851 }
852
853 #[test]
854 fn set_property_and_tag_single_read_write() {
855 let tmp = tempfile::tempdir().unwrap();
857 fs::write(
858 tmp.path().join("note.md"),
859 md!(r"
860---
861title: Note
862---
863"),
864 )
865 .unwrap();
866
867 let outcome = set(
868 tmp.path(),
869 &["status=done".to_owned()],
870 &["rust".to_owned()],
871 &["note.md".to_owned()],
872 &[],
873 &[],
874 &[],
875 Format::Json,
876 &mut None,
877 None,
878 false,
879 false,
880 None,
881 )
882 .unwrap();
883 let CommandOutcome::Success { output: out, .. } = outcome else {
884 panic!("expected success")
885 };
886 let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
887 assert!(parsed.is_array());
888
889 let content = fs::read_to_string(tmp.path().join("note.md")).unwrap();
890 assert!(content.contains("status: done"));
891 assert!(content.contains("rust"));
892 }
893
894 #[test]
895 fn set_where_property_filter_skips_nonmatching() {
896 use hyalo_core::filter::parse_property_filter;
897 let tmp = tempfile::tempdir().unwrap();
899 fs::write(tmp.path().join("match.md"), "---\nstatus: draft\n---\n").unwrap();
900 fs::write(
901 tmp.path().join("no-match.md"),
902 "---\nstatus: published\n---\n",
903 )
904 .unwrap();
905
906 let filter = parse_property_filter("status=draft").unwrap();
907 let outcome = set(
908 tmp.path(),
909 &["priority=high".to_owned()],
910 &[],
911 &[],
912 &["*.md".to_owned()],
913 &[filter],
914 &[],
915 Format::Json,
916 &mut None,
917 None,
918 false,
919 false,
920 None,
921 )
922 .unwrap();
923 let CommandOutcome::Success { output: out, .. } = outcome else {
924 panic!("expected success")
925 };
926 let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
927 assert_eq!(parsed["modified"].as_array().unwrap().len(), 1);
928 assert_eq!(parsed["skipped"].as_array().unwrap().len(), 0);
929 assert_eq!(parsed["scanned"].as_u64().unwrap(), 2);
931 assert!(parsed["scanned"].as_u64().unwrap() > parsed["total"].as_u64().unwrap());
932
933 let match_content = fs::read_to_string(tmp.path().join("match.md")).unwrap();
934 assert!(match_content.contains("priority: high"));
935 let no_match_content = fs::read_to_string(tmp.path().join("no-match.md")).unwrap();
936 assert!(!no_match_content.contains("priority"));
937 }
938
939 #[test]
940 fn set_where_tag_filter_skips_nonmatching() {
941 let tmp = tempfile::tempdir().unwrap();
943 fs::write(tmp.path().join("tagged.md"), "---\ntags:\n - rust\n---\n").unwrap();
944 fs::write(tmp.path().join("untagged.md"), "---\ntitle: Other\n---\n").unwrap();
945
946 let outcome = set(
947 tmp.path(),
948 &["status=reviewed".to_owned()],
949 &[],
950 &[],
951 &["*.md".to_owned()],
952 &[],
953 &["rust".to_owned()],
954 Format::Json,
955 &mut None,
956 None,
957 false,
958 false,
959 None,
960 )
961 .unwrap();
962 let CommandOutcome::Success { output: out, .. } = outcome else {
963 panic!("expected success")
964 };
965 let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
966 assert_eq!(parsed["modified"].as_array().unwrap().len(), 1);
967 assert_eq!(parsed["scanned"].as_u64().unwrap(), 2);
969 assert!(parsed["scanned"].as_u64().unwrap() > parsed["total"].as_u64().unwrap());
970
971 let tagged_content = fs::read_to_string(tmp.path().join("tagged.md")).unwrap();
972 assert!(tagged_content.contains("status: reviewed"));
973 let untagged_content = fs::read_to_string(tmp.path().join("untagged.md")).unwrap();
974 assert!(!untagged_content.contains("status"));
975 }
976
977 #[test]
980 fn set_rejects_gte_filter_in_property() {
981 let tmp = tempfile::tempdir().unwrap();
982 fs::write(tmp.path().join("note.md"), "---\ntitle: x\n---\n").unwrap();
983 let outcome = set(
984 tmp.path(),
985 &["priority>=3".to_owned()],
986 &[],
987 &["note.md".to_owned()],
988 &[],
989 &[],
990 &[],
991 Format::Json,
992 &mut None,
993 None,
994 false,
995 false,
996 None,
997 )
998 .unwrap();
999 match outcome {
1000 CommandOutcome::UserError(msg) => {
1001 assert!(msg.contains("--where-property"), "msg: {msg}");
1002 }
1003 other => panic!("expected UserError, got: {other:?}"),
1004 }
1005 }
1006
1007 #[test]
1008 fn set_rejects_lte_filter_in_property() {
1009 let tmp = tempfile::tempdir().unwrap();
1010 fs::write(tmp.path().join("note.md"), "---\ntitle: x\n---\n").unwrap();
1011 let outcome = set(
1012 tmp.path(),
1013 &["priority<=3".to_owned()],
1014 &[],
1015 &["note.md".to_owned()],
1016 &[],
1017 &[],
1018 &[],
1019 Format::Json,
1020 &mut None,
1021 None,
1022 false,
1023 false,
1024 None,
1025 )
1026 .unwrap();
1027 assert!(matches!(outcome, CommandOutcome::UserError(_)));
1028 }
1029
1030 #[test]
1031 fn set_rejects_neq_filter_in_property() {
1032 let tmp = tempfile::tempdir().unwrap();
1033 fs::write(tmp.path().join("note.md"), "---\ntitle: x\n---\n").unwrap();
1034 let outcome = set(
1035 tmp.path(),
1036 &["status!=draft".to_owned()],
1037 &[],
1038 &["note.md".to_owned()],
1039 &[],
1040 &[],
1041 &[],
1042 Format::Json,
1043 &mut None,
1044 None,
1045 false,
1046 false,
1047 None,
1048 )
1049 .unwrap();
1050 assert!(matches!(outcome, CommandOutcome::UserError(_)));
1051 }
1052
1053 #[test]
1054 fn set_rejects_regex_filter_in_property() {
1055 let tmp = tempfile::tempdir().unwrap();
1056 fs::write(tmp.path().join("note.md"), "---\ntitle: x\n---\n").unwrap();
1057 let outcome = set(
1058 tmp.path(),
1059 &["name~=pattern".to_owned()],
1060 &[],
1061 &["note.md".to_owned()],
1062 &[],
1063 &[],
1064 &[],
1065 Format::Json,
1066 &mut None,
1067 None,
1068 false,
1069 false,
1070 None,
1071 )
1072 .unwrap();
1073 assert!(matches!(outcome, CommandOutcome::UserError(_)));
1074 }
1075
1076 #[test]
1081 fn set_validate_rejects_invalid_enum_value() {
1082 use hyalo_core::schema::{PropertyConstraint, SchemaConfig, TypeSchema};
1083 use std::collections::HashMap;
1084
1085 let tmp = tempfile::tempdir().unwrap();
1086 fs::write(
1087 tmp.path().join("note.md"),
1088 md!(r"
1089---
1090title: My Note
1091type: post
1092---
1093"),
1094 )
1095 .unwrap();
1096
1097 let mut type_props = HashMap::new();
1099 type_props.insert(
1100 "status".to_owned(),
1101 PropertyConstraint::Enum {
1102 values: vec!["draft".to_owned(), "published".to_owned()],
1103 },
1104 );
1105 let schema = SchemaConfig {
1106 default: TypeSchema::default(),
1107 types: {
1108 let mut m = HashMap::new();
1109 m.insert(
1110 "post".to_owned(),
1111 TypeSchema {
1112 required: vec![],
1113 properties: type_props,
1114 filename_template: None,
1115 defaults: HashMap::new(),
1116 },
1117 );
1118 m
1119 },
1120 };
1121
1122 let outcome = set(
1123 tmp.path(),
1124 &["status=archived".to_owned()], &[],
1126 &["note.md".to_owned()],
1127 &[],
1128 &[],
1129 &[],
1130 Format::Json,
1131 &mut None,
1132 None,
1133 false,
1134 true, Some(&schema),
1136 )
1137 .unwrap();
1138 assert!(
1139 matches!(outcome, CommandOutcome::UserError(_)),
1140 "expected UserError for invalid enum value"
1141 );
1142 }
1143
1144 #[test]
1145 fn set_validate_accepts_valid_enum_value() {
1146 use hyalo_core::schema::{PropertyConstraint, SchemaConfig, TypeSchema};
1147 use std::collections::HashMap;
1148
1149 let tmp = tempfile::tempdir().unwrap();
1150 fs::write(
1151 tmp.path().join("note.md"),
1152 md!(r"
1153---
1154title: My Note
1155type: post
1156---
1157"),
1158 )
1159 .unwrap();
1160
1161 let mut type_props = HashMap::new();
1162 type_props.insert(
1163 "status".to_owned(),
1164 PropertyConstraint::Enum {
1165 values: vec!["draft".to_owned(), "published".to_owned()],
1166 },
1167 );
1168 let schema = SchemaConfig {
1169 default: TypeSchema::default(),
1170 types: {
1171 let mut m = HashMap::new();
1172 m.insert(
1173 "post".to_owned(),
1174 TypeSchema {
1175 required: vec![],
1176 properties: type_props,
1177 filename_template: None,
1178 defaults: HashMap::new(),
1179 },
1180 );
1181 m
1182 },
1183 };
1184
1185 let outcome = set(
1186 tmp.path(),
1187 &["status=published".to_owned()], &[],
1189 &["note.md".to_owned()],
1190 &[],
1191 &[],
1192 &[],
1193 Format::Json,
1194 &mut None,
1195 None,
1196 false,
1197 true, Some(&schema),
1199 )
1200 .unwrap();
1201 assert!(
1202 matches!(outcome, CommandOutcome::Success { .. }),
1203 "expected success for valid enum value"
1204 );
1205 }
1206}