1use std::collections::BTreeMap;
14use std::path::{Path, PathBuf};
15
16use chrono::{DateTime, FixedOffset};
17use serde_norway::{Mapping, Value};
18
19const LAYER_DIRS: [&str; 3] = ["sources", "records", "wiki"];
22
23#[derive(Debug, thiserror::Error)]
25pub enum ParseError {
26 #[error("malformed YAML frontmatter in {file}: {source}")]
29 MalformedYaml {
30 file: PathBuf,
32 source: serde_norway::Error,
34 },
35
36 #[error("missing frontmatter block in {file}")]
38 MissingFrontmatter {
39 file: PathBuf,
41 },
42
43 #[error("missing required field '{key}' in {file}")]
46 MissingField {
47 file: PathBuf,
49 key: String,
51 },
52
53 #[error("bad timestamp in field '{key}' of {file}: {value}")]
55 BadTimestamp {
56 file: PathBuf,
58 key: String,
60 value: String,
62 },
63
64 #[error(transparent)]
66 Io(#[from] std::io::Error),
67}
68
69#[derive(Debug, Clone, Default, PartialEq)]
77pub struct Frontmatter {
78 pub type_: Option<String>,
80 pub id: Option<String>,
82 pub created: Option<DateTime<FixedOffset>>,
84 pub updated: Option<DateTime<FixedOffset>>,
86 pub summary: Option<String>,
88 pub status: Option<String>,
90 pub tags: Vec<String>,
92 pub extra: BTreeMap<String, Value>,
97}
98
99impl Frontmatter {
100 pub fn parse(yaml: &str, file: &Path) -> Result<Self, ParseError> {
106 let value: Value = if yaml.trim().is_empty() {
109 Value::Mapping(Mapping::new())
110 } else {
111 serde_norway::from_str(yaml).map_err(|source| ParseError::MalformedYaml {
112 file: file.to_path_buf(),
113 source,
114 })?
115 };
116
117 let map = match value {
120 Value::Mapping(m) => m,
121 Value::Null => Mapping::new(),
122 other => {
123 match serde_norway::from_value::<Mapping>(other) {
132 Ok(m) => m,
133 Err(source) => {
134 return Err(ParseError::MalformedYaml {
135 file: file.to_path_buf(),
136 source,
137 });
138 }
139 }
140 }
141 };
142
143 let mut fm = Frontmatter::default();
144 for (k, v) in map {
145 let key = match k.as_str() {
146 Some(s) => s.to_string(),
147 None => format!("{k:?}"),
150 };
151 match key.as_str() {
152 "type" => fm.type_ = scalar_string(&v),
163 "id" => fm.id = scalar_string(&v),
164 "created" => fm.created = parse_timestamp(&v, "created", file)?,
165 "updated" => fm.updated = parse_timestamp(&v, "updated", file)?,
166 "summary" => fm.summary = scalar_string(&v),
167 "status" => fm.status = scalar_string(&v),
168 "tags" => fm.tags = parse_tags(&v),
169 _ => {
170 fm.extra.insert(key, v);
171 }
172 }
173 }
174 Ok(fm)
175 }
176
177 pub fn to_yaml(&self) -> String {
180 let mut map = Mapping::new();
187
188 if let Some(t) = &self.type_ {
189 map.insert(Value::String("type".into()), Value::String(t.clone()));
190 }
191 if let Some(id) = &self.id {
192 map.insert(Value::String("id".into()), Value::String(id.clone()));
193 }
194 if let Some(created) = &self.created {
195 map.insert(
196 Value::String("created".into()),
197 Value::String(created.to_rfc3339()),
198 );
199 }
200 if let Some(updated) = &self.updated {
201 map.insert(
202 Value::String("updated".into()),
203 Value::String(updated.to_rfc3339()),
204 );
205 }
206 if let Some(summary) = &self.summary {
207 map.insert(
208 Value::String("summary".into()),
209 Value::String(summary.clone()),
210 );
211 }
212
213 for (k, v) in &self.extra {
221 map.insert(Value::String(k.clone()), canonicalize_extra_value(v));
222 }
223
224 if let Some(status) = &self.status {
225 map.insert(
226 Value::String("status".into()),
227 Value::String(status.clone()),
228 );
229 }
230 if !self.tags.is_empty() {
231 map.insert(
232 Value::String("tags".into()),
233 Value::Sequence(self.tags.iter().cloned().map(Value::String).collect()),
234 );
235 }
236
237 if map.is_empty() {
238 return String::new();
239 }
240 serde_norway::to_string(&Value::Mapping(map)).unwrap_or_default()
241 }
242
243 pub fn is_content_file(path: &Path) -> bool {
247 if path.file_name().and_then(|n| n.to_str()) == Some("index.md") {
249 return false;
250 }
251 path.components().any(|c| {
256 c.as_os_str()
257 .to_str()
258 .is_some_and(|s| LAYER_DIRS.contains(&s))
259 })
260 }
261
262 pub fn effective_id(&self, store_relative_path: &Path) -> String {
265 if let Some(id) = &self.id {
266 if !id.is_empty() {
267 return id.clone();
268 }
269 }
270 store_relative_path
272 .file_stem()
273 .and_then(|s| s.to_str())
274 .unwrap_or_default()
275 .to_string()
276 }
277
278 pub fn get(&self, key: &str) -> Option<Value> {
281 match key {
282 "type" => self.type_.clone().map(Value::String),
283 "id" => self.id.clone().map(Value::String),
284 "created" => self.created.map(|d| Value::String(d.to_rfc3339())),
285 "updated" => self.updated.map(|d| Value::String(d.to_rfc3339())),
286 "summary" => self.summary.clone().map(Value::String),
287 "status" => self.status.clone().map(Value::String),
288 "tags" => {
289 if self.tags.is_empty() {
290 None
291 } else {
292 Some(Value::Sequence(
293 self.tags.iter().cloned().map(Value::String).collect(),
294 ))
295 }
296 }
297 _ => self.extra.get(key).cloned(),
298 }
299 }
300
301 pub fn set(&mut self, key: &str, value: &str) -> Result<(), ParseError> {
305 match key {
306 "type" => self.type_ = Some(value.to_string()),
307 "id" => self.id = Some(value.to_string()),
308 "created" => {
309 self.created = Some(parse_rfc3339(value, "created", Path::new("<fm set>"))?)
310 }
311 "updated" => {
312 self.updated = Some(parse_rfc3339(value, "updated", Path::new("<fm set>"))?)
313 }
314 "summary" => self.summary = Some(value.to_string()),
315 "status" => self.status = Some(value.to_string()),
316 "tags" => {
317 self.tags = match serde_norway::from_str::<Value>(value) {
321 Ok(Value::Sequence(seq)) => parse_tags(&Value::Sequence(seq)),
322 _ => vec![value.to_string()],
323 };
324 }
325 _ => {
326 let stored = parse_link_list_value(value)
338 .unwrap_or_else(|| Value::String(value.to_string()));
339 self.extra.insert(key.to_string(), stored);
340 }
341 }
342 Ok(())
343 }
344
345 pub fn link_fields(&self) -> Vec<(String, WikiLink)> {
349 let mut out = Vec::new();
350 if let Some(summary) = &self.summary {
352 for link in extract_wiki_links(summary, Path::new("")) {
353 out.push(("summary".to_string(), link));
354 }
355 }
356 for (key, value) in &self.extra {
360 for link in links_in_field_value(value) {
361 out.push((key.clone(), link));
362 }
363 }
364 out
365 }
366}
367
368#[derive(Debug, Clone, PartialEq, Eq)]
374pub struct WikiLink {
375 pub target: String,
377 pub display: Option<String>,
379 pub is_full_path: bool,
383 pub has_md_extension: bool,
386 pub location: (PathBuf, u32, u32),
388}
389
390#[derive(Debug, Clone, PartialEq, Eq)]
394pub struct MarkdownLink {
395 pub text: String,
397 pub url: String,
399 pub location: (PathBuf, u32, u32),
401}
402
403#[derive(Debug, Clone, PartialEq, Eq)]
407pub struct Section {
408 pub heading: String,
410 pub level: u8,
412 pub line: u32,
414 pub body: String,
417}
418
419#[derive(Debug, Clone, Default, PartialEq)]
424pub struct Config {
425 pub agent_instructions: Option<String>,
428 pub frozen_pages: Vec<PathBuf>,
431 pub ignored_types: Vec<String>,
434 pub schemas: BTreeMap<String, Schema>,
436}
437
438impl Config {
439 pub fn frozen_match(&self, target: &Path) -> Option<PathBuf> {
456 let want = normalize_frozen_path(target);
457 self.frozen_pages
458 .iter()
459 .find(|frozen| normalize_frozen_path(frozen) == want)
460 .cloned()
461 }
462
463 pub fn is_frozen(&self, target: &Path) -> bool {
466 self.frozen_match(target).is_some()
467 }
468}
469
470fn normalize_frozen_path(p: &Path) -> String {
475 let unix: String = p
476 .components()
477 .filter_map(|c| c.as_os_str().to_str())
478 .collect::<Vec<_>>()
479 .join("/");
480 let no_dot = unix.strip_prefix("./").unwrap_or(&unix);
481 no_dot.strip_suffix(".md").unwrap_or(no_dot).to_string()
482}
483
484#[derive(Debug, Clone, Default, PartialEq)]
488pub struct Schema {
489 pub fields: Vec<FieldSpec>,
491 pub unique_keys: Vec<Vec<String>>,
496 pub summary_template: Option<String>,
500 pub shard: Option<bool>,
509}
510
511#[derive(Debug, Clone, Default, PartialEq)]
517pub struct FieldSpec {
518 pub name: String,
520 pub required: bool,
522 pub shape: Option<Shape>,
525 pub link_prefix: Option<PathBuf>,
528 pub default: Option<Value>,
530 pub enum_values: Option<Vec<String>>,
533 pub unknown_modifiers: Vec<String>,
536}
537
538#[derive(Debug, Clone, Copy, PartialEq, Eq)]
541pub enum Shape {
542 String,
544 Int,
546 Bool,
548 Date,
550 Email,
552 Currency,
554 Url,
556}
557
558#[derive(Debug, Clone, PartialEq, Eq)]
563pub struct ParsedFile {
564 pub frontmatter_yaml: String,
566 pub body: String,
568}
569
570pub fn split_frontmatter(text: &str, file: &Path) -> Result<ParsedFile, ParseError> {
575 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
584
585 let mut lines = text.split_inclusive('\n');
588 let first = lines.next().unwrap_or("");
589 if first.trim_end_matches(['\r', '\n']) != "---" {
590 return Err(ParseError::MissingFrontmatter {
591 file: file.to_path_buf(),
592 });
593 }
594
595 let opening_len = first.len();
599 let mut offset = opening_len;
600 for line in lines {
601 if line.trim_end_matches(['\r', '\n']) == "---" {
602 let yaml = &text[opening_len..offset];
603 let body_start = offset + line.len();
604 let body = &text[body_start..];
605 return Ok(ParsedFile {
606 frontmatter_yaml: yaml.to_string(),
607 body: body.to_string(),
608 });
609 }
610 offset += line.len();
611 }
612
613 Err(ParseError::MissingFrontmatter {
615 file: file.to_path_buf(),
616 })
617}
618
619pub fn read_file(path: &Path) -> Result<(Frontmatter, String), ParseError> {
622 let text = std::fs::read_to_string(path)?;
623 let parsed = split_frontmatter(&text, path)?;
624 let fm = Frontmatter::parse(&parsed.frontmatter_yaml, path)?;
625 Ok((fm, parsed.body))
626}
627
628pub fn write_file(path: &Path, frontmatter: &Frontmatter, body: &str) -> Result<(), ParseError> {
633 let yaml = frontmatter.to_yaml();
634 let mut contents = String::with_capacity(yaml.len() + body.len() + 8);
637 contents.push_str("---\n");
638 contents.push_str(&yaml);
639 contents.push_str("---\n");
640 contents.push_str(body);
641
642 crate::fsx::write_atomic(path, contents.as_bytes())?;
646 Ok(())
647}
648
649pub fn extract_wiki_links(body: &str, file: &Path) -> Vec<WikiLink> {
653 static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
654 let re = RE.get_or_init(|| {
655 regex::Regex::new(r"\[\[([^\[\]|]+?)(?:\|([^\[\]]*))?\]\]").expect("valid wiki-link regex")
658 });
659
660 let mut out = Vec::new();
661 for (line_idx, line) in body.lines().enumerate() {
662 for caps in re.captures_iter(line) {
663 let whole = caps.get(0).expect("group 0 always present");
664 let target = caps.get(1).map(|m| m.as_str()).unwrap_or("").to_string();
665 let display = caps.get(2).map(|m| m.as_str().to_string());
666 out.push(WikiLink {
667 is_full_path: target_is_full_path(&target),
668 has_md_extension: target_has_md_extension(&target),
669 target,
670 display,
671 location: (
672 file.to_path_buf(),
673 (line_idx as u32) + 1,
674 char_column(line, whole.start()),
675 ),
676 });
677 }
678 }
679 out
680}
681
682pub fn extract_markdown_links(body: &str, file: &Path) -> Vec<MarkdownLink> {
685 static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
686 let re = RE.get_or_init(|| {
687 regex::Regex::new(r"\[([^\[\]]*)\]\(([^)\s]*)\)").expect("valid markdown-link regex")
690 });
691
692 let mut out = Vec::new();
693 for (line_idx, line) in body.lines().enumerate() {
694 for caps in re.captures_iter(line) {
695 let whole = caps.get(0).expect("group 0 always present");
696 out.push(MarkdownLink {
697 text: caps.get(1).map(|m| m.as_str()).unwrap_or("").to_string(),
698 url: caps.get(2).map(|m| m.as_str()).unwrap_or("").to_string(),
699 location: (
700 file.to_path_buf(),
701 (line_idx as u32) + 1,
702 char_column(line, whole.start()),
703 ),
704 });
705 }
706 }
707 out
708}
709
710pub fn detect_flow_form_link_lists(frontmatter_yaml: &str) -> Vec<String> {
730 let value: Value = match serde_norway::from_str(frontmatter_yaml) {
731 Ok(v) => v,
732 Err(_) => return Vec::new(),
734 };
735 let Value::Mapping(map) = value else {
736 return Vec::new();
737 };
738
739 let mut out = Vec::new();
740 for (k, v) in &map {
741 if let Value::Sequence(items) = v {
742 let is_link_list = items.iter().any(|item| match item {
746 Value::Sequence(inner) => inner.iter().any(|x| matches!(x, Value::Sequence(_))),
747 _ => false,
748 });
749 if is_link_list {
750 if let Some(key) = k.as_str() {
751 out.push(key.to_string());
752 }
753 }
754 }
755 }
756 out
757}
758
759pub fn extract_sections(body: &str) -> Vec<Section> {
762 let lines: Vec<&str> = body.split_inclusive('\n').collect();
764
765 let mut levels: Vec<u8> = Vec::with_capacity(lines.len());
768 let mut fence: Option<(u8, usize)> = None;
769 for line in &lines {
770 let content = line.trim_end_matches(['\n', '\r']);
771 if let Some(f) = fence {
772 if is_closing_fence(content, f) {
773 fence = None;
774 }
775 levels.push(0);
776 continue;
777 }
778 if let Some(opened) = opening_fence(content) {
779 fence = Some(opened);
780 levels.push(0);
781 continue;
782 }
783 levels.push(heading_level(content));
784 }
785
786 let mut sections = Vec::new();
789 for (i, &lvl) in levels.iter().enumerate() {
790 if lvl < 2 {
791 continue;
792 }
793 let heading_line = lines[i].trim_end_matches(['\n', '\r']);
794 let heading = heading_text(heading_line, lvl);
795
796 let mut end = lines.len();
797 for (j, &other) in levels.iter().enumerate().skip(i + 1) {
798 if other != 0 && other <= lvl {
799 end = j;
800 break;
801 }
802 }
803
804 sections.push(Section {
805 heading,
806 level: lvl,
807 line: (i + 1) as u32,
808 body: lines[i..end].concat(),
809 });
810 }
811 sections
812}
813
814pub fn parse_db_md(text: &str, file: &Path) -> Result<Config, ParseError> {
819 let parsed = split_frontmatter(text, file)?;
823 let _frontmatter = Frontmatter::parse(&parsed.frontmatter_yaml, file)?;
824 let sections = extract_sections(&parsed.body);
825
826 let mut config = Config::default();
827 let mut current_h2: Option<String> = None;
829
830 for section in §ions {
831 match section.level {
832 2 => {
833 let name = section.heading.trim().to_ascii_lowercase();
834 current_h2 = Some(name.clone());
835 if name == "agent instructions" {
836 let prose = section_prose(§ion.body);
837 if !prose.is_empty() {
838 config.agent_instructions = Some(prose);
839 }
840 }
841 }
842 3 => {
843 let h2 = current_h2.as_deref().unwrap_or("");
844 let h3 = section.heading.trim().to_ascii_lowercase();
845 match (h2, h3.as_str()) {
846 ("policies", "frozen pages") => {
847 config.frozen_pages = bullet_lines(§ion.body)
848 .into_iter()
849 .map(|b| PathBuf::from(extract_path_bullet(&b)))
850 .collect();
851 }
852 ("policies", "ignored types") => {
853 config.ignored_types = bullet_lines(§ion.body)
854 .into_iter()
855 .flat_map(|b| extract_type_list_bullet(&b))
856 .collect();
857 }
858 ("schemas", _) => {
859 let type_name = section.heading.trim().to_string();
861 let mut schema = Schema::default();
862 for b in bullet_lines(§ion.body) {
863 match parse_schema_bullet(&b) {
864 SchemaBullet::Field(f) => schema.fields.push(f),
865 SchemaBullet::Unique(k) if !k.is_empty() => {
866 schema.unique_keys.push(k)
867 }
868 SchemaBullet::SummaryTemplate(t) if !t.is_empty() => {
869 schema.summary_template = Some(t)
870 }
871 SchemaBullet::Shard(Some(b)) => schema.shard = Some(b),
872 SchemaBullet::Unique(_)
875 | SchemaBullet::SummaryTemplate(_)
876 | SchemaBullet::Shard(None) => {}
877 }
878 }
879 config.schemas.insert(type_name, schema);
880 }
881 _ => {}
882 }
883 }
884 _ => {}
885 }
886 }
887
888 Ok(config)
889}
890
891#[derive(Debug)]
896enum SchemaBullet {
897 Field(FieldSpec),
899 Unique(Vec<String>),
901 SummaryTemplate(String),
903 Shard(Option<bool>),
906}
907
908fn parse_schema_bullet(bullet_line: &str) -> SchemaBullet {
914 let line = bullet_line.trim();
915 let line = line
916 .strip_prefix("- ")
917 .or_else(|| line.strip_prefix("* "))
918 .or_else(|| line.strip_prefix("+ "))
919 .or_else(|| line.strip_prefix('-'))
920 .unwrap_or(line)
921 .trim();
922
923 if let Some((head, rest)) = line.split_once(':') {
924 match head.trim().to_ascii_lowercase().as_str() {
925 "unique" => {
926 let fields = rest
927 .split(',')
928 .map(|f| f.trim().to_string())
929 .filter(|f| !f.is_empty())
930 .collect();
931 return SchemaBullet::Unique(fields);
932 }
933 "summary_template" => {
934 return SchemaBullet::SummaryTemplate(rest.trim().to_string());
935 }
936 "shard" => {
937 let v = match rest.trim().to_ascii_lowercase().as_str() {
940 "by-date" | "date" | "sharded" | "true" => Some(true),
941 "flat" | "none" | "false" => Some(false),
942 _ => None,
943 };
944 return SchemaBullet::Shard(v);
945 }
946 _ => {}
947 }
948 }
949
950 SchemaBullet::Field(parse_field_spec(bullet_line))
951}
952
953pub fn parse_field_spec(bullet_line: &str) -> FieldSpec {
957 let line = bullet_line.trim();
959 let line = line
960 .strip_prefix("- ")
961 .or_else(|| line.strip_prefix("* "))
962 .or_else(|| line.strip_prefix("+ "))
963 .or_else(|| line.strip_prefix('-'))
964 .unwrap_or(line)
965 .trim();
966
967 let (name, modifiers) = match line.find('(') {
970 Some(open) => {
971 let name = line[..open].trim().to_string();
972 let after = &line[open + 1..];
973 let mods = match after.rfind(')') {
974 Some(close) => &after[..close],
975 None => after, };
977 (name, mods.trim())
978 }
979 None => (line.to_string(), ""),
980 };
981
982 let mut spec = FieldSpec {
983 name,
984 ..FieldSpec::default()
985 };
986
987 if modifiers.is_empty() {
988 return spec;
989 }
990
991 let raw: Vec<&str> = modifiers.split(',').collect();
994 let mut i = 0;
995 while i < raw.len() {
996 let token = raw[i].trim();
997 if token.is_empty() {
998 i += 1;
999 continue;
1000 }
1001 let lower = token.to_ascii_lowercase();
1002
1003 if lower == "required" {
1004 spec.required = true;
1005 } else if let Some(shape) = shape_from_str(&lower) {
1006 spec.shape = Some(shape);
1007 } else if let Some(rest) = lower.strip_prefix("link to ") {
1008 let prefix = token["link to ".len()..].trim().trim_end_matches('/');
1011 let _ = rest; spec.link_prefix = Some(PathBuf::from(prefix));
1013 } else if let Some(_rest) = lower.strip_prefix("default ") {
1014 let value = token["default ".len()..].trim().to_string();
1017 spec.default = Some(Value::String(value));
1018 } else if lower == "enum" {
1019 let values: Vec<String> = raw[i + 1..]
1022 .iter()
1023 .map(|v| v.trim().to_string())
1024 .filter(|v| !v.is_empty())
1025 .collect();
1026 spec.enum_values = Some(values);
1027 break; } else if lower.starts_with("enum:") {
1029 let mut joined = raw[i..].join(",");
1032 if let Some(colon) = joined.find(':') {
1033 joined = joined[colon + 1..].to_string();
1034 }
1035 let values: Vec<String> = joined
1036 .split(',')
1037 .map(|v| v.trim().to_string())
1038 .filter(|v| !v.is_empty())
1039 .collect();
1040 spec.enum_values = Some(values);
1041 break; } else {
1043 spec.unknown_modifiers.push(token.to_string());
1045 }
1046 i += 1;
1047 }
1048
1049 spec
1050}
1051
1052fn parse_timestamp(
1057 value: &Value,
1058 key: &str,
1059 file: &Path,
1060) -> Result<Option<DateTime<FixedOffset>>, ParseError> {
1061 match value {
1062 Value::Null => Ok(None),
1063 Value::String(s) => parse_rfc3339(s, key, file).map(Some),
1064 other => Err(ParseError::BadTimestamp {
1065 file: file.to_path_buf(),
1066 key: key.to_string(),
1067 value: format!("{other:?}"),
1068 }),
1069 }
1070}
1071
1072fn parse_rfc3339(s: &str, key: &str, file: &Path) -> Result<DateTime<FixedOffset>, ParseError> {
1074 DateTime::parse_from_rfc3339(s.trim()).map_err(|_| ParseError::BadTimestamp {
1075 file: file.to_path_buf(),
1076 key: key.to_string(),
1077 value: s.to_string(),
1078 })
1079}
1080
1081fn scalar_string(value: &Value) -> Option<String> {
1090 match value {
1091 Value::String(s) => Some(s.clone()),
1092 Value::Number(n) => Some(n.to_string()),
1093 Value::Bool(b) => Some(b.to_string()),
1094 _ => None,
1095 }
1096}
1097
1098fn parse_tags(value: &Value) -> Vec<String> {
1101 match value {
1102 Value::Sequence(items) => items
1103 .iter()
1104 .filter_map(|v| match v {
1105 Value::String(s) => Some(s.clone()),
1106 Value::Number(n) => Some(n.to_string()),
1107 Value::Bool(b) => Some(b.to_string()),
1108 _ => None,
1109 })
1110 .collect(),
1111 Value::String(s) => vec![s.clone()],
1112 _ => Vec::new(),
1113 }
1114}
1115
1116fn parse_wiki_link_str(s: &str) -> Option<WikiLink> {
1120 let s = s.trim();
1121 let inner = s.strip_prefix("[[")?.strip_suffix("]]")?;
1122 if inner.contains('[') || inner.contains(']') {
1125 return None;
1126 }
1127 let (target, display) = match inner.split_once('|') {
1128 Some((t, d)) => (t.to_string(), Some(d.to_string())),
1129 None => (inner.to_string(), None),
1130 };
1131 Some(WikiLink {
1132 is_full_path: target_is_full_path(&target),
1133 has_md_extension: target_has_md_extension(&target),
1134 target,
1135 display,
1136 location: (PathBuf::new(), 0, 0),
1137 })
1138}
1139
1140fn links_in_field_value(value: &Value) -> Vec<WikiLink> {
1168 if let Value::String(s) = value {
1170 return parse_wiki_link_str(s).into_iter().collect();
1171 }
1172 let Value::Sequence(items) = value else {
1173 return Vec::new();
1174 };
1175 if items.len() == 1 {
1179 if let Some(link) = unquoted_inline_link(&items[0]) {
1180 return vec![link];
1181 }
1182 }
1183 items
1186 .iter()
1187 .filter_map(|item| parse_wiki_link_str(item.as_str()?))
1188 .collect()
1189}
1190
1191fn canonicalize_extra_value(value: &Value) -> Value {
1222 match value {
1223 Value::String(s) => match parse_wiki_link_str(s) {
1227 Some(link) => Value::String(wiki_link_literal(&link)),
1228 None => value.clone(),
1229 },
1230 Value::Sequence(items) => {
1231 if items.len() == 1 {
1235 if let Some(link) = unquoted_inline_link(&items[0]) {
1236 return Value::String(wiki_link_literal(&link));
1237 }
1238 }
1239 let mut links = Vec::with_capacity(items.len());
1246 for item in items {
1247 match link_from_flow_list_item(item) {
1248 Some(link) => links.push(link),
1249 None => return value.clone(),
1250 }
1251 }
1252 if links.is_empty() {
1253 return value.clone();
1254 }
1255 Value::Sequence(
1256 links
1257 .iter()
1258 .map(|l| Value::String(wiki_link_literal(l)))
1259 .collect(),
1260 )
1261 }
1262 _ => value.clone(),
1264 }
1265}
1266
1267fn wiki_link_literal(link: &WikiLink) -> String {
1270 match &link.display {
1271 Some(d) => format!("[[{}|{}]]", link.target, d),
1272 None => format!("[[{}]]", link.target),
1273 }
1274}
1275
1276fn unquoted_inline_link(v: &Value) -> Option<WikiLink> {
1283 let Value::Sequence(items) = v else {
1284 return None;
1285 };
1286 if items.len() != 1 {
1287 return None;
1288 }
1289 let s = items[0].as_str()?;
1290 if s.contains('[') || s.contains(']') {
1292 return None;
1293 }
1294 parse_wiki_link_str(&format!("[[{s}]]"))
1295}
1296
1297fn parse_link_list_value(value: &str) -> Option<Value> {
1319 let trimmed = value.trim();
1320 if !(trimmed.starts_with('[') && trimmed.ends_with(']')) {
1324 return None;
1325 }
1326 let Ok(Value::Sequence(items)) = serde_norway::from_str::<Value>(trimmed) else {
1327 return None;
1328 };
1329 if items.len() == 1 && unquoted_inline_link(&items[0]).is_some() {
1334 return None;
1335 }
1336 let mut links = Vec::with_capacity(items.len());
1339 for item in &items {
1340 links.push(link_from_flow_list_item(item)?);
1341 }
1342 if links.is_empty() {
1343 return None;
1344 }
1345 let normalized = links
1349 .iter()
1350 .map(|l| Value::String(wiki_link_literal(l)))
1351 .collect();
1352 Some(Value::Sequence(normalized))
1353}
1354
1355fn link_from_flow_list_item(item: &Value) -> Option<WikiLink> {
1368 match item {
1369 Value::String(s) => parse_wiki_link_str(s),
1370 Value::Sequence(inner) => {
1371 if inner.len() == 1 {
1374 if let Some(link) = unquoted_inline_link(&inner[0]) {
1375 return Some(link);
1376 }
1377 }
1378 unquoted_inline_link(item)
1380 }
1381 _ => None,
1382 }
1383}
1384
1385fn target_is_full_path(target: &str) -> bool {
1389 let target = target.trim();
1390 match target.split_once('/') {
1391 Some((head, _rest)) => LAYER_DIRS.contains(&head),
1392 None => false,
1393 }
1394}
1395
1396fn target_has_md_extension(target: &str) -> bool {
1399 target.trim().ends_with(".md")
1400}
1401
1402fn char_column(line: &str, byte_offset: usize) -> u32 {
1404 (line[..byte_offset].chars().count() as u32) + 1
1405}
1406
1407fn shape_from_str(s: &str) -> Option<Shape> {
1409 match s {
1410 "string" => Some(Shape::String),
1411 "int" => Some(Shape::Int),
1412 "bool" => Some(Shape::Bool),
1413 "date" => Some(Shape::Date),
1414 "email" => Some(Shape::Email),
1415 "currency" => Some(Shape::Currency),
1416 "url" => Some(Shape::Url),
1417 _ => None,
1418 }
1419}
1420
1421fn heading_level(line: &str) -> u8 {
1425 let indent = line.len() - line.trim_start_matches(' ').len();
1426 if indent > 3 {
1427 return 0;
1428 }
1429 let rest = &line[indent..];
1430 let hashes = rest.len() - rest.trim_start_matches('#').len();
1431 if hashes == 0 || hashes > 6 {
1432 return 0;
1433 }
1434 let after = &rest[hashes..];
1435 if after.is_empty() || after.starts_with(' ') || after.starts_with('\t') {
1436 hashes as u8
1437 } else {
1438 0
1439 }
1440}
1441
1442fn heading_text(line: &str, level: u8) -> String {
1445 let indent = line.len() - line.trim_start_matches(' ').len();
1446 let after_hashes = &line[indent + level as usize..];
1447 let trimmed = after_hashes.trim();
1448 let no_trailing = trimmed.trim_end_matches('#');
1449 if no_trailing.len() == trimmed.len() {
1450 trimmed.to_string()
1451 } else {
1452 no_trailing.trim_end().to_string()
1453 }
1454}
1455
1456fn opening_fence(line: &str) -> Option<(u8, usize)> {
1458 let indent = line.len() - line.trim_start_matches(' ').len();
1459 if indent > 3 {
1460 return None;
1461 }
1462 let rest = &line[indent..];
1463 let byte = rest.bytes().next()?;
1464 if byte != b'`' && byte != b'~' {
1465 return None;
1466 }
1467 let run = rest.len() - rest.trim_start_matches(byte as char).len();
1468 if run < 3 {
1469 return None;
1470 }
1471 if byte == b'`' && rest[run..].contains('`') {
1473 return None;
1474 }
1475 Some((byte, run))
1476}
1477
1478fn is_closing_fence(line: &str, fence: (u8, usize)) -> bool {
1481 let (byte, open_len) = fence;
1482 let indent = line.len() - line.trim_start_matches(' ').len();
1483 if indent > 3 {
1484 return false;
1485 }
1486 let rest = &line[indent..];
1487 let run = rest.len() - rest.trim_start_matches(byte as char).len();
1488 if run < open_len {
1489 return false;
1490 }
1491 rest[run..].trim().is_empty()
1492}
1493
1494fn section_prose(section_body: &str) -> String {
1496 match section_body.split_once('\n') {
1497 Some((_heading, rest)) => rest.trim().to_string(),
1498 None => String::new(),
1499 }
1500}
1501
1502fn bullet_lines(section_body: &str) -> Vec<String> {
1505 section_body
1506 .lines()
1507 .skip(1) .map(str::trim)
1509 .filter(|l| l.starts_with("- ") || l.starts_with("* ") || l.starts_with("+ "))
1510 .map(|l| l.to_string())
1511 .collect()
1512}
1513
1514fn strip_bullet_comment(content: &str) -> &str {
1517 let mut cut = content.len();
1518 for sep in [" — ", " -- ", " – "] {
1519 if let Some(idx) = content.find(sep) {
1520 cut = cut.min(idx);
1521 }
1522 }
1523 content[..cut].trim()
1524}
1525
1526fn bullet_content(bullet: &str) -> &str {
1528 let t = bullet.trim();
1529 t.strip_prefix("- ")
1530 .or_else(|| t.strip_prefix("* "))
1531 .or_else(|| t.strip_prefix("+ "))
1532 .unwrap_or(t)
1533 .trim()
1534}
1535
1536fn extract_path_bullet(bullet: &str) -> String {
1539 let content = bullet_content(bullet);
1540 if let Some(start) = content.find('`') {
1542 if let Some(end_rel) = content[start + 1..].find('`') {
1543 return content[start + 1..start + 1 + end_rel].trim().to_string();
1544 }
1545 }
1546 strip_bullet_comment(content)
1548 .trim_matches('"')
1549 .trim_matches('\'')
1550 .trim()
1551 .to_string()
1552}
1553
1554fn extract_type_list_bullet(bullet: &str) -> Vec<String> {
1557 let content = strip_bullet_comment(bullet_content(bullet));
1558 content
1559 .split(',')
1560 .map(|t| {
1561 t.trim()
1562 .trim_matches('`')
1563 .trim_matches('"')
1564 .trim_matches('\'')
1565 .trim()
1566 .to_string()
1567 })
1568 .filter(|t| !t.is_empty())
1569 .collect()
1570}
1571
1572#[cfg(test)]
1573mod tests {
1574 use super::*;
1575 use std::path::Path;
1576 use tempfile::tempdir;
1577
1578 #[test]
1581 fn frozen_match_is_md_insensitive_both_directions() {
1582 let cfg = Config {
1586 frozen_pages: vec![PathBuf::from("records/decisions/q1")],
1587 ..Config::default()
1588 };
1589 assert_eq!(
1590 cfg.frozen_match(Path::new("records/decisions/q1.md")),
1591 Some(PathBuf::from("records/decisions/q1")),
1592 "extensionless policy entry must freeze the .md file"
1593 );
1594 assert!(cfg.is_frozen(Path::new("records/decisions/q1.md")));
1595
1596 let cfg = Config {
1598 frozen_pages: vec![PathBuf::from("records/decisions/q1.md")],
1599 ..Config::default()
1600 };
1601 assert_eq!(
1602 cfg.frozen_match(Path::new("records/decisions/q1")),
1603 Some(PathBuf::from("records/decisions/q1.md")),
1604 );
1605 assert!(cfg.is_frozen(Path::new("records/decisions/q1.md")));
1607 }
1608
1609 #[test]
1610 fn frozen_match_drops_leading_dot_slash() {
1611 let cfg = Config {
1612 frozen_pages: vec![PathBuf::from("records/decisions/q1.md")],
1613 ..Config::default()
1614 };
1615 assert!(cfg.is_frozen(Path::new("./records/decisions/q1.md")));
1616 assert!(cfg.is_frozen(Path::new("./records/decisions/q1")));
1617 }
1618
1619 #[test]
1620 fn frozen_match_returns_none_for_unlisted_and_prefix_paths() {
1621 let cfg = Config {
1622 frozen_pages: vec![PathBuf::from("records/decisions/q1")],
1623 ..Config::default()
1624 };
1625 assert!(cfg
1626 .frozen_match(Path::new("records/decisions/q2.md"))
1627 .is_none());
1628 assert!(cfg
1630 .frozen_match(Path::new("records/decisions/q1-draft.md"))
1631 .is_none());
1632 assert!(!cfg.is_frozen(Path::new("records/decisions/q11.md")));
1633 }
1634
1635 #[test]
1638 fn split_frontmatter_separates_yaml_and_verbatim_body() {
1639 let text = "---\ntype: contact\nsummary: x\n---\n# Heading\n\nBody line.\n";
1640 let p = split_frontmatter(text, Path::new("f.md")).unwrap();
1641 assert_eq!(p.frontmatter_yaml, "type: contact\nsummary: x\n");
1642 assert_eq!(p.body, "# Heading\n\nBody line.\n");
1644 }
1645
1646 #[test]
1647 fn split_frontmatter_preserves_body_without_trailing_newline() {
1648 let text = "---\ntype: x\n---\nno trailing newline";
1649 let p = split_frontmatter(text, Path::new("f.md")).unwrap();
1650 assert_eq!(p.body, "no trailing newline");
1651 }
1652
1653 #[test]
1654 fn split_frontmatter_empty_body_when_nothing_after_fence() {
1655 let text = "---\ntype: x\n---\n";
1656 let p = split_frontmatter(text, Path::new("f.md")).unwrap();
1657 assert_eq!(p.body, "");
1658 }
1659
1660 #[test]
1661 fn split_frontmatter_missing_opening_fence_errors() {
1662 let text = "# No frontmatter here\ntype: x\n";
1663 let err = split_frontmatter(text, Path::new("f.md")).unwrap_err();
1664 assert!(matches!(err, ParseError::MissingFrontmatter { .. }));
1665 }
1666
1667 #[test]
1668 fn split_frontmatter_leading_content_before_fence_rejected() {
1669 let text = "\n---\ntype: x\n---\nbody";
1672 let err = split_frontmatter(text, Path::new("f.md")).unwrap_err();
1673 assert!(matches!(err, ParseError::MissingFrontmatter { .. }));
1674 }
1675
1676 #[test]
1677 fn split_frontmatter_unterminated_block_errors() {
1678 let text = "---\ntype: x\nsummary: y\n";
1679 let err = split_frontmatter(text, Path::new("f.md")).unwrap_err();
1680 assert!(matches!(err, ParseError::MissingFrontmatter { .. }));
1681 }
1682
1683 #[test]
1686 fn parse_populates_typed_fields_and_routes_unknowns_to_extra() {
1687 let yaml = "type: contact\nid: sarah-chen\nsummary: Director of Ops\nstatus: active\ntags: [vip, renewal]\nemail: sarah@northstar.io\nrole: Director";
1688 let fm = Frontmatter::parse(yaml, Path::new("f.md")).unwrap();
1689 assert_eq!(fm.type_.as_deref(), Some("contact"));
1690 assert_eq!(fm.id.as_deref(), Some("sarah-chen"));
1691 assert_eq!(fm.summary.as_deref(), Some("Director of Ops"));
1692 assert_eq!(fm.status.as_deref(), Some("active"));
1693 assert_eq!(fm.tags, vec!["vip".to_string(), "renewal".to_string()]);
1694 assert!(fm.type_.is_some() && !fm.extra.contains_key("type"));
1696 assert!(!fm.extra.contains_key("tags"));
1697 assert_eq!(
1698 fm.extra.get("email").and_then(|v| v.as_str()),
1699 Some("sarah@northstar.io")
1700 );
1701 assert_eq!(
1702 fm.extra.get("role").and_then(|v| v.as_str()),
1703 Some("Director")
1704 );
1705 }
1706
1707 #[test]
1708 fn parse_reads_rfc3339_timestamps() {
1709 let yaml =
1710 "type: email\ncreated: 2026-05-27T08:00:00-07:00\nupdated: 2026-05-28T09:30:00-07:00";
1711 let fm = Frontmatter::parse(yaml, Path::new("f.md")).unwrap();
1712 let created = fm.created.expect("created parsed");
1713 assert_eq!(created.offset().utc_minus_local(), 7 * 3600);
1715 assert_eq!(created.to_rfc3339(), "2026-05-27T08:00:00-07:00");
1716 assert!(fm.updated.is_some());
1717 }
1718
1719 #[test]
1720 fn parse_rejects_non_rfc3339_timestamp() {
1721 let yaml = "type: email\ncreated: 2026-05-27";
1724 let err = Frontmatter::parse(yaml, Path::new("bad.md")).unwrap_err();
1725 match err {
1726 ParseError::BadTimestamp { key, value, .. } => {
1727 assert_eq!(key, "created");
1728 assert_eq!(value, "2026-05-27");
1729 }
1730 other => panic!("expected BadTimestamp, got {other:?}"),
1731 }
1732 }
1733
1734 #[test]
1735 fn parse_malformed_yaml_errors() {
1736 let yaml = "type: contact\n bad: : :\n- nope";
1738 let err = Frontmatter::parse(yaml, Path::new("bad.md")).unwrap_err();
1739 assert!(matches!(err, ParseError::MalformedYaml { .. }));
1740 }
1741
1742 #[test]
1743 fn frontmatter_with_yaml_tag_on_mapping_does_not_panic() {
1744 let fm = Frontmatter::parse("!mytag\ntype: contact\nsummary: hi\n", Path::new("x.md"))
1749 .expect("tagged-mapping frontmatter must parse, not panic");
1750 assert_eq!(fm.type_.as_deref(), Some("contact"));
1751 assert!(Frontmatter::parse("- a\n- b\n", Path::new("x.md")).is_err());
1754 }
1755
1756 #[test]
1757 fn parse_empty_block_is_empty_frontmatter() {
1758 let fm = Frontmatter::parse("", Path::new("f.md")).unwrap();
1759 assert_eq!(fm, Frontmatter::default());
1760 }
1761
1762 #[test]
1763 fn parse_scalar_top_level_is_malformed() {
1764 let err = Frontmatter::parse("just a string", Path::new("f.md")).unwrap_err();
1766 assert!(matches!(err, ParseError::MalformedYaml { .. }));
1767 }
1768
1769 #[test]
1772 fn to_yaml_emits_canonical_key_order() {
1773 let mut fm = Frontmatter {
1774 type_: Some("contact".into()),
1775 id: Some("sarah-chen".into()),
1776 summary: Some("Director of Ops".into()),
1777 status: Some("active".into()),
1778 tags: vec!["vip".into()],
1779 created: Some(DateTime::parse_from_rfc3339("2026-05-27T08:00:00-07:00").unwrap()),
1780 updated: Some(DateTime::parse_from_rfc3339("2026-05-28T09:30:00-07:00").unwrap()),
1781 ..Default::default()
1782 };
1783 fm.extra
1786 .insert("role".into(), Value::String("Director".into()));
1787 fm.extra.insert(
1788 "company".into(),
1789 Value::String("[[records/companies/northstar]]".into()),
1790 );
1791
1792 let yaml = fm.to_yaml();
1793 let keys: Vec<&str> = yaml
1794 .lines()
1795 .filter(|l| !l.starts_with(['-', ' ']) && l.contains(':'))
1796 .map(|l| l.split(':').next().unwrap())
1797 .collect();
1798 assert_eq!(
1799 keys,
1800 vec![
1801 "type", "id", "created", "updated", "summary", "company", "role", "status", "tags",
1805 ],
1806 "canonical order violated; got:\n{yaml}"
1807 );
1808 assert!(
1810 yaml.contains("2026-05-27T08:00:00-07:00"),
1811 "created timestamp missing; got:\n{yaml}"
1812 );
1813 let reparsed = Frontmatter::parse(&yaml, Path::new("rt.md")).unwrap();
1815 assert_eq!(reparsed.created, fm.created);
1816 assert_eq!(reparsed.updated, fm.updated);
1817 }
1818
1819 #[test]
1820 fn to_yaml_omits_absent_optional_fields() {
1821 let fm = Frontmatter {
1822 type_: Some("note".into()),
1823 ..Default::default()
1824 };
1825 let yaml = fm.to_yaml();
1826 assert!(yaml.contains("type: note"));
1827 assert!(!yaml.contains("status"));
1828 assert!(!yaml.contains("tags"));
1829 assert!(!yaml.contains("summary"));
1830 }
1831
1832 #[test]
1835 fn regression_parse_preserves_non_string_scalar_universal_fields() {
1836 let yaml = "type: 42\nid: 100\nsummary: 2026\nstatus: 0";
1842 let fm = Frontmatter::parse(yaml, Path::new("x.md")).unwrap();
1843 assert_eq!(fm.type_.as_deref(), Some("42"), "type scalar dropped");
1844 assert_eq!(fm.id.as_deref(), Some("100"), "id scalar dropped");
1845 assert_eq!(
1846 fm.summary.as_deref(),
1847 Some("2026"),
1848 "summary scalar dropped"
1849 );
1850 assert_eq!(fm.status.as_deref(), Some("0"), "status scalar dropped");
1851 assert_eq!(
1853 fm.get("summary")
1854 .and_then(|v| v.as_str().map(str::to_string)),
1855 Some("2026".to_string())
1856 );
1857 }
1858
1859 #[test]
1860 fn regression_format_round_trip_does_not_delete_numeric_frontmatter() {
1861 let dir = tempdir().unwrap();
1866 let path = dir.path().join("x.md");
1867 let original = "---\ntype: contact\nid: 100\nsummary: 2026\nstatus: 0\n---\nbody\n";
1868 std::fs::write(&path, original).unwrap();
1869
1870 let (fm, body) = read_file(&path).unwrap();
1872 write_file(&path, &fm, &body).unwrap();
1873
1874 let after = std::fs::read_to_string(&path).unwrap();
1875 let reparsed = Frontmatter::parse(
1877 &split_frontmatter(&after, &path).unwrap().frontmatter_yaml,
1878 &path,
1879 )
1880 .unwrap();
1881 assert_eq!(reparsed.type_.as_deref(), Some("contact"));
1882 assert_eq!(reparsed.id.as_deref(), Some("100"), "id deleted by format");
1883 assert_eq!(
1884 reparsed.summary.as_deref(),
1885 Some("2026"),
1886 "summary deleted by format"
1887 );
1888 assert_eq!(
1889 reparsed.status.as_deref(),
1890 Some("0"),
1891 "status deleted by format"
1892 );
1893 assert_eq!(body, "body\n");
1895 }
1896
1897 #[test]
1900 fn regression_split_frontmatter_tolerates_leading_utf8_bom() {
1901 let text = "\u{feff}---\ntype: note\nsummary: x\n---\nbody\n";
1907 let parsed = split_frontmatter(text, Path::new("note.md")).unwrap();
1908 assert_eq!(parsed.frontmatter_yaml, "type: note\nsummary: x\n");
1909 assert_eq!(parsed.body, "body\n");
1911 assert!(!parsed.body.starts_with('\u{feff}'));
1912 }
1913
1914 #[test]
1915 fn regression_read_file_parses_bom_prefixed_file() {
1916 let dir = tempdir().unwrap();
1920 let path = dir.path().join("note.md");
1921 std::fs::write(&path, "\u{feff}---\ntype: note\nsummary: x\n---\nbody\n").unwrap();
1922
1923 let (fm, body) = read_file(&path).expect("BOM-prefixed file must parse");
1924 assert_eq!(fm.type_.as_deref(), Some("note"));
1925 assert_eq!(fm.summary.as_deref(), Some("x"));
1926 assert_eq!(body, "body\n");
1927 }
1928
1929 #[test]
1930 fn to_yaml_preserves_unquoted_scalar_wiki_link_round_trip() {
1931 let yaml = "type: contact\ncompany: [[records/companies/northstar]]";
1941 let fm = Frontmatter::parse(yaml, Path::new("c.md")).unwrap();
1942 assert!(fm.extra.get("company").and_then(|v| v.as_str()).is_none());
1944
1945 let out = fm.to_yaml();
1946 assert!(
1949 out.contains("[[records/companies/northstar]]"),
1950 "canonical writer dropped the wiki-link brackets; got:\n{out}"
1951 );
1952 assert!(
1953 !out.contains("- - "),
1954 "canonical writer emitted a nested block sequence (link corrupted); got:\n{out}"
1955 );
1956
1957 let reparsed = Frontmatter::parse(&out, Path::new("c.md")).unwrap();
1960 let fields = reparsed.link_fields();
1961 let links: Vec<(&str, &str, Option<&str>)> = fields
1962 .iter()
1963 .map(|(k, l)| (k.as_str(), l.target.as_str(), l.display.as_deref()))
1964 .collect();
1965 assert_eq!(
1966 links,
1967 vec![("company", "records/companies/northstar", None)]
1968 );
1969
1970 assert_eq!(
1973 reparsed.to_yaml(),
1974 out,
1975 "to_yaml is not idempotent on links"
1976 );
1977 }
1978
1979 #[test]
1980 fn to_yaml_preserves_unquoted_scalar_link_with_display() {
1981 let yaml = "type: contact\ncompany: [[records/companies/northstar|Northstar]]";
1983 let fm = Frontmatter::parse(yaml, Path::new("c.md")).unwrap();
1984 let out = fm.to_yaml();
1985 assert!(
1986 out.contains("[[records/companies/northstar|Northstar]]"),
1987 "display segment lost on round-trip; got:\n{out}"
1988 );
1989 let reparsed = Frontmatter::parse(&out, Path::new("c.md")).unwrap();
1990 let f = reparsed.link_fields();
1991 assert_eq!(f.len(), 1);
1992 assert_eq!(f[0].1.target, "records/companies/northstar");
1993 assert_eq!(f[0].1.display.as_deref(), Some("Northstar"));
1994 }
1995
1996 #[test]
1997 fn to_yaml_does_not_mangle_link_list_or_plain_nested_sequence() {
1998 let yaml = "type: meeting\nattendees:\n - \"[[records/contacts/elena]]\"\n - \"[[records/contacts/sarah]]\"\nmatrix:\n - - 1\n - 2";
2002 let fm = Frontmatter::parse(yaml, Path::new("m.md")).unwrap();
2003 let out = fm.to_yaml();
2004
2005 assert!(out.contains("[[records/contacts/elena]]"), "got:\n{out}");
2007 assert!(out.contains("[[records/contacts/sarah]]"), "got:\n{out}");
2008
2009 let reparsed = Frontmatter::parse(&out, Path::new("m.md")).unwrap();
2010 let fields = reparsed.link_fields();
2011 let attendees: Vec<&str> = fields
2012 .iter()
2013 .filter(|(k, _)| k == "attendees")
2014 .map(|(_, l)| l.target.as_str())
2015 .collect();
2016 assert_eq!(
2017 attendees,
2018 vec!["records/contacts/elena", "records/contacts/sarah"]
2019 );
2020 assert_eq!(reparsed.extra.get("matrix"), fm.extra.get("matrix"));
2022 }
2023
2024 #[test]
2027 fn write_then_read_roundtrips_and_preserves_body_verbatim() {
2028 let dir = tempdir().unwrap();
2029 let path = dir.path().join("sources/emails/x.md");
2030 let body = "# Subject\n\nHello,\n\nSee [[records/contacts/sarah-chen]].\n";
2031 let mut fm = Frontmatter {
2032 type_: Some("email".into()),
2033 summary: Some("renewal note".into()),
2034 created: Some(DateTime::parse_from_rfc3339("2026-05-27T08:00:00-07:00").unwrap()),
2035 ..Default::default()
2036 };
2037 fm.extra
2038 .insert("from".into(), Value::String("elena@northstar.io".into()));
2039
2040 write_file(&path, &fm, body).unwrap();
2041
2042 let (read_fm, read_body) = read_file(&path).unwrap();
2043 assert_eq!(read_body, body, "body must be preserved byte-for-byte");
2044 assert_eq!(read_fm.type_.as_deref(), Some("email"));
2045 assert_eq!(read_fm.summary.as_deref(), Some("renewal note"));
2046 assert_eq!(
2047 read_fm.extra.get("from").and_then(|v| v.as_str()),
2048 Some("elena@northstar.io")
2049 );
2050 let raw = std::fs::read_to_string(&path).unwrap();
2052 assert!(raw.starts_with("---\n"));
2053 assert!(raw.ends_with(body));
2054 }
2055
2056 #[test]
2057 fn roundtrip_modify_summary_then_write_changes_only_summary() {
2058 let dir = tempdir().unwrap();
2059 let path = dir.path().join("records/contacts/sarah.md");
2060 let body = "Long-form operator notes about Sarah.\n";
2061 let fm = Frontmatter {
2062 type_: Some("contact".into()),
2063 summary: Some("old summary".into()),
2064 ..Default::default()
2065 };
2066 write_file(&path, &fm, body).unwrap();
2067
2068 let (mut fm2, body2) = read_file(&path).unwrap();
2070 fm2.summary = Some("new summary".into());
2071 write_file(&path, &fm2, &body2).unwrap();
2072
2073 let (fm3, body3) = read_file(&path).unwrap();
2074 assert_eq!(fm3.summary.as_deref(), Some("new summary"));
2075 assert_eq!(fm3.type_.as_deref(), Some("contact"));
2076 assert_eq!(body3, body, "body unchanged across the round-trip");
2077 }
2078
2079 #[test]
2080 fn roundtrip_preserves_handwritten_unquoted_scalar_wiki_link_on_disk() {
2081 let dir = tempdir().unwrap();
2088 let path = dir.path().join("records/contacts/sarah-chen.md");
2089 let file = "---\ntype: contact\nid: sarah-chen\nsummary: Director of Ops\ncompany: [[records/companies/northstar]]\n---\n# Sarah Chen\n\nNotes.\n";
2090 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2091 std::fs::write(&path, file).unwrap();
2092
2093 let (fm, body) = read_file(&path).unwrap();
2095 write_file(&path, &fm, &body).unwrap();
2096
2097 let raw = std::fs::read_to_string(&path).unwrap();
2099 assert!(
2100 raw.contains("[[records/companies/northstar]]"),
2101 "on-disk wiki-link brackets were destroyed; got:\n{raw}"
2102 );
2103 assert!(
2104 !raw.contains("- - "),
2105 "on-disk value became a nested block sequence; got:\n{raw}"
2106 );
2107
2108 let (fm2, _) = read_file(&path).unwrap();
2110 let fields = fm2.link_fields();
2111 let links: Vec<(&str, &str)> = fields
2112 .iter()
2113 .map(|(k, l)| (k.as_str(), l.target.as_str()))
2114 .collect();
2115 assert_eq!(links, vec![("company", "records/companies/northstar")]);
2116 }
2117
2118 #[test]
2119 fn write_file_does_not_leave_temp_files_behind() {
2120 let dir = tempdir().unwrap();
2121 let path = dir.path().join("records/x.md");
2122 let fm = Frontmatter {
2123 type_: Some("note".into()),
2124 ..Default::default()
2125 };
2126 write_file(&path, &fm, "body\n").unwrap();
2127 let entries: Vec<String> = std::fs::read_dir(path.parent().unwrap())
2129 .unwrap()
2130 .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
2131 .collect();
2132 assert_eq!(entries, vec!["x.md".to_string()]);
2133 }
2134
2135 #[test]
2138 fn is_content_file_recognizes_layers_and_excludes_meta() {
2139 assert!(Frontmatter::is_content_file(Path::new(
2140 "sources/emails/2026-05-22.md"
2141 )));
2142 assert!(Frontmatter::is_content_file(Path::new(
2143 "records/contacts/sarah-chen.md"
2144 )));
2145 assert!(Frontmatter::is_content_file(Path::new(
2146 "wiki/people/sarah-chen.md"
2147 )));
2148 assert!(Frontmatter::is_content_file(Path::new(
2150 "/home/db/records/companies/northstar.md"
2151 )));
2152 assert!(!Frontmatter::is_content_file(Path::new(
2154 "records/contacts/index.md"
2155 )));
2156 assert!(!Frontmatter::is_content_file(Path::new("index.md")));
2157 assert!(!Frontmatter::is_content_file(Path::new("DB.md")));
2159 assert!(!Frontmatter::is_content_file(Path::new("log.md")));
2160 }
2161
2162 #[test]
2165 fn effective_id_prefers_explicit_then_derives_from_path() {
2166 let with_id = Frontmatter {
2167 id: Some("explicit-id".into()),
2168 ..Default::default()
2169 };
2170 assert_eq!(
2171 with_id.effective_id(Path::new("wiki/people/sarah-chen.md")),
2172 "explicit-id"
2173 );
2174 let no_id = Frontmatter::default();
2175 assert_eq!(
2176 no_id.effective_id(Path::new("wiki/people/sarah-chen.md")),
2177 "sarah-chen"
2178 );
2179 }
2180
2181 #[test]
2184 fn set_routes_universal_and_custom_keys() {
2185 let mut fm = Frontmatter::default();
2186 fm.set("type", "contact").unwrap();
2187 fm.set("summary", "hi").unwrap();
2188 fm.set("company", "[[records/companies/northstar]]")
2189 .unwrap();
2190 assert_eq!(fm.type_.as_deref(), Some("contact"));
2191 assert_eq!(fm.summary.as_deref(), Some("hi"));
2192 assert_eq!(
2194 fm.extra.get("company").and_then(|v| v.as_str()),
2195 Some("[[records/companies/northstar]]")
2196 );
2197 assert_eq!(
2199 fm.get("type").and_then(|v| v.as_str().map(String::from)),
2200 Some("contact".into())
2201 );
2202 assert_eq!(
2203 fm.get("company").and_then(|v| v.as_str().map(String::from)),
2204 Some("[[records/companies/northstar]]".into())
2205 );
2206 assert!(fm.get("nonexistent").is_none());
2207 }
2208
2209 #[test]
2210 fn set_timestamp_validates_rfc3339() {
2211 let mut fm = Frontmatter::default();
2212 fm.set("created", "2026-05-27T08:00:00-07:00").unwrap();
2213 assert!(fm.created.is_some());
2214 let err = fm.set("updated", "not-a-date").unwrap_err();
2215 assert!(matches!(err, ParseError::BadTimestamp { .. }));
2216 }
2217
2218 #[test]
2221 fn extract_wiki_links_flags_full_path_short_form_and_extension() {
2222 let body = "See [[records/contacts/sarah-chen]] and [[sarah-chen]].\nAlso [[wiki/people/sarah-chen.md|Sarah]].\n";
2223 let links = extract_wiki_links(body, Path::new("doc.md"));
2224 assert_eq!(links.len(), 3);
2225
2226 assert_eq!(links[0].target, "records/contacts/sarah-chen");
2228 assert!(links[0].is_full_path);
2229 assert!(!links[0].has_md_extension);
2230 assert_eq!(links[0].display, None);
2231 assert_eq!(links[0].location.1, 1, "first link on line 1");
2232
2233 assert_eq!(links[1].target, "sarah-chen");
2235 assert!(!links[1].is_full_path, "bare target is short-form");
2236
2237 assert_eq!(links[2].target, "wiki/people/sarah-chen.md");
2239 assert!(links[2].is_full_path);
2240 assert!(links[2].has_md_extension);
2241 assert_eq!(links[2].display.as_deref(), Some("Sarah"));
2242 assert_eq!(links[2].location.1, 2);
2243 }
2244
2245 #[test]
2246 fn extract_wiki_links_reports_1_based_column_counting_chars() {
2247 let body = "café [[records/x/y]]";
2249 let links = extract_wiki_links(body, Path::new("d.md"));
2250 assert_eq!(links.len(), 1);
2251 assert_eq!(links[0].location.2, 6);
2253 }
2254
2255 #[test]
2256 fn extract_wiki_links_ignores_a_lone_path_without_brackets() {
2257 let links = extract_wiki_links(
2258 "records/contacts/sarah-chen is not a link",
2259 Path::new("d.md"),
2260 );
2261 assert!(links.is_empty());
2262 }
2263
2264 #[test]
2267 fn extract_markdown_links_captures_external_and_not_wiki_links() {
2268 let body =
2269 "See [the thread](https://x.com/a) and [[records/contacts/sarah-chen]] internally.\n";
2270 let md = extract_markdown_links(body, Path::new("d.md"));
2271 assert_eq!(
2272 md.len(),
2273 1,
2274 "wiki-link must not be captured as a markdown link"
2275 );
2276 assert_eq!(md[0].text, "the thread");
2277 assert_eq!(md[0].url, "https://x.com/a");
2278 assert_eq!(md[0].location.1, 1);
2279
2280 let wl = extract_wiki_links(body, Path::new("d.md"));
2282 assert_eq!(wl.len(), 1);
2283 assert_eq!(wl[0].target, "records/contacts/sarah-chen");
2284 }
2285
2286 #[test]
2289 fn link_fields_extracts_scalar_list_and_summary_links() {
2290 let yaml = "type: meeting\nsummary: with [[records/contacts/elena]]\ncompany: \"[[records/companies/northstar]]\"\nattendees:\n - \"[[records/contacts/elena]]\"\n - \"[[records/contacts/sarah]]\"\nnotes: just plain text";
2294 let fm = Frontmatter::parse(yaml, Path::new("m.md")).unwrap();
2295 assert!(fm.extra.get("company").and_then(|v| v.as_str()).is_some());
2297 let fields = fm.link_fields();
2298
2299 let company: Vec<&str> = fields
2301 .iter()
2302 .filter(|(k, _)| k == "company")
2303 .map(|(_, l)| l.target.as_str())
2304 .collect();
2305 assert_eq!(company, vec!["records/companies/northstar"]);
2306 let attendees: Vec<&str> = fields
2308 .iter()
2309 .filter(|(k, _)| k == "attendees")
2310 .map(|(_, l)| l.target.as_str())
2311 .collect();
2312 assert_eq!(
2313 attendees,
2314 vec!["records/contacts/elena", "records/contacts/sarah"]
2315 );
2316 assert_eq!(fields.iter().filter(|(k, _)| k == "summary").count(), 1);
2318 assert_eq!(fields.iter().filter(|(k, _)| k == "notes").count(), 0);
2320 }
2321
2322 #[test]
2323 fn link_fields_surfaces_canonical_unquoted_scalar_link() {
2324 let yaml = "type: meeting\ncompany: [[records/companies/northstar]]";
2330 let fm = Frontmatter::parse(yaml, Path::new("m.md")).unwrap();
2331 assert!(fm.extra.get("company").and_then(|v| v.as_str()).is_none());
2333
2334 let fields = fm.link_fields();
2335 let links: Vec<(&str, &str, Option<&str>)> = fields
2336 .iter()
2337 .map(|(k, l)| (k.as_str(), l.target.as_str(), l.display.as_deref()))
2338 .collect();
2339 assert_eq!(
2340 links,
2341 vec![("company", "records/companies/northstar", None)]
2342 );
2343
2344 let fm2 = Frontmatter::parse(
2346 "type: meeting\ncompany: [[records/companies/northstar|Northstar]]",
2347 Path::new("m.md"),
2348 )
2349 .unwrap();
2350 let f2 = fm2.link_fields();
2351 assert_eq!(f2.len(), 1);
2352 assert_eq!(f2[0].0, "company");
2353 assert_eq!(f2[0].1.target, "records/companies/northstar");
2354 assert_eq!(f2[0].1.display.as_deref(), Some("Northstar"));
2355 }
2356
2357 #[test]
2358 fn link_fields_ignores_plain_one_item_flow_list() {
2359 let yaml = "type: contact\naliases: [foo]";
2363 let fm = Frontmatter::parse(yaml, Path::new("c.md")).unwrap();
2364 assert_eq!(fm.link_fields(), Vec::new());
2365 }
2366
2367 #[test]
2370 fn detect_flow_form_flags_list_misencodings_not_scalars() {
2371 let bad = "attendees: [[[records/x]], [[records/y]]]\nscalar_inline: [[records/z]]";
2374 let flagged = detect_flow_form_link_lists(bad);
2375 assert_eq!(flagged, vec!["attendees".to_string()]);
2376
2377 let unquoted_block = "attendees:\n - [[records/x]]\n - [[records/y]]";
2379 assert_eq!(
2380 detect_flow_form_link_lists(unquoted_block),
2381 vec!["attendees".to_string()]
2382 );
2383
2384 let good = "attendees:\n - \"[[records/x]]\"\n - \"[[records/y]]\"";
2386 assert!(detect_flow_form_link_lists(good).is_empty());
2387
2388 let plain = "tags: [a, b, c]";
2390 assert!(detect_flow_form_link_lists(plain).is_empty());
2391 }
2392
2393 #[test]
2396 fn extract_sections_levels_nesting_and_boundaries() {
2397 let body = "intro text\n## First\nalpha\n### Sub\nbeta\n## Second\ngamma\n";
2398 let secs = extract_sections(body);
2399 let headings: Vec<(&str, u8)> =
2400 secs.iter().map(|s| (s.heading.as_str(), s.level)).collect();
2401 assert_eq!(headings, vec![("First", 2), ("Sub", 3), ("Second", 2)]);
2402
2403 let first = &secs[0];
2405 assert!(first.body.contains("alpha"));
2406 assert!(first.body.contains("### Sub"));
2407 assert!(first.body.contains("beta"));
2408 assert!(!first.body.contains("Second"));
2409
2410 let sub = &secs[1];
2412 assert!(sub.body.contains("beta"));
2413 assert!(!sub.body.contains("gamma"));
2414
2415 assert_eq!(first.line, 2);
2417 assert_eq!(secs[2].line, 6);
2418 }
2419
2420 #[test]
2421 fn extract_sections_ignores_headings_in_fenced_code() {
2422 let body = "## Real\n```\n## Fake heading in code\n```\nafter\n";
2423 let secs = extract_sections(body);
2424 assert_eq!(secs.len(), 1);
2425 assert_eq!(secs[0].heading, "Real");
2426 assert!(secs[0].body.contains("## Fake heading in code"));
2428 }
2429
2430 #[test]
2433 fn parse_field_spec_required_and_shape() {
2434 let f = parse_field_spec("- email (required, email)");
2435 assert_eq!(f.name, "email");
2436 assert!(f.required);
2437 assert_eq!(f.shape, Some(Shape::Email));
2438 assert!(f.unknown_modifiers.is_empty());
2439 }
2440
2441 #[test]
2442 fn parse_field_spec_link_prefix_strips_trailing_slash() {
2443 let f = parse_field_spec("- company (required, link to records/companies/)");
2444 assert!(f.required);
2445 assert_eq!(f.link_prefix, Some(PathBuf::from("records/companies")));
2446 assert_eq!(f.shape, None);
2447 }
2448
2449 #[test]
2450 fn parse_field_spec_default_preserves_case_and_value() {
2451 let f = parse_field_spec("- currency (default USD)");
2452 assert_eq!(f.name, "currency");
2453 assert_eq!(f.default, Some(Value::String("USD".into())));
2454 }
2455
2456 #[test]
2457 fn parse_field_spec_enum_captures_comma_list_as_last_modifier() {
2458 let f = parse_field_spec("- status (required, enum: open, closed, pending)");
2459 assert!(f.required);
2460 assert_eq!(
2461 f.enum_values,
2462 Some(vec![
2463 "open".to_string(),
2464 "closed".to_string(),
2465 "pending".to_string()
2466 ])
2467 );
2468 }
2469
2470 #[test]
2471 fn parse_field_spec_bare_enum_keyword_is_not_itself_a_value() {
2472 let f = parse_field_spec("- status (required, enum, open, closed)");
2475 assert!(f.required);
2476 assert_eq!(
2477 f.enum_values,
2478 Some(vec!["open".to_string(), "closed".to_string()])
2479 );
2480 }
2481
2482 #[test]
2483 fn parse_field_spec_unknown_modifier_is_captured_not_errored() {
2484 let f = parse_field_spec("- weird (required, frobnicate, string)");
2485 assert!(f.required);
2486 assert_eq!(f.shape, Some(Shape::String));
2487 assert_eq!(f.unknown_modifiers, vec!["frobnicate".to_string()]);
2488 }
2489
2490 #[test]
2491 fn parse_field_spec_no_parens_is_freeform_optional() {
2492 let f = parse_field_spec("- nickname");
2493 assert_eq!(f.name, "nickname");
2494 assert!(!f.required);
2495 assert_eq!(f.shape, None);
2496 assert!(f.link_prefix.is_none());
2497 assert!(f.enum_values.is_none());
2498 assert!(f.unknown_modifiers.is_empty());
2499 }
2500
2501 #[test]
2504 fn schema_bullet_unique_single_field() {
2505 match parse_schema_bullet("- unique: email") {
2506 SchemaBullet::Unique(fields) => assert_eq!(fields, vec!["email".to_string()]),
2507 other => panic!("expected Unique, got {other:?}"),
2508 }
2509 }
2510
2511 #[test]
2512 fn schema_bullet_unique_compound_trims_and_splits() {
2513 match parse_schema_bullet("- unique: date, amount , vendor") {
2514 SchemaBullet::Unique(fields) => assert_eq!(
2515 fields,
2516 vec![
2517 "date".to_string(),
2518 "amount".to_string(),
2519 "vendor".to_string()
2520 ]
2521 ),
2522 other => panic!("expected Unique, got {other:?}"),
2523 }
2524 }
2525
2526 #[test]
2527 fn schema_bullet_summary_template_keeps_braces_and_inner_colons() {
2528 match parse_schema_bullet("- summary_template: {role} at {company} (x: y)") {
2529 SchemaBullet::SummaryTemplate(t) => assert_eq!(t, "{role} at {company} (x: y)"),
2530 other => panic!("expected SummaryTemplate, got {other:?}"),
2531 }
2532 }
2533
2534 #[test]
2535 fn schema_bullet_field_with_enum_modifier_is_not_a_directive() {
2536 match parse_schema_bullet("- status (enum: open, closed)") {
2539 SchemaBullet::Field(f) => {
2540 assert_eq!(f.name, "status");
2541 assert_eq!(
2542 f.enum_values,
2543 Some(vec!["open".to_string(), "closed".to_string()])
2544 );
2545 }
2546 other => panic!("expected Field, got {other:?}"),
2547 }
2548 }
2549
2550 #[test]
2551 fn parse_db_md_schema_captures_unique_and_summary_template() {
2552 let db = "---\ntype: db-md\nscope: x\nowner: y\n---\n\n## Schemas\n\n### contact\n- email (required, email)\n- unique: email\n- summary_template: {role} at {company}\n";
2553 let config = parse_db_md(db, Path::new("DB.md")).unwrap();
2554 let s = config.schemas.get("contact").expect("contact schema");
2555 assert_eq!(s.fields.len(), 1, "directives are not parsed as fields");
2556 assert_eq!(s.unique_keys, vec![vec!["email".to_string()]]);
2557 assert_eq!(s.summary_template.as_deref(), Some("{role} at {company}"));
2558 }
2559
2560 #[test]
2561 fn schema_bullet_shard_directive_parses_values() {
2562 assert!(matches!(
2563 parse_schema_bullet("- shard: by-date"),
2564 SchemaBullet::Shard(Some(true))
2565 ));
2566 assert!(matches!(
2567 parse_schema_bullet("- shard: flat"),
2568 SchemaBullet::Shard(Some(false))
2569 ));
2570 assert!(matches!(
2572 parse_schema_bullet("- shard: weekly"),
2573 SchemaBullet::Shard(None)
2574 ));
2575 assert!(matches!(
2578 parse_schema_bullet("- shardiness (string)"),
2579 SchemaBullet::Field(_)
2580 ));
2581 }
2582
2583 #[test]
2584 fn parse_db_md_schema_captures_shard_directive() {
2585 let db = "---\ntype: db-md\nscope: x\nowner: y\n---\n\n## Schemas\n\n### shipment\n- carrier (string)\n- shard: by-date\n\n### contact\n- shard: flat\n";
2586 let config = parse_db_md(db, Path::new("DB.md")).unwrap();
2587 let shipment = config.schemas.get("shipment").expect("shipment schema");
2588 assert_eq!(shipment.shard, Some(true));
2589 assert_eq!(
2590 shipment.fields.len(),
2591 1,
2592 "`shard:` is a directive, not a field"
2593 );
2594 assert_eq!(config.schemas.get("contact").unwrap().shard, Some(false));
2595 }
2596
2597 const CANONICAL_DB_MD: &str = "---\ntype: db-md\nscope: company\nowner: Sarah Chen\n---\n\n# Acme operations knowledge base\n\nCompany-scale institutional memory for Acme.\n\n## Agent instructions\n\nPrioritize creating `contact` records from new-sender emails. Use British English.\n\n## Policies\n\n### Frozen pages\n- `records/decisions/2026-q1-strategy.md` — finalized, do not modify.\n- `wiki/synthesis/2026-annual-plan.md` — signed-off plan.\n\n### Ignored types\n- `test`, `temp` — read but never synthesize.\n\n## Schemas\n\n### contact\n- name (required)\n- email (required, email)\n- company (required, link to records/companies/)\n- role (string)\n\n### expense\n- date (required, date)\n- amount (required)\n- currency (default USD)\n";
2600
2601 #[test]
2602 fn parse_db_md_extracts_all_canonical_sections() {
2603 let config = parse_db_md(CANONICAL_DB_MD, Path::new("DB.md")).unwrap();
2604
2605 let ai = config
2607 .agent_instructions
2608 .expect("agent instructions present");
2609 assert!(ai.starts_with("Prioritize creating"));
2610 assert!(!ai.contains("## Agent instructions"));
2611
2612 assert_eq!(
2614 config.frozen_pages,
2615 vec![
2616 PathBuf::from("records/decisions/2026-q1-strategy.md"),
2617 PathBuf::from("wiki/synthesis/2026-annual-plan.md"),
2618 ]
2619 );
2620
2621 assert_eq!(
2623 config.ignored_types,
2624 vec!["test".to_string(), "temp".to_string()]
2625 );
2626
2627 assert_eq!(config.schemas.len(), 2);
2629 let contact = config.schemas.get("contact").expect("contact schema");
2630 let names: Vec<&str> = contact.fields.iter().map(|f| f.name.as_str()).collect();
2631 assert_eq!(names, vec!["name", "email", "company", "role"]);
2632 assert!(contact.fields[0].required); assert_eq!(contact.fields[1].shape, Some(Shape::Email)); assert_eq!(
2635 contact.fields[2].link_prefix,
2636 Some(PathBuf::from("records/companies"))
2637 ); let expense = config.schemas.get("expense").expect("expense schema");
2640 let cur = expense
2641 .fields
2642 .iter()
2643 .find(|f| f.name == "currency")
2644 .unwrap();
2645 assert_eq!(cur.default, Some(Value::String("USD".into())));
2646 }
2647
2648 #[test]
2649 fn parse_db_md_handles_malformed_and_unknown_modifiers() {
2650 let text = "---\ntype: db-md\n---\n\n## Schemas\n- orphan (required)\n\n### ticket\n- priority (required, mystery, enum: low, high)\n- broken (\n";
2654 let config = parse_db_md(text, Path::new("DB.md")).unwrap();
2655
2656 assert_eq!(config.schemas.len(), 1);
2659 let ticket = config.schemas.get("ticket").expect("ticket schema");
2660 assert_eq!(ticket.fields.len(), 2);
2661
2662 let priority = &ticket.fields[0];
2663 assert!(priority.required);
2664 assert_eq!(priority.unknown_modifiers, vec!["mystery".to_string()]);
2665 assert_eq!(
2666 priority.enum_values,
2667 Some(vec!["low".to_string(), "high".to_string()])
2668 );
2669
2670 let broken = &ticket.fields[1];
2672 assert_eq!(broken.name, "broken");
2673 }
2674
2675 #[test]
2676 fn parse_db_md_missing_frontmatter_errors() {
2677 let text = "# No frontmatter\n\n## Agent instructions\nhi\n";
2678 let err = parse_db_md(text, Path::new("DB.md")).unwrap_err();
2679 assert!(matches!(err, ParseError::MissingFrontmatter { .. }));
2680 }
2681
2682 #[test]
2683 fn parse_db_md_absent_sections_default_empty() {
2684 let text = "---\ntype: db-md\n---\n\n# Title only\n";
2685 let config = parse_db_md(text, Path::new("DB.md")).unwrap();
2686 assert_eq!(config, Config::default());
2687 }
2688
2689 #[test]
2699 fn set_list_of_wiki_links_becomes_block_sequence_both_spellings() {
2700 for value in [
2701 "[[[records/contacts/a]], [[records/contacts/b]]]",
2702 r#"["[[records/contacts/a]]", "[[records/contacts/b]]"]"#,
2703 ] {
2704 let mut fm = Frontmatter::default();
2705 fm.set("attendees", value).unwrap();
2706
2707 let stored = fm.extra.get("attendees").expect("attendees set");
2709 let Value::Sequence(items) = stored else {
2710 panic!("attendees must be a Sequence, got {stored:?} for input {value}");
2711 };
2712 assert_eq!(items.len(), 2, "input {value}");
2713 assert_eq!(items[0], Value::String("[[records/contacts/a]]".into()));
2714 assert_eq!(items[1], Value::String("[[records/contacts/b]]".into()));
2715
2716 let links: Vec<_> = links_in_field_value(stored)
2719 .into_iter()
2720 .map(|l| l.target)
2721 .collect();
2722 assert_eq!(
2723 links,
2724 vec!["records/contacts/a", "records/contacts/b"],
2725 "input {value}"
2726 );
2727
2728 let yaml = fm.to_yaml();
2730 assert!(
2731 yaml.contains("attendees:\n"),
2732 "expected block list in:\n{yaml}"
2733 );
2734 assert!(
2735 !yaml.contains("attendees: '[["),
2736 "must not be a flow-form scalar string in:\n{yaml}"
2737 );
2738 }
2739 }
2740
2741 #[test]
2745 fn set_single_inline_wiki_link_stays_scalar() {
2746 let mut fm = Frontmatter::default();
2747 fm.set("company", "[[records/companies/tideform]]").unwrap();
2748 assert_eq!(
2749 fm.extra.get("company"),
2750 Some(&Value::String("[[records/companies/tideform]]".into())),
2751 );
2752 let links: Vec<_> = links_in_field_value(fm.extra.get("company").unwrap())
2754 .into_iter()
2755 .map(|l| l.target)
2756 .collect();
2757 assert_eq!(links, vec!["records/companies/tideform"]);
2758 }
2759
2760 #[test]
2763 fn set_non_link_values_stay_scalar_strings() {
2764 let mut fm = Frontmatter::default();
2765 fm.set("location", "Video call (remote)").unwrap();
2766 assert_eq!(
2767 fm.extra.get("location"),
2768 Some(&Value::String("Video call (remote)".into())),
2769 );
2770
2771 fm.set("note", "[draft, wip]").unwrap();
2774 assert_eq!(
2775 fm.extra.get("note"),
2776 Some(&Value::String("[draft, wip]".into()))
2777 );
2778 }
2779}