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 contents = render_file(frontmatter, body);
634
635 crate::fsx::write_atomic(path, contents.as_bytes())?;
639 Ok(())
640}
641
642pub fn write_file_new(
649 path: &Path,
650 frontmatter: &Frontmatter,
651 body: &str,
652) -> Result<(), ParseError> {
653 let contents = render_file(frontmatter, body);
654 crate::fsx::write_atomic_new(path, contents.as_bytes())?;
655 Ok(())
656}
657
658fn render_file(frontmatter: &Frontmatter, body: &str) -> String {
659 let yaml = frontmatter.to_yaml();
660 let mut contents = String::with_capacity(yaml.len() + body.len() + 8);
663 contents.push_str("---\n");
664 contents.push_str(&yaml);
665 contents.push_str("---\n");
666 contents.push_str(body);
667 contents
668}
669
670pub fn extract_wiki_links(body: &str, file: &Path) -> Vec<WikiLink> {
674 static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
675 let re = RE.get_or_init(|| {
676 regex::Regex::new(r"\[\[([^\[\]|]+?)(?:\|([^\[\]]*))?\]\]").expect("valid wiki-link regex")
679 });
680
681 let mut out = Vec::new();
682 for (line_idx, line) in body.lines().enumerate() {
683 for caps in re.captures_iter(line) {
684 let whole = caps.get(0).expect("group 0 always present");
685 let target = caps.get(1).map(|m| m.as_str()).unwrap_or("").to_string();
686 let display = caps.get(2).map(|m| m.as_str().to_string());
687 out.push(WikiLink {
688 is_full_path: target_is_full_path(&target),
689 has_md_extension: target_has_md_extension(&target),
690 target,
691 display,
692 location: (
693 file.to_path_buf(),
694 (line_idx as u32) + 1,
695 char_column(line, whole.start()),
696 ),
697 });
698 }
699 }
700 out
701}
702
703pub fn extract_markdown_links(body: &str, file: &Path) -> Vec<MarkdownLink> {
706 static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
707 let re = RE.get_or_init(|| {
708 regex::Regex::new(r"\[([^\[\]]*)\]\(([^)\s]*)\)").expect("valid markdown-link regex")
711 });
712
713 let mut out = Vec::new();
714 for (line_idx, line) in body.lines().enumerate() {
715 for caps in re.captures_iter(line) {
716 let whole = caps.get(0).expect("group 0 always present");
717 out.push(MarkdownLink {
718 text: caps.get(1).map(|m| m.as_str()).unwrap_or("").to_string(),
719 url: caps.get(2).map(|m| m.as_str()).unwrap_or("").to_string(),
720 location: (
721 file.to_path_buf(),
722 (line_idx as u32) + 1,
723 char_column(line, whole.start()),
724 ),
725 });
726 }
727 }
728 out
729}
730
731pub fn detect_flow_form_link_lists(frontmatter_yaml: &str) -> Vec<String> {
751 let value: Value = match serde_norway::from_str(frontmatter_yaml) {
752 Ok(v) => v,
753 Err(_) => return Vec::new(),
755 };
756 let Value::Mapping(map) = value else {
757 return Vec::new();
758 };
759
760 let mut out = Vec::new();
761 for (k, v) in &map {
762 if let Value::Sequence(items) = v {
763 let is_link_list = items.iter().any(|item| match item {
767 Value::Sequence(inner) => inner.iter().any(|x| matches!(x, Value::Sequence(_))),
768 _ => false,
769 });
770 if is_link_list {
771 if let Some(key) = k.as_str() {
772 out.push(key.to_string());
773 }
774 }
775 }
776 }
777 out
778}
779
780pub fn extract_sections(body: &str) -> Vec<Section> {
783 let lines: Vec<&str> = body.split_inclusive('\n').collect();
785
786 let mut levels: Vec<u8> = Vec::with_capacity(lines.len());
789 let mut fence: Option<(u8, usize)> = None;
790 for line in &lines {
791 let content = line.trim_end_matches(['\n', '\r']);
792 if let Some(f) = fence {
793 if is_closing_fence(content, f) {
794 fence = None;
795 }
796 levels.push(0);
797 continue;
798 }
799 if let Some(opened) = opening_fence(content) {
800 fence = Some(opened);
801 levels.push(0);
802 continue;
803 }
804 levels.push(heading_level(content));
805 }
806
807 let mut sections = Vec::new();
810 for (i, &lvl) in levels.iter().enumerate() {
811 if lvl < 2 {
812 continue;
813 }
814 let heading_line = lines[i].trim_end_matches(['\n', '\r']);
815 let heading = heading_text(heading_line, lvl);
816
817 let mut end = lines.len();
818 for (j, &other) in levels.iter().enumerate().skip(i + 1) {
819 if other != 0 && other <= lvl {
820 end = j;
821 break;
822 }
823 }
824
825 sections.push(Section {
826 heading,
827 level: lvl,
828 line: (i + 1) as u32,
829 body: lines[i..end].concat(),
830 });
831 }
832 sections
833}
834
835pub fn parse_db_md(text: &str, file: &Path) -> Result<Config, ParseError> {
840 let parsed = split_frontmatter(text, file)?;
844 let _frontmatter = Frontmatter::parse(&parsed.frontmatter_yaml, file)?;
845 let sections = extract_sections(&parsed.body);
846
847 let mut config = Config::default();
848 let mut current_h2: Option<String> = None;
850
851 for section in §ions {
852 match section.level {
853 2 => {
854 let name = section.heading.trim().to_ascii_lowercase();
855 current_h2 = Some(name.clone());
856 if name == "agent instructions" {
857 let prose = section_prose(§ion.body);
858 if !prose.is_empty() {
859 config.agent_instructions = Some(prose);
860 }
861 }
862 }
863 3 => {
864 let h2 = current_h2.as_deref().unwrap_or("");
865 let h3 = section.heading.trim().to_ascii_lowercase();
866 match (h2, h3.as_str()) {
867 ("policies", "frozen pages") => {
868 config.frozen_pages = bullet_lines(§ion.body)
869 .into_iter()
870 .map(|b| PathBuf::from(extract_path_bullet(&b)))
871 .collect();
872 }
873 ("policies", "ignored types") => {
874 config.ignored_types = bullet_lines(§ion.body)
875 .into_iter()
876 .flat_map(|b| extract_type_list_bullet(&b))
877 .collect();
878 }
879 ("schemas", _) => {
880 let type_name = section.heading.trim().to_string();
882 let mut schema = Schema::default();
883 for b in bullet_lines(§ion.body) {
884 match parse_schema_bullet(&b) {
885 SchemaBullet::Field(f) => schema.fields.push(f),
886 SchemaBullet::Unique(k) if !k.is_empty() => {
887 schema.unique_keys.push(k)
888 }
889 SchemaBullet::SummaryTemplate(t) if !t.is_empty() => {
890 schema.summary_template = Some(t)
891 }
892 SchemaBullet::Shard(Some(b)) => schema.shard = Some(b),
893 SchemaBullet::Unique(_)
896 | SchemaBullet::SummaryTemplate(_)
897 | SchemaBullet::Shard(None) => {}
898 }
899 }
900 config.schemas.insert(type_name, schema);
901 }
902 _ => {}
903 }
904 }
905 _ => {}
906 }
907 }
908
909 Ok(config)
910}
911
912#[derive(Debug)]
917enum SchemaBullet {
918 Field(FieldSpec),
920 Unique(Vec<String>),
922 SummaryTemplate(String),
924 Shard(Option<bool>),
927}
928
929fn parse_schema_bullet(bullet_line: &str) -> SchemaBullet {
935 let line = bullet_line.trim();
936 let line = line
937 .strip_prefix("- ")
938 .or_else(|| line.strip_prefix("* "))
939 .or_else(|| line.strip_prefix("+ "))
940 .or_else(|| line.strip_prefix('-'))
941 .unwrap_or(line)
942 .trim();
943
944 if let Some((head, rest)) = line.split_once(':') {
945 match head.trim().to_ascii_lowercase().as_str() {
946 "unique" => {
947 let fields = rest
948 .split(',')
949 .map(|f| f.trim().to_string())
950 .filter(|f| !f.is_empty())
951 .collect();
952 return SchemaBullet::Unique(fields);
953 }
954 "summary_template" => {
955 return SchemaBullet::SummaryTemplate(rest.trim().to_string());
956 }
957 "shard" => {
958 let v = match rest.trim().to_ascii_lowercase().as_str() {
961 "by-date" | "date" | "sharded" | "true" => Some(true),
962 "flat" | "none" | "false" => Some(false),
963 _ => None,
964 };
965 return SchemaBullet::Shard(v);
966 }
967 _ => {}
968 }
969 }
970
971 SchemaBullet::Field(parse_field_spec(bullet_line))
972}
973
974pub fn parse_field_spec(bullet_line: &str) -> FieldSpec {
978 let line = bullet_line.trim();
980 let line = line
981 .strip_prefix("- ")
982 .or_else(|| line.strip_prefix("* "))
983 .or_else(|| line.strip_prefix("+ "))
984 .or_else(|| line.strip_prefix('-'))
985 .unwrap_or(line)
986 .trim();
987
988 let (name, modifiers) = match line.find('(') {
991 Some(open) => {
992 let name = line[..open].trim().to_string();
993 let after = &line[open + 1..];
994 let mods = match after.rfind(')') {
995 Some(close) => &after[..close],
996 None => after, };
998 (name, mods.trim())
999 }
1000 None => (line.to_string(), ""),
1001 };
1002
1003 let mut spec = FieldSpec {
1004 name,
1005 ..FieldSpec::default()
1006 };
1007
1008 if modifiers.is_empty() {
1009 return spec;
1010 }
1011
1012 let raw: Vec<&str> = modifiers.split(',').collect();
1015 let mut i = 0;
1016 while i < raw.len() {
1017 let token = raw[i].trim();
1018 if token.is_empty() {
1019 i += 1;
1020 continue;
1021 }
1022 let lower = token.to_ascii_lowercase();
1023
1024 if lower == "required" {
1025 spec.required = true;
1026 } else if let Some(shape) = shape_from_str(&lower) {
1027 spec.shape = Some(shape);
1028 } else if let Some(rest) = lower.strip_prefix("link to ") {
1029 let prefix = token["link to ".len()..].trim().trim_end_matches('/');
1032 let _ = rest; spec.link_prefix = Some(PathBuf::from(prefix));
1034 } else if let Some(_rest) = lower.strip_prefix("default ") {
1035 let value = token["default ".len()..].trim().to_string();
1038 spec.default = Some(Value::String(value));
1039 } else if lower == "enum" {
1040 let values: Vec<String> = raw[i + 1..]
1043 .iter()
1044 .map(|v| v.trim().to_string())
1045 .filter(|v| !v.is_empty())
1046 .collect();
1047 spec.enum_values = Some(values);
1048 break; } else if lower.starts_with("enum:") {
1050 let mut joined = raw[i..].join(",");
1053 if let Some(colon) = joined.find(':') {
1054 joined = joined[colon + 1..].to_string();
1055 }
1056 let values: Vec<String> = joined
1057 .split(',')
1058 .map(|v| v.trim().to_string())
1059 .filter(|v| !v.is_empty())
1060 .collect();
1061 spec.enum_values = Some(values);
1062 break; } else {
1064 spec.unknown_modifiers.push(token.to_string());
1066 }
1067 i += 1;
1068 }
1069
1070 spec
1071}
1072
1073fn parse_timestamp(
1078 value: &Value,
1079 key: &str,
1080 file: &Path,
1081) -> Result<Option<DateTime<FixedOffset>>, ParseError> {
1082 match value {
1083 Value::Null => Ok(None),
1084 Value::String(s) => parse_rfc3339(s, key, file).map(Some),
1085 other => Err(ParseError::BadTimestamp {
1086 file: file.to_path_buf(),
1087 key: key.to_string(),
1088 value: format!("{other:?}"),
1089 }),
1090 }
1091}
1092
1093fn parse_rfc3339(s: &str, key: &str, file: &Path) -> Result<DateTime<FixedOffset>, ParseError> {
1095 DateTime::parse_from_rfc3339(s.trim()).map_err(|_| ParseError::BadTimestamp {
1096 file: file.to_path_buf(),
1097 key: key.to_string(),
1098 value: s.to_string(),
1099 })
1100}
1101
1102fn scalar_string(value: &Value) -> Option<String> {
1111 match value {
1112 Value::String(s) => Some(s.clone()),
1113 Value::Number(n) => Some(n.to_string()),
1114 Value::Bool(b) => Some(b.to_string()),
1115 _ => None,
1116 }
1117}
1118
1119fn parse_tags(value: &Value) -> Vec<String> {
1122 match value {
1123 Value::Sequence(items) => items
1124 .iter()
1125 .filter_map(|v| match v {
1126 Value::String(s) => Some(s.clone()),
1127 Value::Number(n) => Some(n.to_string()),
1128 Value::Bool(b) => Some(b.to_string()),
1129 _ => None,
1130 })
1131 .collect(),
1132 Value::String(s) => vec![s.clone()],
1133 _ => Vec::new(),
1134 }
1135}
1136
1137fn parse_wiki_link_str(s: &str) -> Option<WikiLink> {
1141 let s = s.trim();
1142 let inner = s.strip_prefix("[[")?.strip_suffix("]]")?;
1143 if inner.contains('[') || inner.contains(']') {
1146 return None;
1147 }
1148 let (target, display) = match inner.split_once('|') {
1149 Some((t, d)) => (t.to_string(), Some(d.to_string())),
1150 None => (inner.to_string(), None),
1151 };
1152 Some(WikiLink {
1153 is_full_path: target_is_full_path(&target),
1154 has_md_extension: target_has_md_extension(&target),
1155 target,
1156 display,
1157 location: (PathBuf::new(), 0, 0),
1158 })
1159}
1160
1161fn links_in_field_value(value: &Value) -> Vec<WikiLink> {
1189 if let Value::String(s) = value {
1191 return parse_wiki_link_str(s).into_iter().collect();
1192 }
1193 let Value::Sequence(items) = value else {
1194 return Vec::new();
1195 };
1196 if items.len() == 1 {
1200 if let Some(link) = unquoted_inline_link(&items[0]) {
1201 return vec![link];
1202 }
1203 }
1204 items
1207 .iter()
1208 .filter_map(|item| parse_wiki_link_str(item.as_str()?))
1209 .collect()
1210}
1211
1212fn canonicalize_extra_value(value: &Value) -> Value {
1243 match value {
1244 Value::String(s) => match parse_wiki_link_str(s) {
1248 Some(link) => Value::String(wiki_link_literal(&link)),
1249 None => value.clone(),
1250 },
1251 Value::Sequence(items) => {
1252 if items.len() == 1 {
1256 if let Some(link) = unquoted_inline_link(&items[0]) {
1257 return Value::String(wiki_link_literal(&link));
1258 }
1259 }
1260 let mut links = Vec::with_capacity(items.len());
1267 for item in items {
1268 match link_from_flow_list_item(item) {
1269 Some(link) => links.push(link),
1270 None => return value.clone(),
1271 }
1272 }
1273 if links.is_empty() {
1274 return value.clone();
1275 }
1276 Value::Sequence(
1277 links
1278 .iter()
1279 .map(|l| Value::String(wiki_link_literal(l)))
1280 .collect(),
1281 )
1282 }
1283 _ => value.clone(),
1285 }
1286}
1287
1288fn wiki_link_literal(link: &WikiLink) -> String {
1291 match &link.display {
1292 Some(d) => format!("[[{}|{}]]", link.target, d),
1293 None => format!("[[{}]]", link.target),
1294 }
1295}
1296
1297fn unquoted_inline_link(v: &Value) -> Option<WikiLink> {
1304 let Value::Sequence(items) = v else {
1305 return None;
1306 };
1307 if items.len() != 1 {
1308 return None;
1309 }
1310 let s = items[0].as_str()?;
1311 if s.contains('[') || s.contains(']') {
1313 return None;
1314 }
1315 parse_wiki_link_str(&format!("[[{s}]]"))
1316}
1317
1318fn parse_link_list_value(value: &str) -> Option<Value> {
1340 let trimmed = value.trim();
1341 if !(trimmed.starts_with('[') && trimmed.ends_with(']')) {
1345 return None;
1346 }
1347 let Ok(Value::Sequence(items)) = serde_norway::from_str::<Value>(trimmed) else {
1348 return None;
1349 };
1350 if items.len() == 1 && unquoted_inline_link(&items[0]).is_some() {
1355 return None;
1356 }
1357 let mut links = Vec::with_capacity(items.len());
1360 for item in &items {
1361 links.push(link_from_flow_list_item(item)?);
1362 }
1363 if links.is_empty() {
1364 return None;
1365 }
1366 let normalized = links
1370 .iter()
1371 .map(|l| Value::String(wiki_link_literal(l)))
1372 .collect();
1373 Some(Value::Sequence(normalized))
1374}
1375
1376fn link_from_flow_list_item(item: &Value) -> Option<WikiLink> {
1389 match item {
1390 Value::String(s) => parse_wiki_link_str(s),
1391 Value::Sequence(inner) => {
1392 if inner.len() == 1 {
1395 if let Some(link) = unquoted_inline_link(&inner[0]) {
1396 return Some(link);
1397 }
1398 }
1399 unquoted_inline_link(item)
1401 }
1402 _ => None,
1403 }
1404}
1405
1406fn target_is_full_path(target: &str) -> bool {
1410 let target = target.trim();
1411 match target.split_once('/') {
1412 Some((head, _rest)) => LAYER_DIRS.contains(&head),
1413 None => false,
1414 }
1415}
1416
1417fn target_has_md_extension(target: &str) -> bool {
1420 target.trim().ends_with(".md")
1421}
1422
1423fn char_column(line: &str, byte_offset: usize) -> u32 {
1425 (line[..byte_offset].chars().count() as u32) + 1
1426}
1427
1428fn shape_from_str(s: &str) -> Option<Shape> {
1430 match s {
1431 "string" => Some(Shape::String),
1432 "int" => Some(Shape::Int),
1433 "bool" => Some(Shape::Bool),
1434 "date" => Some(Shape::Date),
1435 "email" => Some(Shape::Email),
1436 "currency" => Some(Shape::Currency),
1437 "url" => Some(Shape::Url),
1438 _ => None,
1439 }
1440}
1441
1442fn heading_level(line: &str) -> u8 {
1446 let indent = line.len() - line.trim_start_matches(' ').len();
1447 if indent > 3 {
1448 return 0;
1449 }
1450 let rest = &line[indent..];
1451 let hashes = rest.len() - rest.trim_start_matches('#').len();
1452 if hashes == 0 || hashes > 6 {
1453 return 0;
1454 }
1455 let after = &rest[hashes..];
1456 if after.is_empty() || after.starts_with(' ') || after.starts_with('\t') {
1457 hashes as u8
1458 } else {
1459 0
1460 }
1461}
1462
1463fn heading_text(line: &str, level: u8) -> String {
1466 let indent = line.len() - line.trim_start_matches(' ').len();
1467 let after_hashes = &line[indent + level as usize..];
1468 let trimmed = after_hashes.trim();
1469 let no_trailing = trimmed.trim_end_matches('#');
1470 if no_trailing.len() == trimmed.len() {
1471 trimmed.to_string()
1472 } else {
1473 no_trailing.trim_end().to_string()
1474 }
1475}
1476
1477fn opening_fence(line: &str) -> Option<(u8, usize)> {
1479 let indent = line.len() - line.trim_start_matches(' ').len();
1480 if indent > 3 {
1481 return None;
1482 }
1483 let rest = &line[indent..];
1484 let byte = rest.bytes().next()?;
1485 if byte != b'`' && byte != b'~' {
1486 return None;
1487 }
1488 let run = rest.len() - rest.trim_start_matches(byte as char).len();
1489 if run < 3 {
1490 return None;
1491 }
1492 if byte == b'`' && rest[run..].contains('`') {
1494 return None;
1495 }
1496 Some((byte, run))
1497}
1498
1499fn is_closing_fence(line: &str, fence: (u8, usize)) -> bool {
1502 let (byte, open_len) = fence;
1503 let indent = line.len() - line.trim_start_matches(' ').len();
1504 if indent > 3 {
1505 return false;
1506 }
1507 let rest = &line[indent..];
1508 let run = rest.len() - rest.trim_start_matches(byte as char).len();
1509 if run < open_len {
1510 return false;
1511 }
1512 rest[run..].trim().is_empty()
1513}
1514
1515fn section_prose(section_body: &str) -> String {
1517 match section_body.split_once('\n') {
1518 Some((_heading, rest)) => rest.trim().to_string(),
1519 None => String::new(),
1520 }
1521}
1522
1523fn bullet_lines(section_body: &str) -> Vec<String> {
1526 section_body
1527 .lines()
1528 .skip(1) .map(str::trim)
1530 .filter(|l| l.starts_with("- ") || l.starts_with("* ") || l.starts_with("+ "))
1531 .map(|l| l.to_string())
1532 .collect()
1533}
1534
1535fn strip_bullet_comment(content: &str) -> &str {
1538 let mut cut = content.len();
1539 for sep in [" — ", " -- ", " – "] {
1540 if let Some(idx) = content.find(sep) {
1541 cut = cut.min(idx);
1542 }
1543 }
1544 content[..cut].trim()
1545}
1546
1547fn bullet_content(bullet: &str) -> &str {
1549 let t = bullet.trim();
1550 t.strip_prefix("- ")
1551 .or_else(|| t.strip_prefix("* "))
1552 .or_else(|| t.strip_prefix("+ "))
1553 .unwrap_or(t)
1554 .trim()
1555}
1556
1557fn extract_path_bullet(bullet: &str) -> String {
1560 let content = bullet_content(bullet);
1561 if let Some(start) = content.find('`') {
1563 if let Some(end_rel) = content[start + 1..].find('`') {
1564 return content[start + 1..start + 1 + end_rel].trim().to_string();
1565 }
1566 }
1567 strip_bullet_comment(content)
1569 .trim_matches('"')
1570 .trim_matches('\'')
1571 .trim()
1572 .to_string()
1573}
1574
1575fn extract_type_list_bullet(bullet: &str) -> Vec<String> {
1578 let content = strip_bullet_comment(bullet_content(bullet));
1579 content
1580 .split(',')
1581 .map(|t| {
1582 t.trim()
1583 .trim_matches('`')
1584 .trim_matches('"')
1585 .trim_matches('\'')
1586 .trim()
1587 .to_string()
1588 })
1589 .filter(|t| !t.is_empty())
1590 .collect()
1591}
1592
1593#[cfg(test)]
1594mod tests {
1595 use super::*;
1596 use std::path::Path;
1597 use tempfile::tempdir;
1598
1599 #[test]
1602 fn frozen_match_is_md_insensitive_both_directions() {
1603 let cfg = Config {
1607 frozen_pages: vec![PathBuf::from("records/decisions/q1")],
1608 ..Config::default()
1609 };
1610 assert_eq!(
1611 cfg.frozen_match(Path::new("records/decisions/q1.md")),
1612 Some(PathBuf::from("records/decisions/q1")),
1613 "extensionless policy entry must freeze the .md file"
1614 );
1615 assert!(cfg.is_frozen(Path::new("records/decisions/q1.md")));
1616
1617 let cfg = Config {
1619 frozen_pages: vec![PathBuf::from("records/decisions/q1.md")],
1620 ..Config::default()
1621 };
1622 assert_eq!(
1623 cfg.frozen_match(Path::new("records/decisions/q1")),
1624 Some(PathBuf::from("records/decisions/q1.md")),
1625 );
1626 assert!(cfg.is_frozen(Path::new("records/decisions/q1.md")));
1628 }
1629
1630 #[test]
1631 fn frozen_match_drops_leading_dot_slash() {
1632 let cfg = Config {
1633 frozen_pages: vec![PathBuf::from("records/decisions/q1.md")],
1634 ..Config::default()
1635 };
1636 assert!(cfg.is_frozen(Path::new("./records/decisions/q1.md")));
1637 assert!(cfg.is_frozen(Path::new("./records/decisions/q1")));
1638 }
1639
1640 #[test]
1641 fn frozen_match_returns_none_for_unlisted_and_prefix_paths() {
1642 let cfg = Config {
1643 frozen_pages: vec![PathBuf::from("records/decisions/q1")],
1644 ..Config::default()
1645 };
1646 assert!(cfg
1647 .frozen_match(Path::new("records/decisions/q2.md"))
1648 .is_none());
1649 assert!(cfg
1651 .frozen_match(Path::new("records/decisions/q1-draft.md"))
1652 .is_none());
1653 assert!(!cfg.is_frozen(Path::new("records/decisions/q11.md")));
1654 }
1655
1656 #[test]
1659 fn split_frontmatter_separates_yaml_and_verbatim_body() {
1660 let text = "---\ntype: contact\nsummary: x\n---\n# Heading\n\nBody line.\n";
1661 let p = split_frontmatter(text, Path::new("f.md")).unwrap();
1662 assert_eq!(p.frontmatter_yaml, "type: contact\nsummary: x\n");
1663 assert_eq!(p.body, "# Heading\n\nBody line.\n");
1665 }
1666
1667 #[test]
1668 fn split_frontmatter_preserves_body_without_trailing_newline() {
1669 let text = "---\ntype: x\n---\nno trailing newline";
1670 let p = split_frontmatter(text, Path::new("f.md")).unwrap();
1671 assert_eq!(p.body, "no trailing newline");
1672 }
1673
1674 #[test]
1675 fn split_frontmatter_empty_body_when_nothing_after_fence() {
1676 let text = "---\ntype: x\n---\n";
1677 let p = split_frontmatter(text, Path::new("f.md")).unwrap();
1678 assert_eq!(p.body, "");
1679 }
1680
1681 #[test]
1682 fn split_frontmatter_missing_opening_fence_errors() {
1683 let text = "# No frontmatter here\ntype: x\n";
1684 let err = split_frontmatter(text, Path::new("f.md")).unwrap_err();
1685 assert!(matches!(err, ParseError::MissingFrontmatter { .. }));
1686 }
1687
1688 #[test]
1689 fn split_frontmatter_leading_content_before_fence_rejected() {
1690 let text = "\n---\ntype: x\n---\nbody";
1693 let err = split_frontmatter(text, Path::new("f.md")).unwrap_err();
1694 assert!(matches!(err, ParseError::MissingFrontmatter { .. }));
1695 }
1696
1697 #[test]
1698 fn split_frontmatter_unterminated_block_errors() {
1699 let text = "---\ntype: x\nsummary: y\n";
1700 let err = split_frontmatter(text, Path::new("f.md")).unwrap_err();
1701 assert!(matches!(err, ParseError::MissingFrontmatter { .. }));
1702 }
1703
1704 #[test]
1707 fn parse_populates_typed_fields_and_routes_unknowns_to_extra() {
1708 let yaml = "type: contact\nid: sarah-chen\nsummary: Director of Ops\nstatus: active\ntags: [vip, renewal]\nemail: sarah@northstar.io\nrole: Director";
1709 let fm = Frontmatter::parse(yaml, Path::new("f.md")).unwrap();
1710 assert_eq!(fm.type_.as_deref(), Some("contact"));
1711 assert_eq!(fm.id.as_deref(), Some("sarah-chen"));
1712 assert_eq!(fm.summary.as_deref(), Some("Director of Ops"));
1713 assert_eq!(fm.status.as_deref(), Some("active"));
1714 assert_eq!(fm.tags, vec!["vip".to_string(), "renewal".to_string()]);
1715 assert!(fm.type_.is_some() && !fm.extra.contains_key("type"));
1717 assert!(!fm.extra.contains_key("tags"));
1718 assert_eq!(
1719 fm.extra.get("email").and_then(|v| v.as_str()),
1720 Some("sarah@northstar.io")
1721 );
1722 assert_eq!(
1723 fm.extra.get("role").and_then(|v| v.as_str()),
1724 Some("Director")
1725 );
1726 }
1727
1728 #[test]
1729 fn parse_reads_rfc3339_timestamps() {
1730 let yaml =
1731 "type: email\ncreated: 2026-05-27T08:00:00-07:00\nupdated: 2026-05-28T09:30:00-07:00";
1732 let fm = Frontmatter::parse(yaml, Path::new("f.md")).unwrap();
1733 let created = fm.created.expect("created parsed");
1734 assert_eq!(created.offset().utc_minus_local(), 7 * 3600);
1736 assert_eq!(created.to_rfc3339(), "2026-05-27T08:00:00-07:00");
1737 assert!(fm.updated.is_some());
1738 }
1739
1740 #[test]
1741 fn parse_rejects_non_rfc3339_timestamp() {
1742 let yaml = "type: email\ncreated: 2026-05-27";
1745 let err = Frontmatter::parse(yaml, Path::new("bad.md")).unwrap_err();
1746 match err {
1747 ParseError::BadTimestamp { key, value, .. } => {
1748 assert_eq!(key, "created");
1749 assert_eq!(value, "2026-05-27");
1750 }
1751 other => panic!("expected BadTimestamp, got {other:?}"),
1752 }
1753 }
1754
1755 #[test]
1756 fn parse_malformed_yaml_errors() {
1757 let yaml = "type: contact\n bad: : :\n- nope";
1759 let err = Frontmatter::parse(yaml, Path::new("bad.md")).unwrap_err();
1760 assert!(matches!(err, ParseError::MalformedYaml { .. }));
1761 }
1762
1763 #[test]
1764 fn frontmatter_with_yaml_tag_on_mapping_does_not_panic() {
1765 let fm = Frontmatter::parse("!mytag\ntype: contact\nsummary: hi\n", Path::new("x.md"))
1770 .expect("tagged-mapping frontmatter must parse, not panic");
1771 assert_eq!(fm.type_.as_deref(), Some("contact"));
1772 assert!(Frontmatter::parse("- a\n- b\n", Path::new("x.md")).is_err());
1775 }
1776
1777 #[test]
1778 fn parse_empty_block_is_empty_frontmatter() {
1779 let fm = Frontmatter::parse("", Path::new("f.md")).unwrap();
1780 assert_eq!(fm, Frontmatter::default());
1781 }
1782
1783 #[test]
1784 fn parse_scalar_top_level_is_malformed() {
1785 let err = Frontmatter::parse("just a string", Path::new("f.md")).unwrap_err();
1787 assert!(matches!(err, ParseError::MalformedYaml { .. }));
1788 }
1789
1790 #[test]
1793 fn to_yaml_emits_canonical_key_order() {
1794 let mut fm = Frontmatter {
1795 type_: Some("contact".into()),
1796 id: Some("sarah-chen".into()),
1797 summary: Some("Director of Ops".into()),
1798 status: Some("active".into()),
1799 tags: vec!["vip".into()],
1800 created: Some(DateTime::parse_from_rfc3339("2026-05-27T08:00:00-07:00").unwrap()),
1801 updated: Some(DateTime::parse_from_rfc3339("2026-05-28T09:30:00-07:00").unwrap()),
1802 ..Default::default()
1803 };
1804 fm.extra
1807 .insert("role".into(), Value::String("Director".into()));
1808 fm.extra.insert(
1809 "company".into(),
1810 Value::String("[[records/companies/northstar]]".into()),
1811 );
1812
1813 let yaml = fm.to_yaml();
1814 let keys: Vec<&str> = yaml
1815 .lines()
1816 .filter(|l| !l.starts_with(['-', ' ']) && l.contains(':'))
1817 .map(|l| l.split(':').next().unwrap())
1818 .collect();
1819 assert_eq!(
1820 keys,
1821 vec![
1822 "type", "id", "created", "updated", "summary", "company", "role", "status", "tags",
1826 ],
1827 "canonical order violated; got:\n{yaml}"
1828 );
1829 assert!(
1831 yaml.contains("2026-05-27T08:00:00-07:00"),
1832 "created timestamp missing; got:\n{yaml}"
1833 );
1834 let reparsed = Frontmatter::parse(&yaml, Path::new("rt.md")).unwrap();
1836 assert_eq!(reparsed.created, fm.created);
1837 assert_eq!(reparsed.updated, fm.updated);
1838 }
1839
1840 #[test]
1841 fn to_yaml_omits_absent_optional_fields() {
1842 let fm = Frontmatter {
1843 type_: Some("note".into()),
1844 ..Default::default()
1845 };
1846 let yaml = fm.to_yaml();
1847 assert!(yaml.contains("type: note"));
1848 assert!(!yaml.contains("status"));
1849 assert!(!yaml.contains("tags"));
1850 assert!(!yaml.contains("summary"));
1851 }
1852
1853 #[test]
1856 fn regression_parse_preserves_non_string_scalar_universal_fields() {
1857 let yaml = "type: 42\nid: 100\nsummary: 2026\nstatus: 0";
1863 let fm = Frontmatter::parse(yaml, Path::new("x.md")).unwrap();
1864 assert_eq!(fm.type_.as_deref(), Some("42"), "type scalar dropped");
1865 assert_eq!(fm.id.as_deref(), Some("100"), "id scalar dropped");
1866 assert_eq!(
1867 fm.summary.as_deref(),
1868 Some("2026"),
1869 "summary scalar dropped"
1870 );
1871 assert_eq!(fm.status.as_deref(), Some("0"), "status scalar dropped");
1872 assert_eq!(
1874 fm.get("summary")
1875 .and_then(|v| v.as_str().map(str::to_string)),
1876 Some("2026".to_string())
1877 );
1878 }
1879
1880 #[test]
1881 fn regression_format_round_trip_does_not_delete_numeric_frontmatter() {
1882 let dir = tempdir().unwrap();
1887 let path = dir.path().join("x.md");
1888 let original = "---\ntype: contact\nid: 100\nsummary: 2026\nstatus: 0\n---\nbody\n";
1889 std::fs::write(&path, original).unwrap();
1890
1891 let (fm, body) = read_file(&path).unwrap();
1893 write_file(&path, &fm, &body).unwrap();
1894
1895 let after = std::fs::read_to_string(&path).unwrap();
1896 let reparsed = Frontmatter::parse(
1898 &split_frontmatter(&after, &path).unwrap().frontmatter_yaml,
1899 &path,
1900 )
1901 .unwrap();
1902 assert_eq!(reparsed.type_.as_deref(), Some("contact"));
1903 assert_eq!(reparsed.id.as_deref(), Some("100"), "id deleted by format");
1904 assert_eq!(
1905 reparsed.summary.as_deref(),
1906 Some("2026"),
1907 "summary deleted by format"
1908 );
1909 assert_eq!(
1910 reparsed.status.as_deref(),
1911 Some("0"),
1912 "status deleted by format"
1913 );
1914 assert_eq!(body, "body\n");
1916 }
1917
1918 #[test]
1921 fn regression_split_frontmatter_tolerates_leading_utf8_bom() {
1922 let text = "\u{feff}---\ntype: note\nsummary: x\n---\nbody\n";
1928 let parsed = split_frontmatter(text, Path::new("note.md")).unwrap();
1929 assert_eq!(parsed.frontmatter_yaml, "type: note\nsummary: x\n");
1930 assert_eq!(parsed.body, "body\n");
1932 assert!(!parsed.body.starts_with('\u{feff}'));
1933 }
1934
1935 #[test]
1936 fn regression_read_file_parses_bom_prefixed_file() {
1937 let dir = tempdir().unwrap();
1941 let path = dir.path().join("note.md");
1942 std::fs::write(&path, "\u{feff}---\ntype: note\nsummary: x\n---\nbody\n").unwrap();
1943
1944 let (fm, body) = read_file(&path).expect("BOM-prefixed file must parse");
1945 assert_eq!(fm.type_.as_deref(), Some("note"));
1946 assert_eq!(fm.summary.as_deref(), Some("x"));
1947 assert_eq!(body, "body\n");
1948 }
1949
1950 #[test]
1951 fn to_yaml_preserves_unquoted_scalar_wiki_link_round_trip() {
1952 let yaml = "type: contact\ncompany: [[records/companies/northstar]]";
1962 let fm = Frontmatter::parse(yaml, Path::new("c.md")).unwrap();
1963 assert!(fm.extra.get("company").and_then(|v| v.as_str()).is_none());
1965
1966 let out = fm.to_yaml();
1967 assert!(
1970 out.contains("[[records/companies/northstar]]"),
1971 "canonical writer dropped the wiki-link brackets; got:\n{out}"
1972 );
1973 assert!(
1974 !out.contains("- - "),
1975 "canonical writer emitted a nested block sequence (link corrupted); got:\n{out}"
1976 );
1977
1978 let reparsed = Frontmatter::parse(&out, Path::new("c.md")).unwrap();
1981 let fields = reparsed.link_fields();
1982 let links: Vec<(&str, &str, Option<&str>)> = fields
1983 .iter()
1984 .map(|(k, l)| (k.as_str(), l.target.as_str(), l.display.as_deref()))
1985 .collect();
1986 assert_eq!(
1987 links,
1988 vec![("company", "records/companies/northstar", None)]
1989 );
1990
1991 assert_eq!(
1994 reparsed.to_yaml(),
1995 out,
1996 "to_yaml is not idempotent on links"
1997 );
1998 }
1999
2000 #[test]
2001 fn to_yaml_preserves_unquoted_scalar_link_with_display() {
2002 let yaml = "type: contact\ncompany: [[records/companies/northstar|Northstar]]";
2004 let fm = Frontmatter::parse(yaml, Path::new("c.md")).unwrap();
2005 let out = fm.to_yaml();
2006 assert!(
2007 out.contains("[[records/companies/northstar|Northstar]]"),
2008 "display segment lost on round-trip; got:\n{out}"
2009 );
2010 let reparsed = Frontmatter::parse(&out, Path::new("c.md")).unwrap();
2011 let f = reparsed.link_fields();
2012 assert_eq!(f.len(), 1);
2013 assert_eq!(f[0].1.target, "records/companies/northstar");
2014 assert_eq!(f[0].1.display.as_deref(), Some("Northstar"));
2015 }
2016
2017 #[test]
2018 fn to_yaml_does_not_mangle_link_list_or_plain_nested_sequence() {
2019 let yaml = "type: meeting\nattendees:\n - \"[[records/contacts/elena]]\"\n - \"[[records/contacts/sarah]]\"\nmatrix:\n - - 1\n - 2";
2023 let fm = Frontmatter::parse(yaml, Path::new("m.md")).unwrap();
2024 let out = fm.to_yaml();
2025
2026 assert!(out.contains("[[records/contacts/elena]]"), "got:\n{out}");
2028 assert!(out.contains("[[records/contacts/sarah]]"), "got:\n{out}");
2029
2030 let reparsed = Frontmatter::parse(&out, Path::new("m.md")).unwrap();
2031 let fields = reparsed.link_fields();
2032 let attendees: Vec<&str> = fields
2033 .iter()
2034 .filter(|(k, _)| k == "attendees")
2035 .map(|(_, l)| l.target.as_str())
2036 .collect();
2037 assert_eq!(
2038 attendees,
2039 vec!["records/contacts/elena", "records/contacts/sarah"]
2040 );
2041 assert_eq!(reparsed.extra.get("matrix"), fm.extra.get("matrix"));
2043 }
2044
2045 #[test]
2048 fn write_then_read_roundtrips_and_preserves_body_verbatim() {
2049 let dir = tempdir().unwrap();
2050 let path = dir.path().join("sources/emails/x.md");
2051 let body = "# Subject\n\nHello,\n\nSee [[records/contacts/sarah-chen]].\n";
2052 let mut fm = Frontmatter {
2053 type_: Some("email".into()),
2054 summary: Some("renewal note".into()),
2055 created: Some(DateTime::parse_from_rfc3339("2026-05-27T08:00:00-07:00").unwrap()),
2056 ..Default::default()
2057 };
2058 fm.extra
2059 .insert("from".into(), Value::String("elena@northstar.io".into()));
2060
2061 write_file(&path, &fm, body).unwrap();
2062
2063 let (read_fm, read_body) = read_file(&path).unwrap();
2064 assert_eq!(read_body, body, "body must be preserved byte-for-byte");
2065 assert_eq!(read_fm.type_.as_deref(), Some("email"));
2066 assert_eq!(read_fm.summary.as_deref(), Some("renewal note"));
2067 assert_eq!(
2068 read_fm.extra.get("from").and_then(|v| v.as_str()),
2069 Some("elena@northstar.io")
2070 );
2071 let raw = std::fs::read_to_string(&path).unwrap();
2073 assert!(raw.starts_with("---\n"));
2074 assert!(raw.ends_with(body));
2075 }
2076
2077 #[test]
2078 fn roundtrip_modify_summary_then_write_changes_only_summary() {
2079 let dir = tempdir().unwrap();
2080 let path = dir.path().join("records/contacts/sarah.md");
2081 let body = "Long-form operator notes about Sarah.\n";
2082 let fm = Frontmatter {
2083 type_: Some("contact".into()),
2084 summary: Some("old summary".into()),
2085 ..Default::default()
2086 };
2087 write_file(&path, &fm, body).unwrap();
2088
2089 let (mut fm2, body2) = read_file(&path).unwrap();
2091 fm2.summary = Some("new summary".into());
2092 write_file(&path, &fm2, &body2).unwrap();
2093
2094 let (fm3, body3) = read_file(&path).unwrap();
2095 assert_eq!(fm3.summary.as_deref(), Some("new summary"));
2096 assert_eq!(fm3.type_.as_deref(), Some("contact"));
2097 assert_eq!(body3, body, "body unchanged across the round-trip");
2098 }
2099
2100 #[test]
2101 fn roundtrip_preserves_handwritten_unquoted_scalar_wiki_link_on_disk() {
2102 let dir = tempdir().unwrap();
2109 let path = dir.path().join("records/contacts/sarah-chen.md");
2110 let file = "---\ntype: contact\nid: sarah-chen\nsummary: Director of Ops\ncompany: [[records/companies/northstar]]\n---\n# Sarah Chen\n\nNotes.\n";
2111 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
2112 std::fs::write(&path, file).unwrap();
2113
2114 let (fm, body) = read_file(&path).unwrap();
2116 write_file(&path, &fm, &body).unwrap();
2117
2118 let raw = std::fs::read_to_string(&path).unwrap();
2120 assert!(
2121 raw.contains("[[records/companies/northstar]]"),
2122 "on-disk wiki-link brackets were destroyed; got:\n{raw}"
2123 );
2124 assert!(
2125 !raw.contains("- - "),
2126 "on-disk value became a nested block sequence; got:\n{raw}"
2127 );
2128
2129 let (fm2, _) = read_file(&path).unwrap();
2131 let fields = fm2.link_fields();
2132 let links: Vec<(&str, &str)> = fields
2133 .iter()
2134 .map(|(k, l)| (k.as_str(), l.target.as_str()))
2135 .collect();
2136 assert_eq!(links, vec![("company", "records/companies/northstar")]);
2137 }
2138
2139 #[test]
2140 fn write_file_does_not_leave_temp_files_behind() {
2141 let dir = tempdir().unwrap();
2142 let path = dir.path().join("records/x.md");
2143 let fm = Frontmatter {
2144 type_: Some("note".into()),
2145 ..Default::default()
2146 };
2147 write_file(&path, &fm, "body\n").unwrap();
2148 let entries: Vec<String> = std::fs::read_dir(path.parent().unwrap())
2150 .unwrap()
2151 .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
2152 .collect();
2153 assert_eq!(entries, vec!["x.md".to_string()]);
2154 }
2155
2156 #[test]
2159 fn is_content_file_recognizes_layers_and_excludes_meta() {
2160 assert!(Frontmatter::is_content_file(Path::new(
2161 "sources/emails/2026-05-22.md"
2162 )));
2163 assert!(Frontmatter::is_content_file(Path::new(
2164 "records/contacts/sarah-chen.md"
2165 )));
2166 assert!(Frontmatter::is_content_file(Path::new(
2167 "wiki/people/sarah-chen.md"
2168 )));
2169 assert!(Frontmatter::is_content_file(Path::new(
2171 "/home/db/records/companies/northstar.md"
2172 )));
2173 assert!(!Frontmatter::is_content_file(Path::new(
2175 "records/contacts/index.md"
2176 )));
2177 assert!(!Frontmatter::is_content_file(Path::new("index.md")));
2178 assert!(!Frontmatter::is_content_file(Path::new("DB.md")));
2180 assert!(!Frontmatter::is_content_file(Path::new("log.md")));
2181 }
2182
2183 #[test]
2186 fn effective_id_prefers_explicit_then_derives_from_path() {
2187 let with_id = Frontmatter {
2188 id: Some("explicit-id".into()),
2189 ..Default::default()
2190 };
2191 assert_eq!(
2192 with_id.effective_id(Path::new("wiki/people/sarah-chen.md")),
2193 "explicit-id"
2194 );
2195 let no_id = Frontmatter::default();
2196 assert_eq!(
2197 no_id.effective_id(Path::new("wiki/people/sarah-chen.md")),
2198 "sarah-chen"
2199 );
2200 }
2201
2202 #[test]
2205 fn set_routes_universal_and_custom_keys() {
2206 let mut fm = Frontmatter::default();
2207 fm.set("type", "contact").unwrap();
2208 fm.set("summary", "hi").unwrap();
2209 fm.set("company", "[[records/companies/northstar]]")
2210 .unwrap();
2211 assert_eq!(fm.type_.as_deref(), Some("contact"));
2212 assert_eq!(fm.summary.as_deref(), Some("hi"));
2213 assert_eq!(
2215 fm.extra.get("company").and_then(|v| v.as_str()),
2216 Some("[[records/companies/northstar]]")
2217 );
2218 assert_eq!(
2220 fm.get("type").and_then(|v| v.as_str().map(String::from)),
2221 Some("contact".into())
2222 );
2223 assert_eq!(
2224 fm.get("company").and_then(|v| v.as_str().map(String::from)),
2225 Some("[[records/companies/northstar]]".into())
2226 );
2227 assert!(fm.get("nonexistent").is_none());
2228 }
2229
2230 #[test]
2231 fn set_timestamp_validates_rfc3339() {
2232 let mut fm = Frontmatter::default();
2233 fm.set("created", "2026-05-27T08:00:00-07:00").unwrap();
2234 assert!(fm.created.is_some());
2235 let err = fm.set("updated", "not-a-date").unwrap_err();
2236 assert!(matches!(err, ParseError::BadTimestamp { .. }));
2237 }
2238
2239 #[test]
2242 fn extract_wiki_links_flags_full_path_short_form_and_extension() {
2243 let body = "See [[records/contacts/sarah-chen]] and [[sarah-chen]].\nAlso [[wiki/people/sarah-chen.md|Sarah]].\n";
2244 let links = extract_wiki_links(body, Path::new("doc.md"));
2245 assert_eq!(links.len(), 3);
2246
2247 assert_eq!(links[0].target, "records/contacts/sarah-chen");
2249 assert!(links[0].is_full_path);
2250 assert!(!links[0].has_md_extension);
2251 assert_eq!(links[0].display, None);
2252 assert_eq!(links[0].location.1, 1, "first link on line 1");
2253
2254 assert_eq!(links[1].target, "sarah-chen");
2256 assert!(!links[1].is_full_path, "bare target is short-form");
2257
2258 assert_eq!(links[2].target, "wiki/people/sarah-chen.md");
2260 assert!(links[2].is_full_path);
2261 assert!(links[2].has_md_extension);
2262 assert_eq!(links[2].display.as_deref(), Some("Sarah"));
2263 assert_eq!(links[2].location.1, 2);
2264 }
2265
2266 #[test]
2267 fn extract_wiki_links_reports_1_based_column_counting_chars() {
2268 let body = "café [[records/x/y]]";
2270 let links = extract_wiki_links(body, Path::new("d.md"));
2271 assert_eq!(links.len(), 1);
2272 assert_eq!(links[0].location.2, 6);
2274 }
2275
2276 #[test]
2277 fn extract_wiki_links_ignores_a_lone_path_without_brackets() {
2278 let links = extract_wiki_links(
2279 "records/contacts/sarah-chen is not a link",
2280 Path::new("d.md"),
2281 );
2282 assert!(links.is_empty());
2283 }
2284
2285 #[test]
2288 fn extract_markdown_links_captures_external_and_not_wiki_links() {
2289 let body =
2290 "See [the thread](https://x.com/a) and [[records/contacts/sarah-chen]] internally.\n";
2291 let md = extract_markdown_links(body, Path::new("d.md"));
2292 assert_eq!(
2293 md.len(),
2294 1,
2295 "wiki-link must not be captured as a markdown link"
2296 );
2297 assert_eq!(md[0].text, "the thread");
2298 assert_eq!(md[0].url, "https://x.com/a");
2299 assert_eq!(md[0].location.1, 1);
2300
2301 let wl = extract_wiki_links(body, Path::new("d.md"));
2303 assert_eq!(wl.len(), 1);
2304 assert_eq!(wl[0].target, "records/contacts/sarah-chen");
2305 }
2306
2307 #[test]
2310 fn link_fields_extracts_scalar_list_and_summary_links() {
2311 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";
2315 let fm = Frontmatter::parse(yaml, Path::new("m.md")).unwrap();
2316 assert!(fm.extra.get("company").and_then(|v| v.as_str()).is_some());
2318 let fields = fm.link_fields();
2319
2320 let company: Vec<&str> = fields
2322 .iter()
2323 .filter(|(k, _)| k == "company")
2324 .map(|(_, l)| l.target.as_str())
2325 .collect();
2326 assert_eq!(company, vec!["records/companies/northstar"]);
2327 let attendees: Vec<&str> = fields
2329 .iter()
2330 .filter(|(k, _)| k == "attendees")
2331 .map(|(_, l)| l.target.as_str())
2332 .collect();
2333 assert_eq!(
2334 attendees,
2335 vec!["records/contacts/elena", "records/contacts/sarah"]
2336 );
2337 assert_eq!(fields.iter().filter(|(k, _)| k == "summary").count(), 1);
2339 assert_eq!(fields.iter().filter(|(k, _)| k == "notes").count(), 0);
2341 }
2342
2343 #[test]
2344 fn link_fields_surfaces_canonical_unquoted_scalar_link() {
2345 let yaml = "type: meeting\ncompany: [[records/companies/northstar]]";
2351 let fm = Frontmatter::parse(yaml, Path::new("m.md")).unwrap();
2352 assert!(fm.extra.get("company").and_then(|v| v.as_str()).is_none());
2354
2355 let fields = fm.link_fields();
2356 let links: Vec<(&str, &str, Option<&str>)> = fields
2357 .iter()
2358 .map(|(k, l)| (k.as_str(), l.target.as_str(), l.display.as_deref()))
2359 .collect();
2360 assert_eq!(
2361 links,
2362 vec![("company", "records/companies/northstar", None)]
2363 );
2364
2365 let fm2 = Frontmatter::parse(
2367 "type: meeting\ncompany: [[records/companies/northstar|Northstar]]",
2368 Path::new("m.md"),
2369 )
2370 .unwrap();
2371 let f2 = fm2.link_fields();
2372 assert_eq!(f2.len(), 1);
2373 assert_eq!(f2[0].0, "company");
2374 assert_eq!(f2[0].1.target, "records/companies/northstar");
2375 assert_eq!(f2[0].1.display.as_deref(), Some("Northstar"));
2376 }
2377
2378 #[test]
2379 fn link_fields_ignores_plain_one_item_flow_list() {
2380 let yaml = "type: contact\naliases: [foo]";
2384 let fm = Frontmatter::parse(yaml, Path::new("c.md")).unwrap();
2385 assert_eq!(fm.link_fields(), Vec::new());
2386 }
2387
2388 #[test]
2391 fn detect_flow_form_flags_list_misencodings_not_scalars() {
2392 let bad = "attendees: [[[records/x]], [[records/y]]]\nscalar_inline: [[records/z]]";
2395 let flagged = detect_flow_form_link_lists(bad);
2396 assert_eq!(flagged, vec!["attendees".to_string()]);
2397
2398 let unquoted_block = "attendees:\n - [[records/x]]\n - [[records/y]]";
2400 assert_eq!(
2401 detect_flow_form_link_lists(unquoted_block),
2402 vec!["attendees".to_string()]
2403 );
2404
2405 let good = "attendees:\n - \"[[records/x]]\"\n - \"[[records/y]]\"";
2407 assert!(detect_flow_form_link_lists(good).is_empty());
2408
2409 let plain = "tags: [a, b, c]";
2411 assert!(detect_flow_form_link_lists(plain).is_empty());
2412 }
2413
2414 #[test]
2417 fn extract_sections_levels_nesting_and_boundaries() {
2418 let body = "intro text\n## First\nalpha\n### Sub\nbeta\n## Second\ngamma\n";
2419 let secs = extract_sections(body);
2420 let headings: Vec<(&str, u8)> =
2421 secs.iter().map(|s| (s.heading.as_str(), s.level)).collect();
2422 assert_eq!(headings, vec![("First", 2), ("Sub", 3), ("Second", 2)]);
2423
2424 let first = &secs[0];
2426 assert!(first.body.contains("alpha"));
2427 assert!(first.body.contains("### Sub"));
2428 assert!(first.body.contains("beta"));
2429 assert!(!first.body.contains("Second"));
2430
2431 let sub = &secs[1];
2433 assert!(sub.body.contains("beta"));
2434 assert!(!sub.body.contains("gamma"));
2435
2436 assert_eq!(first.line, 2);
2438 assert_eq!(secs[2].line, 6);
2439 }
2440
2441 #[test]
2442 fn extract_sections_ignores_headings_in_fenced_code() {
2443 let body = "## Real\n```\n## Fake heading in code\n```\nafter\n";
2444 let secs = extract_sections(body);
2445 assert_eq!(secs.len(), 1);
2446 assert_eq!(secs[0].heading, "Real");
2447 assert!(secs[0].body.contains("## Fake heading in code"));
2449 }
2450
2451 #[test]
2454 fn parse_field_spec_required_and_shape() {
2455 let f = parse_field_spec("- email (required, email)");
2456 assert_eq!(f.name, "email");
2457 assert!(f.required);
2458 assert_eq!(f.shape, Some(Shape::Email));
2459 assert!(f.unknown_modifiers.is_empty());
2460 }
2461
2462 #[test]
2463 fn parse_field_spec_link_prefix_strips_trailing_slash() {
2464 let f = parse_field_spec("- company (required, link to records/companies/)");
2465 assert!(f.required);
2466 assert_eq!(f.link_prefix, Some(PathBuf::from("records/companies")));
2467 assert_eq!(f.shape, None);
2468 }
2469
2470 #[test]
2471 fn parse_field_spec_default_preserves_case_and_value() {
2472 let f = parse_field_spec("- currency (default USD)");
2473 assert_eq!(f.name, "currency");
2474 assert_eq!(f.default, Some(Value::String("USD".into())));
2475 }
2476
2477 #[test]
2478 fn parse_field_spec_enum_captures_comma_list_as_last_modifier() {
2479 let f = parse_field_spec("- status (required, enum: open, closed, pending)");
2480 assert!(f.required);
2481 assert_eq!(
2482 f.enum_values,
2483 Some(vec![
2484 "open".to_string(),
2485 "closed".to_string(),
2486 "pending".to_string()
2487 ])
2488 );
2489 }
2490
2491 #[test]
2492 fn parse_field_spec_bare_enum_keyword_is_not_itself_a_value() {
2493 let f = parse_field_spec("- status (required, enum, open, closed)");
2496 assert!(f.required);
2497 assert_eq!(
2498 f.enum_values,
2499 Some(vec!["open".to_string(), "closed".to_string()])
2500 );
2501 }
2502
2503 #[test]
2504 fn parse_field_spec_unknown_modifier_is_captured_not_errored() {
2505 let f = parse_field_spec("- weird (required, frobnicate, string)");
2506 assert!(f.required);
2507 assert_eq!(f.shape, Some(Shape::String));
2508 assert_eq!(f.unknown_modifiers, vec!["frobnicate".to_string()]);
2509 }
2510
2511 #[test]
2512 fn parse_field_spec_no_parens_is_freeform_optional() {
2513 let f = parse_field_spec("- nickname");
2514 assert_eq!(f.name, "nickname");
2515 assert!(!f.required);
2516 assert_eq!(f.shape, None);
2517 assert!(f.link_prefix.is_none());
2518 assert!(f.enum_values.is_none());
2519 assert!(f.unknown_modifiers.is_empty());
2520 }
2521
2522 #[test]
2525 fn schema_bullet_unique_single_field() {
2526 match parse_schema_bullet("- unique: email") {
2527 SchemaBullet::Unique(fields) => assert_eq!(fields, vec!["email".to_string()]),
2528 other => panic!("expected Unique, got {other:?}"),
2529 }
2530 }
2531
2532 #[test]
2533 fn schema_bullet_unique_compound_trims_and_splits() {
2534 match parse_schema_bullet("- unique: date, amount , vendor") {
2535 SchemaBullet::Unique(fields) => assert_eq!(
2536 fields,
2537 vec![
2538 "date".to_string(),
2539 "amount".to_string(),
2540 "vendor".to_string()
2541 ]
2542 ),
2543 other => panic!("expected Unique, got {other:?}"),
2544 }
2545 }
2546
2547 #[test]
2548 fn schema_bullet_summary_template_keeps_braces_and_inner_colons() {
2549 match parse_schema_bullet("- summary_template: {role} at {company} (x: y)") {
2550 SchemaBullet::SummaryTemplate(t) => assert_eq!(t, "{role} at {company} (x: y)"),
2551 other => panic!("expected SummaryTemplate, got {other:?}"),
2552 }
2553 }
2554
2555 #[test]
2556 fn schema_bullet_field_with_enum_modifier_is_not_a_directive() {
2557 match parse_schema_bullet("- status (enum: open, closed)") {
2560 SchemaBullet::Field(f) => {
2561 assert_eq!(f.name, "status");
2562 assert_eq!(
2563 f.enum_values,
2564 Some(vec!["open".to_string(), "closed".to_string()])
2565 );
2566 }
2567 other => panic!("expected Field, got {other:?}"),
2568 }
2569 }
2570
2571 #[test]
2572 fn parse_db_md_schema_captures_unique_and_summary_template() {
2573 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";
2574 let config = parse_db_md(db, Path::new("DB.md")).unwrap();
2575 let s = config.schemas.get("contact").expect("contact schema");
2576 assert_eq!(s.fields.len(), 1, "directives are not parsed as fields");
2577 assert_eq!(s.unique_keys, vec![vec!["email".to_string()]]);
2578 assert_eq!(s.summary_template.as_deref(), Some("{role} at {company}"));
2579 }
2580
2581 #[test]
2582 fn schema_bullet_shard_directive_parses_values() {
2583 assert!(matches!(
2584 parse_schema_bullet("- shard: by-date"),
2585 SchemaBullet::Shard(Some(true))
2586 ));
2587 assert!(matches!(
2588 parse_schema_bullet("- shard: flat"),
2589 SchemaBullet::Shard(Some(false))
2590 ));
2591 assert!(matches!(
2593 parse_schema_bullet("- shard: weekly"),
2594 SchemaBullet::Shard(None)
2595 ));
2596 assert!(matches!(
2599 parse_schema_bullet("- shardiness (string)"),
2600 SchemaBullet::Field(_)
2601 ));
2602 }
2603
2604 #[test]
2605 fn parse_db_md_schema_captures_shard_directive() {
2606 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";
2607 let config = parse_db_md(db, Path::new("DB.md")).unwrap();
2608 let shipment = config.schemas.get("shipment").expect("shipment schema");
2609 assert_eq!(shipment.shard, Some(true));
2610 assert_eq!(
2611 shipment.fields.len(),
2612 1,
2613 "`shard:` is a directive, not a field"
2614 );
2615 assert_eq!(config.schemas.get("contact").unwrap().shard, Some(false));
2616 }
2617
2618 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";
2621
2622 #[test]
2623 fn parse_db_md_extracts_all_canonical_sections() {
2624 let config = parse_db_md(CANONICAL_DB_MD, Path::new("DB.md")).unwrap();
2625
2626 let ai = config
2628 .agent_instructions
2629 .expect("agent instructions present");
2630 assert!(ai.starts_with("Prioritize creating"));
2631 assert!(!ai.contains("## Agent instructions"));
2632
2633 assert_eq!(
2635 config.frozen_pages,
2636 vec![
2637 PathBuf::from("records/decisions/2026-q1-strategy.md"),
2638 PathBuf::from("wiki/synthesis/2026-annual-plan.md"),
2639 ]
2640 );
2641
2642 assert_eq!(
2644 config.ignored_types,
2645 vec!["test".to_string(), "temp".to_string()]
2646 );
2647
2648 assert_eq!(config.schemas.len(), 2);
2650 let contact = config.schemas.get("contact").expect("contact schema");
2651 let names: Vec<&str> = contact.fields.iter().map(|f| f.name.as_str()).collect();
2652 assert_eq!(names, vec!["name", "email", "company", "role"]);
2653 assert!(contact.fields[0].required); assert_eq!(contact.fields[1].shape, Some(Shape::Email)); assert_eq!(
2656 contact.fields[2].link_prefix,
2657 Some(PathBuf::from("records/companies"))
2658 ); let expense = config.schemas.get("expense").expect("expense schema");
2661 let cur = expense
2662 .fields
2663 .iter()
2664 .find(|f| f.name == "currency")
2665 .unwrap();
2666 assert_eq!(cur.default, Some(Value::String("USD".into())));
2667 }
2668
2669 #[test]
2670 fn parse_db_md_handles_malformed_and_unknown_modifiers() {
2671 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";
2675 let config = parse_db_md(text, Path::new("DB.md")).unwrap();
2676
2677 assert_eq!(config.schemas.len(), 1);
2680 let ticket = config.schemas.get("ticket").expect("ticket schema");
2681 assert_eq!(ticket.fields.len(), 2);
2682
2683 let priority = &ticket.fields[0];
2684 assert!(priority.required);
2685 assert_eq!(priority.unknown_modifiers, vec!["mystery".to_string()]);
2686 assert_eq!(
2687 priority.enum_values,
2688 Some(vec!["low".to_string(), "high".to_string()])
2689 );
2690
2691 let broken = &ticket.fields[1];
2693 assert_eq!(broken.name, "broken");
2694 }
2695
2696 #[test]
2697 fn parse_db_md_missing_frontmatter_errors() {
2698 let text = "# No frontmatter\n\n## Agent instructions\nhi\n";
2699 let err = parse_db_md(text, Path::new("DB.md")).unwrap_err();
2700 assert!(matches!(err, ParseError::MissingFrontmatter { .. }));
2701 }
2702
2703 #[test]
2704 fn parse_db_md_absent_sections_default_empty() {
2705 let text = "---\ntype: db-md\n---\n\n# Title only\n";
2706 let config = parse_db_md(text, Path::new("DB.md")).unwrap();
2707 assert_eq!(config, Config::default());
2708 }
2709
2710 #[test]
2720 fn set_list_of_wiki_links_becomes_block_sequence_both_spellings() {
2721 for value in [
2722 "[[[records/contacts/a]], [[records/contacts/b]]]",
2723 r#"["[[records/contacts/a]]", "[[records/contacts/b]]"]"#,
2724 ] {
2725 let mut fm = Frontmatter::default();
2726 fm.set("attendees", value).unwrap();
2727
2728 let stored = fm.extra.get("attendees").expect("attendees set");
2730 let Value::Sequence(items) = stored else {
2731 panic!("attendees must be a Sequence, got {stored:?} for input {value}");
2732 };
2733 assert_eq!(items.len(), 2, "input {value}");
2734 assert_eq!(items[0], Value::String("[[records/contacts/a]]".into()));
2735 assert_eq!(items[1], Value::String("[[records/contacts/b]]".into()));
2736
2737 let links: Vec<_> = links_in_field_value(stored)
2740 .into_iter()
2741 .map(|l| l.target)
2742 .collect();
2743 assert_eq!(
2744 links,
2745 vec!["records/contacts/a", "records/contacts/b"],
2746 "input {value}"
2747 );
2748
2749 let yaml = fm.to_yaml();
2751 assert!(
2752 yaml.contains("attendees:\n"),
2753 "expected block list in:\n{yaml}"
2754 );
2755 assert!(
2756 !yaml.contains("attendees: '[["),
2757 "must not be a flow-form scalar string in:\n{yaml}"
2758 );
2759 }
2760 }
2761
2762 #[test]
2766 fn set_single_inline_wiki_link_stays_scalar() {
2767 let mut fm = Frontmatter::default();
2768 fm.set("company", "[[records/companies/tideform]]").unwrap();
2769 assert_eq!(
2770 fm.extra.get("company"),
2771 Some(&Value::String("[[records/companies/tideform]]".into())),
2772 );
2773 let links: Vec<_> = links_in_field_value(fm.extra.get("company").unwrap())
2775 .into_iter()
2776 .map(|l| l.target)
2777 .collect();
2778 assert_eq!(links, vec!["records/companies/tideform"]);
2779 }
2780
2781 #[test]
2784 fn set_non_link_values_stay_scalar_strings() {
2785 let mut fm = Frontmatter::default();
2786 fm.set("location", "Video call (remote)").unwrap();
2787 assert_eq!(
2788 fm.extra.get("location"),
2789 Some(&Value::String("Video call (remote)".into())),
2790 );
2791
2792 fm.set("note", "[draft, wip]").unwrap();
2795 assert_eq!(
2796 fm.extra.get("note"),
2797 Some(&Value::String("[draft, wip]".into()))
2798 );
2799 }
2800}