1use chrono::{DateTime, FixedOffset};
14use serde_json::Value;
15
16use crate::index::IndexRecord;
17use crate::store::{Layer, Store, StoreError};
18
19#[derive(Debug, Clone, Default)]
25pub struct Query {
26 type_: Option<String>,
28 layer: Option<Layer>,
30 wheres: Vec<(String, String)>,
32}
33
34impl Query {
35 pub fn new() -> Self {
37 Self::default()
38 }
39
40 pub fn with_type(mut self, type_: &str) -> Self {
46 self.type_ = Some(type_.to_string());
47 self
48 }
49
50 pub fn with_layer(mut self, layer: Layer) -> Self {
53 self.layer = Some(layer);
54 self
55 }
56
57 pub fn with_where(mut self, key: &str, value: &str) -> Self {
61 self.wheres.push((key.to_string(), value.to_string()));
62 self
63 }
64
65 pub fn execute(&self, store: &Store) -> Result<Vec<IndexRecord>, StoreError> {
96 let (candidates, type_done, where_done) = if self.type_.is_some() {
100 (store.sidecar_records(self.layer)?, false, 0)
109 } else if let Some((key, value)) = self.wheres.first() {
110 (store.find_by_where_in(key, value, self.layer)?, false, 1)
117 } else if let Some(layer) = self.layer {
118 (store.sidecar_records(Some(layer))?, false, 0)
122 } else {
123 return Ok(Vec::new());
125 };
126
127 Ok(self.filter_candidates(candidates, type_done, where_done))
128 }
129
130 fn filter_candidates(
142 &self,
143 candidates: Vec<IndexRecord>,
144 type_already_applied: bool,
145 wheres_already_applied: usize,
146 ) -> Vec<IndexRecord> {
147 candidates
148 .into_iter()
149 .filter(|record| {
150 if !type_already_applied {
151 if let Some(type_) = &self.type_ {
152 if record.type_ != *type_ {
153 return false;
154 }
155 }
156 }
157 if let Some(layer) = self.layer {
158 if !record_in_layer(record, layer) {
159 return false;
160 }
161 }
162 self.wheres
163 .iter()
164 .skip(wheres_already_applied)
165 .all(|(key, value)| record_matches_where(record, key, value))
166 })
167 .collect()
168 }
169}
170
171fn record_in_layer(record: &IndexRecord, layer: Layer) -> bool {
176 record
177 .path
178 .components()
179 .next()
180 .and_then(|c| c.as_os_str().to_str())
181 == Some(layer_dir_name(layer))
182}
183
184fn layer_dir_name(layer: Layer) -> &'static str {
188 match layer {
189 Layer::Sources => "sources",
190 Layer::Records => "records",
191 }
192}
193
194fn record_matches_where(record: &IndexRecord, key: &str, value: &str) -> bool {
202 match key {
203 "type" => record.type_ == value,
204 "summary" => record.summary == value,
205 "path" => record.path.to_str() == Some(value),
206 "tags" => record.tags.iter().any(|t| t == value),
209 "links" => record.links.iter().any(|l| l == value),
210 "created" => timestamp_value_matches(record.created, value),
216 "updated" => timestamp_value_matches(record.updated, value),
217 _ => record
218 .fields
219 .get(key)
220 .is_some_and(|v| json_value_matches(v, value)),
221 }
222}
223
224fn json_value_matches(value: &Value, target: &str) -> bool {
236 match value {
237 Value::String(s) => s == target,
238 Value::Number(n) => n.to_string() == target,
239 Value::Bool(b) => b.to_string() == target,
240 Value::Array(items) => items.iter().any(|item| json_value_matches(item, target)),
241 Value::Null => false,
242 Value::Object(_) => false,
244 }
245}
246
247fn timestamp_value_matches(stored: Option<DateTime<FixedOffset>>, value: &str) -> bool {
253 match (stored, DateTime::parse_from_rfc3339(value)) {
254 (Some(stored), Ok(queried)) => stored == queried,
255 _ => false,
256 }
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262 use crate::store::Store;
263 use std::fs;
264 use std::path::PathBuf;
265 use tempfile::TempDir;
266
267 fn rec(path: &str, type_: &str, fields: &[(&str, Value)]) -> IndexRecord {
273 IndexRecord {
274 path: PathBuf::from(path),
275 type_: type_.to_string(),
276 summary: format!("summary of {path}"),
277 tags: Vec::new(),
278 links: Vec::new(),
279 created: None,
280 updated: None,
281 fields: fields
282 .iter()
283 .map(|(k, v)| (k.to_string(), v.clone()))
284 .collect(),
285 }
286 }
287
288 fn jsonl_line(record: &IndexRecord) -> String {
290 serde_json::to_string(record).expect("serialize IndexRecord")
291 }
292
293 const DB_MD: &str = "---\ntype: db-md\n---\n\n# Test store\n";
296
297 fn store_with_sidecars(sidecars: &[(&str, &[IndexRecord])]) -> (TempDir, Store) {
301 let dir = TempDir::new().expect("temp dir");
302 let root = dir.path();
303 fs::write(root.join("DB.md"), DB_MD).expect("write DB.md");
304
305 for (folder, records) in sidecars {
306 let folder_abs = root.join(folder);
307 fs::create_dir_all(&folder_abs).expect("create type folder");
308 let body: String = records
309 .iter()
310 .map(|r| format!("{}\n", jsonl_line(r)))
311 .collect();
312 fs::write(folder_abs.join("index.jsonl"), body).expect("write index.jsonl");
313 }
314
315 let store = Store::open(root).expect("open store");
316 (dir, store)
317 }
318
319 fn paths(records: &[IndexRecord]) -> std::collections::BTreeSet<String> {
322 records
323 .iter()
324 .map(|r| r.path.to_string_lossy().into_owned())
325 .collect()
326 }
327
328 fn path_set(items: &[&str]) -> std::collections::BTreeSet<String> {
329 items.iter().map(|s| s.to_string()).collect()
330 }
331
332 #[test]
335 fn builder_accumulates_predicates() {
336 let q = Query::new()
337 .with_type("contact")
338 .with_layer(Layer::Records)
339 .with_where("company", "acme")
340 .with_where("status", "active");
341
342 assert_eq!(q.type_.as_deref(), Some("contact"));
343 assert_eq!(q.layer, Some(Layer::Records));
344 assert_eq!(
345 q.wheres,
346 vec![
347 ("company".to_string(), "acme".to_string()),
348 ("status".to_string(), "active".to_string()),
349 ],
350 "each with_where appends a distinct clause"
351 );
352 }
353
354 #[test]
355 fn with_type_and_with_layer_replace_rather_than_stack() {
356 let q = Query::new()
357 .with_type("contact")
358 .with_type("company")
359 .with_layer(Layer::Sources)
360 .with_layer(Layer::Records);
361 assert_eq!(q.type_.as_deref(), Some("company"));
362 assert_eq!(q.layer, Some(Layer::Records));
363 }
364
365 #[test]
366 fn repeated_with_where_same_key_keeps_both_clauses() {
367 let q = Query::new()
370 .with_where("updated", "2026-01-01T00:00:00+00:00")
371 .with_where("updated", "2026-02-01T00:00:00+00:00");
372 assert_eq!(q.wheres.len(), 2);
373 }
374
375 #[test]
378 fn execute_with_type_returns_only_that_types_folder() {
379 let contacts = [
380 rec("records/contacts/sarah.md", "contact", &[]),
381 rec("records/contacts/mara.md", "contact", &[]),
382 ];
383 let companies = [rec("records/companies/acme.md", "company", &[])];
384 let (_dir, store) = store_with_sidecars(&[
385 ("records/contacts", &contacts),
386 ("records/companies", &companies),
387 ]);
388
389 let got = Query::new().with_type("contact").execute(&store).unwrap();
390
391 assert_eq!(
392 paths(&got),
393 path_set(&["records/contacts/sarah.md", "records/contacts/mara.md"]),
394 "a type query reads its own type-folder sidecar and excludes other types"
395 );
396 }
397
398 #[test]
399 fn execute_type_plus_where_intersects_on_a_custom_field() {
400 let contacts = [
401 rec(
402 "records/contacts/sarah.md",
403 "contact",
404 &[("company", Value::String("acme".into()))],
405 ),
406 rec(
407 "records/contacts/mara.md",
408 "contact",
409 &[("company", Value::String("globex".into()))],
410 ),
411 rec("records/contacts/no-company.md", "contact", &[]),
412 ];
413 let (_dir, store) = store_with_sidecars(&[("records/contacts", &contacts)]);
414
415 let got = Query::new()
416 .with_type("contact")
417 .with_where("company", "acme")
418 .execute(&store)
419 .unwrap();
420
421 assert_eq!(
422 paths(&got),
423 path_set(&["records/contacts/sarah.md"]),
424 "the where clause narrows the type's records to the matching field; \
425 a record missing the key does not match"
426 );
427 }
428
429 #[test]
430 fn execute_multiple_where_clauses_and_together() {
431 let contacts = [
432 rec(
433 "records/contacts/a.md",
434 "contact",
435 &[
436 ("company", Value::String("acme".into())),
437 ("status", Value::String("active".into())),
438 ],
439 ),
440 rec(
441 "records/contacts/b.md",
442 "contact",
443 &[
444 ("company", Value::String("acme".into())),
445 ("status", Value::String("churned".into())),
446 ],
447 ),
448 rec(
449 "records/contacts/c.md",
450 "contact",
451 &[
452 ("company", Value::String("globex".into())),
453 ("status", Value::String("active".into())),
454 ],
455 ),
456 ];
457 let (_dir, store) = store_with_sidecars(&[("records/contacts", &contacts)]);
458
459 let got = Query::new()
460 .with_type("contact")
461 .with_where("company", "acme")
462 .with_where("status", "active")
463 .execute(&store)
464 .unwrap();
465
466 assert_eq!(paths(&got), path_set(&["records/contacts/a.md"]));
469 }
470
471 #[test]
472 fn execute_where_without_type_reads_across_sidecars() {
473 let contacts = [rec(
476 "records/contacts/sarah.md",
477 "contact",
478 &[("domain", Value::String("acme.com".into()))],
479 )];
480 let companies = [
481 rec(
482 "records/companies/acme.md",
483 "company",
484 &[("domain", Value::String("acme.com".into()))],
485 ),
486 rec(
487 "records/companies/globex.md",
488 "company",
489 &[("domain", Value::String("globex.com".into()))],
490 ),
491 ];
492 let (_dir, store) = store_with_sidecars(&[
493 ("records/contacts", &contacts),
494 ("records/companies", &companies),
495 ]);
496
497 let got = Query::new()
498 .with_where("domain", "acme.com")
499 .execute(&store)
500 .unwrap();
501
502 assert_eq!(
503 paths(&got),
504 path_set(&["records/contacts/sarah.md", "records/companies/acme.md"]),
505 "a where-only query matches the field across every type-folder sidecar"
506 );
507 }
508
509 #[test]
510 fn execute_with_layer_scopes_by_path() {
511 let source_recs = [rec(
514 "sources/notes/n1.md",
515 "note",
516 &[("topic", Value::String("billing".into()))],
517 )];
518 let record_recs = [rec(
519 "records/notes/n2.md",
520 "note",
521 &[("topic", Value::String("billing".into()))],
522 )];
523 let (_dir, store) = store_with_sidecars(&[
524 ("sources/notes", &source_recs),
525 ("records/notes", &record_recs),
526 ]);
527
528 let unscoped = Query::new()
530 .with_where("topic", "billing")
531 .execute(&store)
532 .unwrap();
533 assert_eq!(
534 paths(&unscoped),
535 path_set(&["sources/notes/n1.md", "records/notes/n2.md"]),
536 );
537
538 let scoped = Query::new()
540 .with_where("topic", "billing")
541 .with_layer(Layer::Sources)
542 .execute(&store)
543 .unwrap();
544 assert_eq!(
545 paths(&scoped),
546 path_set(&["sources/notes/n1.md"]),
547 "with_layer(Sources) drops the records/-layer record"
548 );
549 }
550
551 #[test]
552 fn execute_where_only_with_layer_confines_sidecar_io_not_just_result() {
553 let dir = TempDir::new().unwrap();
560 let root = dir.path();
561 fs::write(root.join("DB.md"), DB_MD).unwrap();
562
563 let records_dir = root.join("records/contacts");
565 fs::create_dir_all(&records_dir).unwrap();
566 let match_rec = rec(
567 "records/contacts/sarah.md",
568 "contact",
569 &[("domain", Value::String("acme.com".into()))],
570 );
571 fs::write(
572 records_dir.join("index.jsonl"),
573 format!("{}\n", jsonl_line(&match_rec)),
574 )
575 .unwrap();
576
577 let sources_dir = root.join("sources/emails");
580 fs::create_dir_all(&sources_dir).unwrap();
581 fs::write(sources_dir.join("index.jsonl"), "{ not valid json }\n").unwrap();
582
583 let store = Store::open(root).unwrap();
584
585 let scoped = Query::new()
588 .with_where("domain", "acme.com")
589 .with_layer(Layer::Records)
590 .execute(&store)
591 .expect("a records-scoped where query must not read the sources sidecar");
592 assert_eq!(paths(&scoped), path_set(&["records/contacts/sarah.md"]));
593
594 let unscoped = Query::new()
598 .with_where("domain", "acme.com")
599 .execute(&store);
600 assert!(
601 unscoped.is_err(),
602 "an unscoped where query reads every sidecar, including the corrupt one"
603 );
604 }
605
606 #[test]
607 fn execute_full_composition_type_layer_where() {
608 let contacts = [
609 rec(
610 "records/contacts/match.md",
611 "contact",
612 &[("city", Value::String("denver".into()))],
613 ),
614 rec(
615 "records/contacts/wrong-city.md",
616 "contact",
617 &[("city", Value::String("austin".into()))],
618 ),
619 ];
620 let (_dir, store) = store_with_sidecars(&[("records/contacts", &contacts)]);
621
622 let got = Query::new()
623 .with_type("contact")
624 .with_layer(Layer::Records)
625 .with_where("city", "denver")
626 .execute(&store)
627 .unwrap();
628 assert_eq!(paths(&got), path_set(&["records/contacts/match.md"]));
629
630 let wrong_layer = Query::new()
633 .with_type("contact")
634 .with_layer(Layer::Sources)
635 .with_where("city", "denver")
636 .execute(&store)
637 .unwrap();
638 assert!(wrong_layer.is_empty());
639 }
640
641 #[test]
642 fn execute_bare_query_selects_no_sidecar() {
643 let contacts = [rec("records/contacts/sarah.md", "contact", &[])];
647 let (_dir, store) = store_with_sidecars(&[("records/contacts", &contacts)]);
648
649 let got = Query::new().execute(&store).unwrap();
650 assert!(
651 got.is_empty(),
652 "an unconstrained query resolves to empty, not to every record"
653 );
654 }
655
656 #[test]
657 fn execute_layer_only_enumerates_that_layer() {
658 let contacts = [rec("records/contacts/sarah.md", "contact", &[])];
662 let emails = [rec("sources/emails/e.md", "email", &[])];
663 let (_dir, store) =
664 store_with_sidecars(&[("records/contacts", &contacts), ("sources/emails", &emails)]);
665
666 let records = Query::new()
667 .with_layer(Layer::Records)
668 .execute(&store)
669 .unwrap();
670 assert_eq!(
671 paths(&records),
672 path_set(&["records/contacts/sarah.md"]),
673 "a layer-only query enumerates that layer, excluding other layers"
674 );
675
676 let sources = Query::new()
677 .with_layer(Layer::Sources)
678 .execute(&store)
679 .unwrap();
680 assert_eq!(
681 paths(&sources),
682 path_set(&["sources/emails/e.md"]),
683 "the sources-layer scope returns the sources records"
684 );
685 }
686
687 #[test]
688 fn execute_type_finds_records_filed_outside_canonical_layer() {
689 let source_contacts = [rec("sources/foo/jane.md", "contact", &[])];
695 let record_contacts = [rec("records/contacts/sarah.md", "contact", &[])];
696 let screenshots = [rec("sources/screenshots/shot1.md", "screenshot", &[])];
697 let (_dir, store) = store_with_sidecars(&[
698 ("sources/foo", &source_contacts),
699 ("records/contacts", &record_contacts),
700 ("sources/screenshots", &screenshots),
701 ]);
702
703 let contacts = Query::new().with_type("contact").execute(&store).unwrap();
706 assert_eq!(
707 paths(&contacts),
708 path_set(&["records/contacts/sarah.md", "sources/foo/jane.md"]),
709 "a type query spans every layer the type is filed under"
710 );
711
712 let shots = Query::new()
714 .with_type("screenshot")
715 .execute(&store)
716 .unwrap();
717 assert_eq!(
718 paths(&shots),
719 path_set(&["sources/screenshots/shot1.md"]),
720 "a type filed entirely under sources/ is visible to --type"
721 );
722
723 let in_sources = Query::new()
726 .with_type("contact")
727 .with_layer(Layer::Sources)
728 .execute(&store)
729 .unwrap();
730 assert_eq!(
731 paths(&in_sources),
732 path_set(&["sources/foo/jane.md"]),
733 "--type X --in <layer> returns the records of that type under the layer"
734 );
735
736 let in_records = Query::new()
738 .with_type("contact")
739 .with_layer(Layer::Records)
740 .execute(&store)
741 .unwrap();
742 assert_eq!(
743 paths(&in_records),
744 path_set(&["records/contacts/sarah.md"]),
745 "the layer scope confines a type query to the named layer"
746 );
747 }
748
749 #[test]
750 fn execute_tag_membership_via_where() {
751 let mut urgent = rec("records/tasks/t1.md", "task", &[]);
752 urgent.tags = vec!["urgent".into(), "ops".into()];
753 let mut calm = rec("records/tasks/t2.md", "task", &[]);
754 calm.tags = vec!["ops".into()];
755 let recs = [urgent, calm];
756 let (_dir, store) = store_with_sidecars(&[("records/tasks", &recs)]);
757
758 let got = Query::new()
759 .with_type("task")
760 .with_where("tags", "urgent")
761 .execute(&store)
762 .unwrap();
763 assert_eq!(
764 paths(&got),
765 path_set(&["records/tasks/t1.md"]),
766 "tags match on membership: only the record carrying the tag matches"
767 );
768 }
769
770 #[test]
771 fn execute_matches_numeric_and_bool_fields_from_string_predicate() {
772 let recs = [
773 rec(
774 "records/invoices/paid.md",
775 "invoice",
776 &[
777 ("amount", Value::Number(42.into())),
778 ("paid", Value::Bool(true)),
779 ],
780 ),
781 rec(
782 "records/invoices/unpaid.md",
783 "invoice",
784 &[
785 ("amount", Value::Number(99.into())),
786 ("paid", Value::Bool(false)),
787 ],
788 ),
789 ];
790 let (_dir, store) = store_with_sidecars(&[("records/invoices", &recs)]);
791
792 let by_amount = Query::new()
793 .with_type("invoice")
794 .with_where("amount", "42")
795 .execute(&store)
796 .unwrap();
797 assert_eq!(
798 paths(&by_amount),
799 path_set(&["records/invoices/paid.md"]),
800 "a JSON number matches the string form of the predicate"
801 );
802
803 let by_paid = Query::new()
804 .with_type("invoice")
805 .with_where("paid", "true")
806 .execute(&store)
807 .unwrap();
808 assert_eq!(
809 paths(&by_paid),
810 path_set(&["records/invoices/paid.md"]),
811 "a JSON bool matches \"true\"/\"false\""
812 );
813 }
814
815 #[test]
816 fn execute_honors_last_write_wins_in_sidecar() {
817 let dir = TempDir::new().unwrap();
821 let root = dir.path();
822 fs::write(root.join("DB.md"), DB_MD).unwrap();
823 let folder = root.join("records/contacts");
824 fs::create_dir_all(&folder).unwrap();
825
826 let old = rec(
827 "records/contacts/sarah.md",
828 "contact",
829 &[("status", Value::String("lead".into()))],
830 );
831 let new = rec(
832 "records/contacts/sarah.md",
833 "contact",
834 &[("status", Value::String("customer".into()))],
835 );
836 fs::write(
837 folder.join("index.jsonl"),
838 format!("{}\n{}\n", jsonl_line(&old), jsonl_line(&new)),
839 )
840 .unwrap();
841 let store = Store::open(root).unwrap();
842
843 let superseding = Query::new()
844 .with_type("contact")
845 .with_where("status", "customer")
846 .execute(&store)
847 .unwrap();
848 assert_eq!(superseding.len(), 1, "the superseding line's value matches");
849
850 let superseded = Query::new()
851 .with_type("contact")
852 .with_where("status", "lead")
853 .execute(&store)
854 .unwrap();
855 assert!(
856 superseded.is_empty(),
857 "the superseded line's value no longer matches after last-write-wins"
858 );
859 }
860
861 #[test]
862 fn execute_returns_full_records_not_just_paths() {
863 let mut r = rec(
866 "records/contacts/sarah.md",
867 "contact",
868 &[("company", Value::String("acme".into()))],
869 );
870 r.summary = "Renewal champion".into();
871 r.tags = vec!["vip".into()];
872 r.links = vec!["wiki/people/sarah-chen.md".into()];
873 let recs = [r];
874 let (_dir, store) = store_with_sidecars(&[("records/contacts", &recs)]);
875
876 let got = Query::new().with_type("contact").execute(&store).unwrap();
877 assert_eq!(got.len(), 1);
878 let only = &got[0];
879 assert_eq!(only.summary, "Renewal champion");
880 assert_eq!(only.tags, vec!["vip".to_string()]);
881 assert_eq!(only.links, vec!["wiki/people/sarah-chen.md".to_string()]);
882 assert_eq!(
883 only.fields.get("company"),
884 Some(&Value::String("acme".into())),
885 "type-specific fields come back verbatim for on-demand use"
886 );
887 }
888
889 #[test]
892 fn record_matches_where_on_typed_columns() {
893 let mut r = rec("records/contacts/x.md", "contact", &[]);
894 r.summary = "hello".into();
895
896 assert!(record_matches_where(&r, "type", "contact"));
897 assert!(!record_matches_where(&r, "type", "company"));
898 assert!(record_matches_where(&r, "summary", "hello"));
899 assert!(!record_matches_where(&r, "summary", "goodbye"));
900 assert!(record_matches_where(&r, "path", "records/contacts/x.md"));
901 assert!(!record_matches_where(&r, "path", "records/contacts/y.md"));
902 }
903
904 #[test]
905 fn record_matches_where_on_timestamps_uses_rfc3339() {
906 let mut r = rec("records/meetings/m.md", "meeting", &[]);
907 let ts = chrono::DateTime::parse_from_rfc3339("2026-05-29T12:00:00+00:00").unwrap();
908 r.created = Some(ts);
909
910 assert!(record_matches_where(
911 &r,
912 "created",
913 "2026-05-29T12:00:00+00:00"
914 ));
915 assert!(!record_matches_where(
916 &r,
917 "created",
918 "2026-05-29T13:00:00+00:00"
919 ));
920 assert!(!record_matches_where(
922 &r,
923 "updated",
924 "2026-05-29T12:00:00+00:00"
925 ));
926 }
927
928 #[test]
929 fn record_matches_where_timestamp_z_and_offset_spellings_are_equal() {
930 let mut stored_z = rec("records/meetings/m.md", "meeting", &[]);
936 stored_z.created =
937 Some(chrono::DateTime::parse_from_rfc3339("2026-05-29T12:00:00Z").unwrap());
938 assert!(record_matches_where(
939 &stored_z,
940 "created",
941 "2026-05-29T12:00:00Z"
942 ));
943 assert!(record_matches_where(
944 &stored_z,
945 "created",
946 "2026-05-29T12:00:00+00:00"
947 ));
948
949 let mut stored_offset = rec("records/meetings/n.md", "meeting", &[]);
952 stored_offset.created =
953 Some(chrono::DateTime::parse_from_rfc3339("2026-05-29T12:00:00+00:00").unwrap());
954 assert!(record_matches_where(
955 &stored_offset,
956 "created",
957 "2026-05-29T12:00:00Z"
958 ));
959
960 assert!(!record_matches_where(
962 &stored_z,
963 "created",
964 "2026-05-29T13:00:00Z"
965 ));
966 assert!(!record_matches_where(
967 &stored_z,
968 "created",
969 "not-a-timestamp"
970 ));
971 }
972
973 #[test]
974 fn record_matches_where_absent_field_is_false() {
975 let r = rec("records/contacts/x.md", "contact", &[]);
976 assert!(
977 !record_matches_where(&r, "nonexistent", "anything"),
978 "an absent frontmatter key never matches"
979 );
980 }
981
982 #[test]
983 fn json_value_matches_covers_scalars_and_arrays() {
984 assert!(json_value_matches(&Value::String("acme".into()), "acme"));
985 assert!(!json_value_matches(&Value::String("acme".into()), "globex"));
986
987 assert!(json_value_matches(&Value::Number(42.into()), "42"));
988 assert!(!json_value_matches(&Value::Number(42.into()), "43"));
989
990 assert!(json_value_matches(&Value::Bool(true), "true"));
991 assert!(json_value_matches(&Value::Bool(false), "false"));
992 assert!(!json_value_matches(&Value::Bool(true), "false"));
993
994 let arr = Value::Array(vec![Value::String("a".into()), Value::String("b".into())]);
995 assert!(json_value_matches(&arr, "b"), "array matches on membership");
996 assert!(!json_value_matches(&arr, "c"));
997 }
998
999 #[test]
1000 fn json_value_matches_null_and_object_never_match() {
1001 assert!(!json_value_matches(&Value::Null, ""));
1002 assert!(!json_value_matches(&Value::Null, "null"));
1003 let obj = serde_json::json!({"k": "v"});
1004 assert!(!json_value_matches(&obj, "v"));
1005 }
1006
1007 #[test]
1008 fn record_in_layer_keys_off_first_path_component() {
1009 let s = rec("sources/emails/e.md", "email", &[]);
1010 let r = rec("records/contacts/c.md", "contact", &[]);
1011 let c = rec("records/profiles/p.md", "profile", &[]);
1013
1014 assert!(record_in_layer(&s, Layer::Sources));
1015 assert!(!record_in_layer(&s, Layer::Records));
1016 assert!(record_in_layer(&r, Layer::Records));
1017 assert!(!record_in_layer(&r, Layer::Sources));
1018 assert!(record_in_layer(&c, Layer::Records));
1019 assert!(!record_in_layer(&c, Layer::Sources));
1020 }
1021
1022 #[test]
1023 fn filter_candidates_skips_already_applied_where_clause() {
1024 let q = Query::new()
1029 .with_where("company", "acme")
1030 .with_where("status", "active");
1031
1032 let keep = rec(
1033 "records/contacts/keep.md",
1034 "contact",
1035 &[
1036 ("company", Value::String("acme".into())),
1037 ("status", Value::String("active".into())),
1038 ],
1039 );
1040 let drop = rec(
1041 "records/contacts/drop.md",
1042 "contact",
1043 &[
1044 ("company", Value::String("acme".into())),
1045 ("status", Value::String("churned".into())),
1046 ],
1047 );
1048
1049 let out = q.filter_candidates(vec![keep, drop], false, 1);
1050 assert_eq!(
1051 paths(&out),
1052 path_set(&["records/contacts/keep.md"]),
1053 "the second clause is enforced even when the first is pre-applied"
1054 );
1055 }
1056
1057 #[test]
1058 fn filter_candidates_enforces_type_when_not_preapplied() {
1059 let q = Query::new().with_type("contact");
1062 let contact = rec("records/contacts/c.md", "contact", &[]);
1063 let company = rec("records/companies/co.md", "company", &[]);
1064
1065 let out = q.filter_candidates(vec![contact, company], false, 0);
1066 assert_eq!(paths(&out), path_set(&["records/contacts/c.md"]));
1067 }
1068
1069 #[test]
1074 fn fixture_canonical_folders_match_store_expectations() {
1075 let contacts = [rec("records/contacts/x.md", "contact", &[])];
1076 let (_dir, store) = store_with_sidecars(&[("records/contacts", &contacts)]);
1077 let got = store.find_by_type("contact").unwrap();
1080 assert_eq!(got.len(), 1, "fixture folder == store's canonical folder");
1081 }
1082}