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 let source = serde_norway::from_value::<Mapping>(other)
127 .expect_err("non-mapping frontmatter top level deserializes to Mapping");
128 return Err(ParseError::MalformedYaml {
129 file: file.to_path_buf(),
130 source,
131 });
132 }
133 };
134
135 let mut fm = Frontmatter::default();
136 for (k, v) in map {
137 let key = match k.as_str() {
138 Some(s) => s.to_string(),
139 None => format!("{k:?}"),
142 };
143 match key.as_str() {
144 "type" => fm.type_ = v.as_str().map(str::to_string),
145 "id" => fm.id = v.as_str().map(str::to_string),
146 "created" => fm.created = parse_timestamp(&v, "created", file)?,
147 "updated" => fm.updated = parse_timestamp(&v, "updated", file)?,
148 "summary" => fm.summary = v.as_str().map(str::to_string),
149 "status" => fm.status = v.as_str().map(str::to_string),
150 "tags" => fm.tags = parse_tags(&v),
151 _ => {
152 fm.extra.insert(key, v);
153 }
154 }
155 }
156 Ok(fm)
157 }
158
159 pub fn to_yaml(&self) -> String {
162 let mut map = Mapping::new();
169
170 if let Some(t) = &self.type_ {
171 map.insert(Value::String("type".into()), Value::String(t.clone()));
172 }
173 if let Some(id) = &self.id {
174 map.insert(Value::String("id".into()), Value::String(id.clone()));
175 }
176 if let Some(created) = &self.created {
177 map.insert(
178 Value::String("created".into()),
179 Value::String(created.to_rfc3339()),
180 );
181 }
182 if let Some(updated) = &self.updated {
183 map.insert(
184 Value::String("updated".into()),
185 Value::String(updated.to_rfc3339()),
186 );
187 }
188 if let Some(summary) = &self.summary {
189 map.insert(
190 Value::String("summary".into()),
191 Value::String(summary.clone()),
192 );
193 }
194
195 for (k, v) in &self.extra {
203 map.insert(Value::String(k.clone()), canonicalize_extra_value(v));
204 }
205
206 if let Some(status) = &self.status {
207 map.insert(
208 Value::String("status".into()),
209 Value::String(status.clone()),
210 );
211 }
212 if !self.tags.is_empty() {
213 map.insert(
214 Value::String("tags".into()),
215 Value::Sequence(self.tags.iter().cloned().map(Value::String).collect()),
216 );
217 }
218
219 if map.is_empty() {
220 return String::new();
221 }
222 serde_norway::to_string(&Value::Mapping(map)).unwrap_or_default()
223 }
224
225 pub fn is_content_file(path: &Path) -> bool {
229 if path.file_name().and_then(|n| n.to_str()) == Some("index.md") {
231 return false;
232 }
233 path.components().any(|c| {
238 c.as_os_str()
239 .to_str()
240 .is_some_and(|s| LAYER_DIRS.contains(&s))
241 })
242 }
243
244 pub fn effective_id(&self, store_relative_path: &Path) -> String {
247 if let Some(id) = &self.id {
248 if !id.is_empty() {
249 return id.clone();
250 }
251 }
252 store_relative_path
254 .file_stem()
255 .and_then(|s| s.to_str())
256 .unwrap_or_default()
257 .to_string()
258 }
259
260 pub fn get(&self, key: &str) -> Option<Value> {
263 match key {
264 "type" => self.type_.clone().map(Value::String),
265 "id" => self.id.clone().map(Value::String),
266 "created" => self.created.map(|d| Value::String(d.to_rfc3339())),
267 "updated" => self.updated.map(|d| Value::String(d.to_rfc3339())),
268 "summary" => self.summary.clone().map(Value::String),
269 "status" => self.status.clone().map(Value::String),
270 "tags" => {
271 if self.tags.is_empty() {
272 None
273 } else {
274 Some(Value::Sequence(
275 self.tags.iter().cloned().map(Value::String).collect(),
276 ))
277 }
278 }
279 _ => self.extra.get(key).cloned(),
280 }
281 }
282
283 pub fn set(&mut self, key: &str, value: &str) -> Result<(), ParseError> {
287 match key {
288 "type" => self.type_ = Some(value.to_string()),
289 "id" => self.id = Some(value.to_string()),
290 "created" => {
291 self.created = Some(parse_rfc3339(value, "created", Path::new("<fm set>"))?)
292 }
293 "updated" => {
294 self.updated = Some(parse_rfc3339(value, "updated", Path::new("<fm set>"))?)
295 }
296 "summary" => self.summary = Some(value.to_string()),
297 "status" => self.status = Some(value.to_string()),
298 "tags" => {
299 self.tags = match serde_norway::from_str::<Value>(value) {
303 Ok(Value::Sequence(seq)) => parse_tags(&Value::Sequence(seq)),
304 _ => vec![value.to_string()],
305 };
306 }
307 _ => {
308 let stored = parse_link_list_value(value)
320 .unwrap_or_else(|| Value::String(value.to_string()));
321 self.extra.insert(key.to_string(), stored);
322 }
323 }
324 Ok(())
325 }
326
327 pub fn link_fields(&self) -> Vec<(String, WikiLink)> {
331 let mut out = Vec::new();
332 if let Some(summary) = &self.summary {
334 for link in extract_wiki_links(summary, Path::new("")) {
335 out.push(("summary".to_string(), link));
336 }
337 }
338 for (key, value) in &self.extra {
342 for link in links_in_field_value(value) {
343 out.push((key.clone(), link));
344 }
345 }
346 out
347 }
348}
349
350#[derive(Debug, Clone, PartialEq, Eq)]
356pub struct WikiLink {
357 pub target: String,
359 pub display: Option<String>,
361 pub is_full_path: bool,
365 pub has_md_extension: bool,
368 pub location: (PathBuf, u32, u32),
370}
371
372#[derive(Debug, Clone, PartialEq, Eq)]
376pub struct MarkdownLink {
377 pub text: String,
379 pub url: String,
381 pub location: (PathBuf, u32, u32),
383}
384
385#[derive(Debug, Clone, PartialEq, Eq)]
389pub struct Section {
390 pub heading: String,
392 pub level: u8,
394 pub line: u32,
396 pub body: String,
399}
400
401#[derive(Debug, Clone, Default, PartialEq)]
406pub struct Config {
407 pub agent_instructions: Option<String>,
410 pub frozen_pages: Vec<PathBuf>,
413 pub ignored_types: Vec<String>,
416 pub schemas: BTreeMap<String, Schema>,
418}
419
420impl Config {
421 pub fn frozen_match(&self, target: &Path) -> Option<PathBuf> {
438 let want = normalize_frozen_path(target);
439 self.frozen_pages
440 .iter()
441 .find(|frozen| normalize_frozen_path(frozen) == want)
442 .cloned()
443 }
444
445 pub fn is_frozen(&self, target: &Path) -> bool {
448 self.frozen_match(target).is_some()
449 }
450}
451
452fn normalize_frozen_path(p: &Path) -> String {
457 let unix: String = p
458 .components()
459 .filter_map(|c| c.as_os_str().to_str())
460 .collect::<Vec<_>>()
461 .join("/");
462 let no_dot = unix.strip_prefix("./").unwrap_or(&unix);
463 no_dot.strip_suffix(".md").unwrap_or(no_dot).to_string()
464}
465
466#[derive(Debug, Clone, Default, PartialEq)]
470pub struct Schema {
471 pub fields: Vec<FieldSpec>,
473 pub unique_keys: Vec<Vec<String>>,
478 pub summary_template: Option<String>,
482}
483
484#[derive(Debug, Clone, Default, PartialEq)]
490pub struct FieldSpec {
491 pub name: String,
493 pub required: bool,
495 pub shape: Option<Shape>,
498 pub link_prefix: Option<PathBuf>,
501 pub default: Option<Value>,
503 pub enum_values: Option<Vec<String>>,
506 pub unknown_modifiers: Vec<String>,
509}
510
511#[derive(Debug, Clone, Copy, PartialEq, Eq)]
514pub enum Shape {
515 String,
517 Int,
519 Bool,
521 Date,
523 Email,
525 Currency,
527 Url,
529}
530
531#[derive(Debug, Clone, PartialEq, Eq)]
536pub struct ParsedFile {
537 pub frontmatter_yaml: String,
539 pub body: String,
541}
542
543pub fn split_frontmatter(text: &str, file: &Path) -> Result<ParsedFile, ParseError> {
548 let mut lines = text.split_inclusive('\n');
551 let first = lines.next().unwrap_or("");
552 if first.trim_end_matches(['\r', '\n']) != "---" {
553 return Err(ParseError::MissingFrontmatter {
554 file: file.to_path_buf(),
555 });
556 }
557
558 let opening_len = first.len();
562 let mut offset = opening_len;
563 for line in lines {
564 if line.trim_end_matches(['\r', '\n']) == "---" {
565 let yaml = &text[opening_len..offset];
566 let body_start = offset + line.len();
567 let body = &text[body_start..];
568 return Ok(ParsedFile {
569 frontmatter_yaml: yaml.to_string(),
570 body: body.to_string(),
571 });
572 }
573 offset += line.len();
574 }
575
576 Err(ParseError::MissingFrontmatter {
578 file: file.to_path_buf(),
579 })
580}
581
582pub fn read_file(path: &Path) -> Result<(Frontmatter, String), ParseError> {
585 let text = std::fs::read_to_string(path)?;
586 let parsed = split_frontmatter(&text, path)?;
587 let fm = Frontmatter::parse(&parsed.frontmatter_yaml, path)?;
588 Ok((fm, parsed.body))
589}
590
591pub fn write_file(path: &Path, frontmatter: &Frontmatter, body: &str) -> Result<(), ParseError> {
596 use std::io::Write;
597
598 let yaml = frontmatter.to_yaml();
599 let mut contents = String::with_capacity(yaml.len() + body.len() + 8);
602 contents.push_str("---\n");
603 contents.push_str(&yaml);
604 contents.push_str("---\n");
605 contents.push_str(body);
606
607 let parent = path.parent().unwrap_or_else(|| Path::new("."));
611 std::fs::create_dir_all(parent)?;
612 let file_name = path
613 .file_name()
614 .and_then(|n| n.to_str())
615 .unwrap_or("dbmd-write");
616 let (mut f, tmp) = create_temp_file(parent, file_name)?;
617
618 {
620 f.write_all(contents.as_bytes())?;
621 f.sync_all()?;
622 }
623 if let Err(e) = std::fs::rename(&tmp, path) {
625 let _ = std::fs::remove_file(&tmp);
626 return Err(ParseError::Io(e));
627 }
628 sync_parent_dir(parent);
629 Ok(())
630}
631
632fn create_temp_file(parent: &Path, file_name: &str) -> std::io::Result<(std::fs::File, PathBuf)> {
633 use std::sync::atomic::{AtomicU64, Ordering};
634 use std::time::{SystemTime, UNIX_EPOCH};
635
636 static TMP_SEQ: AtomicU64 = AtomicU64::new(0);
637 let pid = std::process::id();
638 let nanos = SystemTime::now()
639 .duration_since(UNIX_EPOCH)
640 .map(|d| d.as_nanos())
641 .unwrap_or(0);
642
643 for _ in 0..128 {
644 let seq = TMP_SEQ.fetch_add(1, Ordering::Relaxed);
645 let tmp = parent.join(format!(".{file_name}.tmp.{pid}.{nanos}.{seq}"));
646 match std::fs::OpenOptions::new()
647 .write(true)
648 .create_new(true)
649 .open(&tmp)
650 {
651 Ok(file) => return Ok((file, tmp)),
652 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
653 Err(e) => return Err(e),
654 }
655 }
656
657 Err(std::io::Error::new(
658 std::io::ErrorKind::AlreadyExists,
659 "could not allocate a unique dbmd temp file",
660 ))
661}
662
663fn sync_parent_dir(parent: &Path) {
664 if let Ok(dir) = std::fs::File::open(parent) {
665 let _ = dir.sync_all();
666 }
667}
668
669pub fn extract_wiki_links(body: &str, file: &Path) -> Vec<WikiLink> {
673 static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
674 let re = RE.get_or_init(|| {
675 regex::Regex::new(r"\[\[([^\[\]|]+?)(?:\|([^\[\]]*))?\]\]").expect("valid wiki-link regex")
678 });
679
680 let mut out = Vec::new();
681 for (line_idx, line) in body.lines().enumerate() {
682 for caps in re.captures_iter(line) {
683 let whole = caps.get(0).expect("group 0 always present");
684 let target = caps.get(1).map(|m| m.as_str()).unwrap_or("").to_string();
685 let display = caps.get(2).map(|m| m.as_str().to_string());
686 out.push(WikiLink {
687 is_full_path: target_is_full_path(&target),
688 has_md_extension: target_has_md_extension(&target),
689 target,
690 display,
691 location: (
692 file.to_path_buf(),
693 (line_idx as u32) + 1,
694 char_column(line, whole.start()),
695 ),
696 });
697 }
698 }
699 out
700}
701
702pub fn extract_markdown_links(body: &str, file: &Path) -> Vec<MarkdownLink> {
705 static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
706 let re = RE.get_or_init(|| {
707 regex::Regex::new(r"\[([^\[\]]*)\]\(([^)\s]*)\)").expect("valid markdown-link regex")
710 });
711
712 let mut out = Vec::new();
713 for (line_idx, line) in body.lines().enumerate() {
714 for caps in re.captures_iter(line) {
715 let whole = caps.get(0).expect("group 0 always present");
716 out.push(MarkdownLink {
717 text: caps.get(1).map(|m| m.as_str()).unwrap_or("").to_string(),
718 url: caps.get(2).map(|m| m.as_str()).unwrap_or("").to_string(),
719 location: (
720 file.to_path_buf(),
721 (line_idx as u32) + 1,
722 char_column(line, whole.start()),
723 ),
724 });
725 }
726 }
727 out
728}
729
730pub fn detect_flow_form_link_lists(frontmatter_yaml: &str) -> Vec<String> {
750 let value: Value = match serde_norway::from_str(frontmatter_yaml) {
751 Ok(v) => v,
752 Err(_) => return Vec::new(),
754 };
755 let Value::Mapping(map) = value else {
756 return Vec::new();
757 };
758
759 let mut out = Vec::new();
760 for (k, v) in &map {
761 if let Value::Sequence(items) = v {
762 let is_link_list = items.iter().any(|item| match item {
766 Value::Sequence(inner) => inner.iter().any(|x| matches!(x, Value::Sequence(_))),
767 _ => false,
768 });
769 if is_link_list {
770 if let Some(key) = k.as_str() {
771 out.push(key.to_string());
772 }
773 }
774 }
775 }
776 out
777}
778
779pub fn extract_sections(body: &str) -> Vec<Section> {
782 let lines: Vec<&str> = body.split_inclusive('\n').collect();
784
785 let mut levels: Vec<u8> = Vec::with_capacity(lines.len());
788 let mut fence: Option<(u8, usize)> = None;
789 for line in &lines {
790 let content = line.trim_end_matches(['\n', '\r']);
791 if let Some(f) = fence {
792 if is_closing_fence(content, f) {
793 fence = None;
794 }
795 levels.push(0);
796 continue;
797 }
798 if let Some(opened) = opening_fence(content) {
799 fence = Some(opened);
800 levels.push(0);
801 continue;
802 }
803 levels.push(heading_level(content));
804 }
805
806 let mut sections = Vec::new();
809 for (i, &lvl) in levels.iter().enumerate() {
810 if lvl < 2 {
811 continue;
812 }
813 let heading_line = lines[i].trim_end_matches(['\n', '\r']);
814 let heading = heading_text(heading_line, lvl);
815
816 let mut end = lines.len();
817 for (j, &other) in levels.iter().enumerate().skip(i + 1) {
818 if other != 0 && other <= lvl {
819 end = j;
820 break;
821 }
822 }
823
824 sections.push(Section {
825 heading,
826 level: lvl,
827 line: (i + 1) as u32,
828 body: lines[i..end].concat(),
829 });
830 }
831 sections
832}
833
834pub fn parse_db_md(text: &str, file: &Path) -> Result<Config, ParseError> {
839 let parsed = split_frontmatter(text, file)?;
843 let _frontmatter = Frontmatter::parse(&parsed.frontmatter_yaml, file)?;
844 let sections = extract_sections(&parsed.body);
845
846 let mut config = Config::default();
847 let mut current_h2: Option<String> = None;
849
850 for section in §ions {
851 match section.level {
852 2 => {
853 let name = section.heading.trim().to_ascii_lowercase();
854 current_h2 = Some(name.clone());
855 if name == "agent instructions" {
856 let prose = section_prose(§ion.body);
857 if !prose.is_empty() {
858 config.agent_instructions = Some(prose);
859 }
860 }
861 }
862 3 => {
863 let h2 = current_h2.as_deref().unwrap_or("");
864 let h3 = section.heading.trim().to_ascii_lowercase();
865 match (h2, h3.as_str()) {
866 ("policies", "frozen pages") => {
867 config.frozen_pages = bullet_lines(§ion.body)
868 .into_iter()
869 .map(|b| PathBuf::from(extract_path_bullet(&b)))
870 .collect();
871 }
872 ("policies", "ignored types") => {
873 config.ignored_types = bullet_lines(§ion.body)
874 .into_iter()
875 .flat_map(|b| extract_type_list_bullet(&b))
876 .collect();
877 }
878 ("schemas", _) => {
879 let type_name = section.heading.trim().to_string();
881 let mut schema = Schema::default();
882 for b in bullet_lines(§ion.body) {
883 match parse_schema_bullet(&b) {
884 SchemaBullet::Field(f) => schema.fields.push(f),
885 SchemaBullet::Unique(k) if !k.is_empty() => {
886 schema.unique_keys.push(k)
887 }
888 SchemaBullet::SummaryTemplate(t) if !t.is_empty() => {
889 schema.summary_template = Some(t)
890 }
891 SchemaBullet::Unique(_) | SchemaBullet::SummaryTemplate(_) => {}
893 }
894 }
895 config.schemas.insert(type_name, schema);
896 }
897 _ => {}
898 }
899 }
900 _ => {}
901 }
902 }
903
904 Ok(config)
905}
906
907#[derive(Debug)]
911enum SchemaBullet {
912 Field(FieldSpec),
914 Unique(Vec<String>),
916 SummaryTemplate(String),
918}
919
920fn parse_schema_bullet(bullet_line: &str) -> SchemaBullet {
926 let line = bullet_line.trim();
927 let line = line
928 .strip_prefix("- ")
929 .or_else(|| line.strip_prefix("* "))
930 .or_else(|| line.strip_prefix("+ "))
931 .or_else(|| line.strip_prefix('-'))
932 .unwrap_or(line)
933 .trim();
934
935 if let Some((head, rest)) = line.split_once(':') {
936 match head.trim().to_ascii_lowercase().as_str() {
937 "unique" => {
938 let fields = rest
939 .split(',')
940 .map(|f| f.trim().to_string())
941 .filter(|f| !f.is_empty())
942 .collect();
943 return SchemaBullet::Unique(fields);
944 }
945 "summary_template" => {
946 return SchemaBullet::SummaryTemplate(rest.trim().to_string());
947 }
948 _ => {}
949 }
950 }
951
952 SchemaBullet::Field(parse_field_spec(bullet_line))
953}
954
955pub fn parse_field_spec(bullet_line: &str) -> FieldSpec {
959 let line = bullet_line.trim();
961 let line = line
962 .strip_prefix("- ")
963 .or_else(|| line.strip_prefix("* "))
964 .or_else(|| line.strip_prefix("+ "))
965 .or_else(|| line.strip_prefix('-'))
966 .unwrap_or(line)
967 .trim();
968
969 let (name, modifiers) = match line.find('(') {
972 Some(open) => {
973 let name = line[..open].trim().to_string();
974 let after = &line[open + 1..];
975 let mods = match after.rfind(')') {
976 Some(close) => &after[..close],
977 None => after, };
979 (name, mods.trim())
980 }
981 None => (line.to_string(), ""),
982 };
983
984 let mut spec = FieldSpec {
985 name,
986 ..FieldSpec::default()
987 };
988
989 if modifiers.is_empty() {
990 return spec;
991 }
992
993 let raw: Vec<&str> = modifiers.split(',').collect();
996 let mut i = 0;
997 while i < raw.len() {
998 let token = raw[i].trim();
999 if token.is_empty() {
1000 i += 1;
1001 continue;
1002 }
1003 let lower = token.to_ascii_lowercase();
1004
1005 if lower == "required" {
1006 spec.required = true;
1007 } else if let Some(shape) = shape_from_str(&lower) {
1008 spec.shape = Some(shape);
1009 } else if let Some(rest) = lower.strip_prefix("link to ") {
1010 let prefix = token["link to ".len()..].trim().trim_end_matches('/');
1013 let _ = rest; spec.link_prefix = Some(PathBuf::from(prefix));
1015 } else if let Some(_rest) = lower.strip_prefix("default ") {
1016 let value = token["default ".len()..].trim().to_string();
1019 spec.default = Some(Value::String(value));
1020 } else if lower.starts_with("enum:") || lower == "enum" {
1021 let mut joined = raw[i..].join(",");
1024 if let Some(colon) = joined.find(':') {
1026 joined = joined[colon + 1..].to_string();
1027 }
1028 let values: Vec<String> = joined
1029 .split(',')
1030 .map(|v| v.trim().to_string())
1031 .filter(|v| !v.is_empty())
1032 .collect();
1033 spec.enum_values = Some(values);
1034 break; } else {
1036 spec.unknown_modifiers.push(token.to_string());
1038 }
1039 i += 1;
1040 }
1041
1042 spec
1043}
1044
1045fn parse_timestamp(
1050 value: &Value,
1051 key: &str,
1052 file: &Path,
1053) -> Result<Option<DateTime<FixedOffset>>, ParseError> {
1054 match value {
1055 Value::Null => Ok(None),
1056 Value::String(s) => parse_rfc3339(s, key, file).map(Some),
1057 other => Err(ParseError::BadTimestamp {
1058 file: file.to_path_buf(),
1059 key: key.to_string(),
1060 value: format!("{other:?}"),
1061 }),
1062 }
1063}
1064
1065fn parse_rfc3339(s: &str, key: &str, file: &Path) -> Result<DateTime<FixedOffset>, ParseError> {
1067 DateTime::parse_from_rfc3339(s.trim()).map_err(|_| ParseError::BadTimestamp {
1068 file: file.to_path_buf(),
1069 key: key.to_string(),
1070 value: s.to_string(),
1071 })
1072}
1073
1074fn parse_tags(value: &Value) -> Vec<String> {
1077 match value {
1078 Value::Sequence(items) => items
1079 .iter()
1080 .filter_map(|v| match v {
1081 Value::String(s) => Some(s.clone()),
1082 Value::Number(n) => Some(n.to_string()),
1083 Value::Bool(b) => Some(b.to_string()),
1084 _ => None,
1085 })
1086 .collect(),
1087 Value::String(s) => vec![s.clone()],
1088 _ => Vec::new(),
1089 }
1090}
1091
1092fn parse_wiki_link_str(s: &str) -> Option<WikiLink> {
1096 let s = s.trim();
1097 let inner = s.strip_prefix("[[")?.strip_suffix("]]")?;
1098 if inner.contains('[') || inner.contains(']') {
1101 return None;
1102 }
1103 let (target, display) = match inner.split_once('|') {
1104 Some((t, d)) => (t.to_string(), Some(d.to_string())),
1105 None => (inner.to_string(), None),
1106 };
1107 Some(WikiLink {
1108 is_full_path: target_is_full_path(&target),
1109 has_md_extension: target_has_md_extension(&target),
1110 target,
1111 display,
1112 location: (PathBuf::new(), 0, 0),
1113 })
1114}
1115
1116fn links_in_field_value(value: &Value) -> Vec<WikiLink> {
1144 if let Value::String(s) = value {
1146 return parse_wiki_link_str(s).into_iter().collect();
1147 }
1148 let Value::Sequence(items) = value else {
1149 return Vec::new();
1150 };
1151 if items.len() == 1 {
1155 if let Some(link) = unquoted_inline_link(&items[0]) {
1156 return vec![link];
1157 }
1158 }
1159 items
1162 .iter()
1163 .filter_map(|item| parse_wiki_link_str(item.as_str()?))
1164 .collect()
1165}
1166
1167fn canonicalize_extra_value(value: &Value) -> Value {
1198 match value {
1199 Value::String(s) => match parse_wiki_link_str(s) {
1203 Some(link) => Value::String(wiki_link_literal(&link)),
1204 None => value.clone(),
1205 },
1206 Value::Sequence(items) => {
1207 if items.len() == 1 {
1211 if let Some(link) = unquoted_inline_link(&items[0]) {
1212 return Value::String(wiki_link_literal(&link));
1213 }
1214 }
1215 let mut links = Vec::with_capacity(items.len());
1222 for item in items {
1223 match link_from_flow_list_item(item) {
1224 Some(link) => links.push(link),
1225 None => return value.clone(),
1226 }
1227 }
1228 if links.is_empty() {
1229 return value.clone();
1230 }
1231 Value::Sequence(
1232 links
1233 .iter()
1234 .map(|l| Value::String(wiki_link_literal(l)))
1235 .collect(),
1236 )
1237 }
1238 _ => value.clone(),
1240 }
1241}
1242
1243fn wiki_link_literal(link: &WikiLink) -> String {
1246 match &link.display {
1247 Some(d) => format!("[[{}|{}]]", link.target, d),
1248 None => format!("[[{}]]", link.target),
1249 }
1250}
1251
1252fn unquoted_inline_link(v: &Value) -> Option<WikiLink> {
1259 let Value::Sequence(items) = v else {
1260 return None;
1261 };
1262 if items.len() != 1 {
1263 return None;
1264 }
1265 let s = items[0].as_str()?;
1266 if s.contains('[') || s.contains(']') {
1268 return None;
1269 }
1270 parse_wiki_link_str(&format!("[[{s}]]"))
1271}
1272
1273fn parse_link_list_value(value: &str) -> Option<Value> {
1295 let trimmed = value.trim();
1296 if !(trimmed.starts_with('[') && trimmed.ends_with(']')) {
1300 return None;
1301 }
1302 let Ok(Value::Sequence(items)) = serde_norway::from_str::<Value>(trimmed) else {
1303 return None;
1304 };
1305 if items.len() == 1 && unquoted_inline_link(&items[0]).is_some() {
1310 return None;
1311 }
1312 let mut links = Vec::with_capacity(items.len());
1315 for item in &items {
1316 links.push(link_from_flow_list_item(item)?);
1317 }
1318 if links.is_empty() {
1319 return None;
1320 }
1321 let normalized = links
1325 .iter()
1326 .map(|l| Value::String(wiki_link_literal(l)))
1327 .collect();
1328 Some(Value::Sequence(normalized))
1329}
1330
1331fn link_from_flow_list_item(item: &Value) -> Option<WikiLink> {
1344 match item {
1345 Value::String(s) => parse_wiki_link_str(s),
1346 Value::Sequence(inner) => {
1347 if inner.len() == 1 {
1350 if let Some(link) = unquoted_inline_link(&inner[0]) {
1351 return Some(link);
1352 }
1353 }
1354 unquoted_inline_link(item)
1356 }
1357 _ => None,
1358 }
1359}
1360
1361fn target_is_full_path(target: &str) -> bool {
1365 let target = target.trim();
1366 match target.split_once('/') {
1367 Some((head, _rest)) => LAYER_DIRS.contains(&head),
1368 None => false,
1369 }
1370}
1371
1372fn target_has_md_extension(target: &str) -> bool {
1375 target.trim().ends_with(".md")
1376}
1377
1378fn char_column(line: &str, byte_offset: usize) -> u32 {
1380 (line[..byte_offset].chars().count() as u32) + 1
1381}
1382
1383fn shape_from_str(s: &str) -> Option<Shape> {
1385 match s {
1386 "string" => Some(Shape::String),
1387 "int" => Some(Shape::Int),
1388 "bool" => Some(Shape::Bool),
1389 "date" => Some(Shape::Date),
1390 "email" => Some(Shape::Email),
1391 "currency" => Some(Shape::Currency),
1392 "url" => Some(Shape::Url),
1393 _ => None,
1394 }
1395}
1396
1397fn heading_level(line: &str) -> u8 {
1401 let indent = line.len() - line.trim_start_matches(' ').len();
1402 if indent > 3 {
1403 return 0;
1404 }
1405 let rest = &line[indent..];
1406 let hashes = rest.len() - rest.trim_start_matches('#').len();
1407 if hashes == 0 || hashes > 6 {
1408 return 0;
1409 }
1410 let after = &rest[hashes..];
1411 if after.is_empty() || after.starts_with(' ') || after.starts_with('\t') {
1412 hashes as u8
1413 } else {
1414 0
1415 }
1416}
1417
1418fn heading_text(line: &str, level: u8) -> String {
1421 let indent = line.len() - line.trim_start_matches(' ').len();
1422 let after_hashes = &line[indent + level as usize..];
1423 let trimmed = after_hashes.trim();
1424 let no_trailing = trimmed.trim_end_matches('#');
1425 if no_trailing.len() == trimmed.len() {
1426 trimmed.to_string()
1427 } else {
1428 no_trailing.trim_end().to_string()
1429 }
1430}
1431
1432fn opening_fence(line: &str) -> Option<(u8, usize)> {
1434 let indent = line.len() - line.trim_start_matches(' ').len();
1435 if indent > 3 {
1436 return None;
1437 }
1438 let rest = &line[indent..];
1439 let byte = rest.bytes().next()?;
1440 if byte != b'`' && byte != b'~' {
1441 return None;
1442 }
1443 let run = rest.len() - rest.trim_start_matches(byte as char).len();
1444 if run < 3 {
1445 return None;
1446 }
1447 if byte == b'`' && rest[run..].contains('`') {
1449 return None;
1450 }
1451 Some((byte, run))
1452}
1453
1454fn is_closing_fence(line: &str, fence: (u8, usize)) -> bool {
1457 let (byte, open_len) = fence;
1458 let indent = line.len() - line.trim_start_matches(' ').len();
1459 if indent > 3 {
1460 return false;
1461 }
1462 let rest = &line[indent..];
1463 let run = rest.len() - rest.trim_start_matches(byte as char).len();
1464 if run < open_len {
1465 return false;
1466 }
1467 rest[run..].trim().is_empty()
1468}
1469
1470fn section_prose(section_body: &str) -> String {
1472 match section_body.split_once('\n') {
1473 Some((_heading, rest)) => rest.trim().to_string(),
1474 None => String::new(),
1475 }
1476}
1477
1478fn bullet_lines(section_body: &str) -> Vec<String> {
1481 section_body
1482 .lines()
1483 .skip(1) .map(str::trim)
1485 .filter(|l| l.starts_with("- ") || l.starts_with("* ") || l.starts_with("+ "))
1486 .map(|l| l.to_string())
1487 .collect()
1488}
1489
1490fn strip_bullet_comment(content: &str) -> &str {
1493 let mut cut = content.len();
1494 for sep in [" — ", " -- ", " – "] {
1495 if let Some(idx) = content.find(sep) {
1496 cut = cut.min(idx);
1497 }
1498 }
1499 content[..cut].trim()
1500}
1501
1502fn bullet_content(bullet: &str) -> &str {
1504 let t = bullet.trim();
1505 t.strip_prefix("- ")
1506 .or_else(|| t.strip_prefix("* "))
1507 .or_else(|| t.strip_prefix("+ "))
1508 .unwrap_or(t)
1509 .trim()
1510}
1511
1512fn extract_path_bullet(bullet: &str) -> String {
1515 let content = bullet_content(bullet);
1516 if let Some(start) = content.find('`') {
1518 if let Some(end_rel) = content[start + 1..].find('`') {
1519 return content[start + 1..start + 1 + end_rel].trim().to_string();
1520 }
1521 }
1522 strip_bullet_comment(content)
1524 .trim_matches('"')
1525 .trim_matches('\'')
1526 .trim()
1527 .to_string()
1528}
1529
1530fn extract_type_list_bullet(bullet: &str) -> Vec<String> {
1533 let content = strip_bullet_comment(bullet_content(bullet));
1534 content
1535 .split(',')
1536 .map(|t| {
1537 t.trim()
1538 .trim_matches('`')
1539 .trim_matches('"')
1540 .trim_matches('\'')
1541 .trim()
1542 .to_string()
1543 })
1544 .filter(|t| !t.is_empty())
1545 .collect()
1546}
1547
1548#[cfg(test)]
1549mod tests {
1550 use super::*;
1551 use std::path::Path;
1552 use tempfile::tempdir;
1553
1554 #[test]
1557 fn frozen_match_is_md_insensitive_both_directions() {
1558 let cfg = Config {
1562 frozen_pages: vec![PathBuf::from("records/decisions/q1")],
1563 ..Config::default()
1564 };
1565 assert_eq!(
1566 cfg.frozen_match(Path::new("records/decisions/q1.md")),
1567 Some(PathBuf::from("records/decisions/q1")),
1568 "extensionless policy entry must freeze the .md file"
1569 );
1570 assert!(cfg.is_frozen(Path::new("records/decisions/q1.md")));
1571
1572 let cfg = Config {
1574 frozen_pages: vec![PathBuf::from("records/decisions/q1.md")],
1575 ..Config::default()
1576 };
1577 assert_eq!(
1578 cfg.frozen_match(Path::new("records/decisions/q1")),
1579 Some(PathBuf::from("records/decisions/q1.md")),
1580 );
1581 assert!(cfg.is_frozen(Path::new("records/decisions/q1.md")));
1583 }
1584
1585 #[test]
1586 fn frozen_match_drops_leading_dot_slash() {
1587 let cfg = Config {
1588 frozen_pages: vec![PathBuf::from("records/decisions/q1.md")],
1589 ..Config::default()
1590 };
1591 assert!(cfg.is_frozen(Path::new("./records/decisions/q1.md")));
1592 assert!(cfg.is_frozen(Path::new("./records/decisions/q1")));
1593 }
1594
1595 #[test]
1596 fn frozen_match_returns_none_for_unlisted_and_prefix_paths() {
1597 let cfg = Config {
1598 frozen_pages: vec![PathBuf::from("records/decisions/q1")],
1599 ..Config::default()
1600 };
1601 assert!(cfg
1602 .frozen_match(Path::new("records/decisions/q2.md"))
1603 .is_none());
1604 assert!(cfg
1606 .frozen_match(Path::new("records/decisions/q1-draft.md"))
1607 .is_none());
1608 assert!(!cfg.is_frozen(Path::new("records/decisions/q11.md")));
1609 }
1610
1611 #[test]
1614 fn split_frontmatter_separates_yaml_and_verbatim_body() {
1615 let text = "---\ntype: contact\nsummary: x\n---\n# Heading\n\nBody line.\n";
1616 let p = split_frontmatter(text, Path::new("f.md")).unwrap();
1617 assert_eq!(p.frontmatter_yaml, "type: contact\nsummary: x\n");
1618 assert_eq!(p.body, "# Heading\n\nBody line.\n");
1620 }
1621
1622 #[test]
1623 fn split_frontmatter_preserves_body_without_trailing_newline() {
1624 let text = "---\ntype: x\n---\nno trailing newline";
1625 let p = split_frontmatter(text, Path::new("f.md")).unwrap();
1626 assert_eq!(p.body, "no trailing newline");
1627 }
1628
1629 #[test]
1630 fn split_frontmatter_empty_body_when_nothing_after_fence() {
1631 let text = "---\ntype: x\n---\n";
1632 let p = split_frontmatter(text, Path::new("f.md")).unwrap();
1633 assert_eq!(p.body, "");
1634 }
1635
1636 #[test]
1637 fn split_frontmatter_missing_opening_fence_errors() {
1638 let text = "# No frontmatter here\ntype: x\n";
1639 let err = split_frontmatter(text, Path::new("f.md")).unwrap_err();
1640 assert!(matches!(err, ParseError::MissingFrontmatter { .. }));
1641 }
1642
1643 #[test]
1644 fn split_frontmatter_leading_content_before_fence_rejected() {
1645 let text = "\n---\ntype: x\n---\nbody";
1648 let err = split_frontmatter(text, Path::new("f.md")).unwrap_err();
1649 assert!(matches!(err, ParseError::MissingFrontmatter { .. }));
1650 }
1651
1652 #[test]
1653 fn split_frontmatter_unterminated_block_errors() {
1654 let text = "---\ntype: x\nsummary: y\n";
1655 let err = split_frontmatter(text, Path::new("f.md")).unwrap_err();
1656 assert!(matches!(err, ParseError::MissingFrontmatter { .. }));
1657 }
1658
1659 #[test]
1662 fn parse_populates_typed_fields_and_routes_unknowns_to_extra() {
1663 let yaml = "type: contact\nid: sarah-chen\nsummary: Director of Ops\nstatus: active\ntags: [vip, renewal]\nemail: sarah@northstar.io\nrole: Director";
1664 let fm = Frontmatter::parse(yaml, Path::new("f.md")).unwrap();
1665 assert_eq!(fm.type_.as_deref(), Some("contact"));
1666 assert_eq!(fm.id.as_deref(), Some("sarah-chen"));
1667 assert_eq!(fm.summary.as_deref(), Some("Director of Ops"));
1668 assert_eq!(fm.status.as_deref(), Some("active"));
1669 assert_eq!(fm.tags, vec!["vip".to_string(), "renewal".to_string()]);
1670 assert!(fm.type_.is_some() && !fm.extra.contains_key("type"));
1672 assert!(!fm.extra.contains_key("tags"));
1673 assert_eq!(
1674 fm.extra.get("email").and_then(|v| v.as_str()),
1675 Some("sarah@northstar.io")
1676 );
1677 assert_eq!(
1678 fm.extra.get("role").and_then(|v| v.as_str()),
1679 Some("Director")
1680 );
1681 }
1682
1683 #[test]
1684 fn parse_reads_rfc3339_timestamps() {
1685 let yaml =
1686 "type: email\ncreated: 2026-05-27T08:00:00-07:00\nupdated: 2026-05-28T09:30:00-07:00";
1687 let fm = Frontmatter::parse(yaml, Path::new("f.md")).unwrap();
1688 let created = fm.created.expect("created parsed");
1689 assert_eq!(created.offset().utc_minus_local(), 7 * 3600);
1691 assert_eq!(created.to_rfc3339(), "2026-05-27T08:00:00-07:00");
1692 assert!(fm.updated.is_some());
1693 }
1694
1695 #[test]
1696 fn parse_rejects_non_rfc3339_timestamp() {
1697 let yaml = "type: email\ncreated: 2026-05-27";
1700 let err = Frontmatter::parse(yaml, Path::new("bad.md")).unwrap_err();
1701 match err {
1702 ParseError::BadTimestamp { key, value, .. } => {
1703 assert_eq!(key, "created");
1704 assert_eq!(value, "2026-05-27");
1705 }
1706 other => panic!("expected BadTimestamp, got {other:?}"),
1707 }
1708 }
1709
1710 #[test]
1711 fn parse_malformed_yaml_errors() {
1712 let yaml = "type: contact\n bad: : :\n- nope";
1714 let err = Frontmatter::parse(yaml, Path::new("bad.md")).unwrap_err();
1715 assert!(matches!(err, ParseError::MalformedYaml { .. }));
1716 }
1717
1718 #[test]
1719 fn parse_empty_block_is_empty_frontmatter() {
1720 let fm = Frontmatter::parse("", Path::new("f.md")).unwrap();
1721 assert_eq!(fm, Frontmatter::default());
1722 }
1723
1724 #[test]
1725 fn parse_scalar_top_level_is_malformed() {
1726 let err = Frontmatter::parse("just a string", Path::new("f.md")).unwrap_err();
1728 assert!(matches!(err, ParseError::MalformedYaml { .. }));
1729 }
1730
1731 #[test]
1734 fn to_yaml_emits_canonical_key_order() {
1735 let mut fm = Frontmatter {
1736 type_: Some("contact".into()),
1737 id: Some("sarah-chen".into()),
1738 summary: Some("Director of Ops".into()),
1739 status: Some("active".into()),
1740 tags: vec!["vip".into()],
1741 created: Some(DateTime::parse_from_rfc3339("2026-05-27T08:00:00-07:00").unwrap()),
1742 updated: Some(DateTime::parse_from_rfc3339("2026-05-28T09:30:00-07:00").unwrap()),
1743 ..Default::default()
1744 };
1745 fm.extra
1748 .insert("role".into(), Value::String("Director".into()));
1749 fm.extra.insert(
1750 "company".into(),
1751 Value::String("[[records/companies/northstar]]".into()),
1752 );
1753
1754 let yaml = fm.to_yaml();
1755 let keys: Vec<&str> = yaml
1756 .lines()
1757 .filter(|l| !l.starts_with(['-', ' ']) && l.contains(':'))
1758 .map(|l| l.split(':').next().unwrap())
1759 .collect();
1760 assert_eq!(
1761 keys,
1762 vec![
1763 "type", "id", "created", "updated", "summary", "company", "role", "status", "tags",
1767 ],
1768 "canonical order violated; got:\n{yaml}"
1769 );
1770 assert!(
1772 yaml.contains("2026-05-27T08:00:00-07:00"),
1773 "created timestamp missing; got:\n{yaml}"
1774 );
1775 let reparsed = Frontmatter::parse(&yaml, Path::new("rt.md")).unwrap();
1777 assert_eq!(reparsed.created, fm.created);
1778 assert_eq!(reparsed.updated, fm.updated);
1779 }
1780
1781 #[test]
1782 fn to_yaml_omits_absent_optional_fields() {
1783 let fm = Frontmatter {
1784 type_: Some("note".into()),
1785 ..Default::default()
1786 };
1787 let yaml = fm.to_yaml();
1788 assert!(yaml.contains("type: note"));
1789 assert!(!yaml.contains("status"));
1790 assert!(!yaml.contains("tags"));
1791 assert!(!yaml.contains("summary"));
1792 }
1793
1794 #[test]
1795 fn to_yaml_preserves_unquoted_scalar_wiki_link_round_trip() {
1796 let yaml = "type: contact\ncompany: [[records/companies/northstar]]";
1806 let fm = Frontmatter::parse(yaml, Path::new("c.md")).unwrap();
1807 assert!(fm.extra.get("company").and_then(|v| v.as_str()).is_none());
1809
1810 let out = fm.to_yaml();
1811 assert!(
1814 out.contains("[[records/companies/northstar]]"),
1815 "canonical writer dropped the wiki-link brackets; got:\n{out}"
1816 );
1817 assert!(
1818 !out.contains("- - "),
1819 "canonical writer emitted a nested block sequence (link corrupted); got:\n{out}"
1820 );
1821
1822 let reparsed = Frontmatter::parse(&out, Path::new("c.md")).unwrap();
1825 let fields = reparsed.link_fields();
1826 let links: Vec<(&str, &str, Option<&str>)> = fields
1827 .iter()
1828 .map(|(k, l)| (k.as_str(), l.target.as_str(), l.display.as_deref()))
1829 .collect();
1830 assert_eq!(
1831 links,
1832 vec![("company", "records/companies/northstar", None)]
1833 );
1834
1835 assert_eq!(
1838 reparsed.to_yaml(),
1839 out,
1840 "to_yaml is not idempotent on links"
1841 );
1842 }
1843
1844 #[test]
1845 fn to_yaml_preserves_unquoted_scalar_link_with_display() {
1846 let yaml = "type: contact\ncompany: [[records/companies/northstar|Northstar]]";
1848 let fm = Frontmatter::parse(yaml, Path::new("c.md")).unwrap();
1849 let out = fm.to_yaml();
1850 assert!(
1851 out.contains("[[records/companies/northstar|Northstar]]"),
1852 "display segment lost on round-trip; got:\n{out}"
1853 );
1854 let reparsed = Frontmatter::parse(&out, Path::new("c.md")).unwrap();
1855 let f = reparsed.link_fields();
1856 assert_eq!(f.len(), 1);
1857 assert_eq!(f[0].1.target, "records/companies/northstar");
1858 assert_eq!(f[0].1.display.as_deref(), Some("Northstar"));
1859 }
1860
1861 #[test]
1862 fn to_yaml_does_not_mangle_link_list_or_plain_nested_sequence() {
1863 let yaml = "type: meeting\nattendees:\n - \"[[records/contacts/elena]]\"\n - \"[[records/contacts/sarah]]\"\nmatrix:\n - - 1\n - 2";
1867 let fm = Frontmatter::parse(yaml, Path::new("m.md")).unwrap();
1868 let out = fm.to_yaml();
1869
1870 assert!(out.contains("[[records/contacts/elena]]"), "got:\n{out}");
1872 assert!(out.contains("[[records/contacts/sarah]]"), "got:\n{out}");
1873
1874 let reparsed = Frontmatter::parse(&out, Path::new("m.md")).unwrap();
1875 let fields = reparsed.link_fields();
1876 let attendees: Vec<&str> = fields
1877 .iter()
1878 .filter(|(k, _)| k == "attendees")
1879 .map(|(_, l)| l.target.as_str())
1880 .collect();
1881 assert_eq!(
1882 attendees,
1883 vec!["records/contacts/elena", "records/contacts/sarah"]
1884 );
1885 assert_eq!(reparsed.extra.get("matrix"), fm.extra.get("matrix"));
1887 }
1888
1889 #[test]
1892 fn write_then_read_roundtrips_and_preserves_body_verbatim() {
1893 let dir = tempdir().unwrap();
1894 let path = dir.path().join("sources/emails/x.md");
1895 let body = "# Subject\n\nHello,\n\nSee [[records/contacts/sarah-chen]].\n";
1896 let mut fm = Frontmatter {
1897 type_: Some("email".into()),
1898 summary: Some("renewal note".into()),
1899 created: Some(DateTime::parse_from_rfc3339("2026-05-27T08:00:00-07:00").unwrap()),
1900 ..Default::default()
1901 };
1902 fm.extra
1903 .insert("from".into(), Value::String("elena@northstar.io".into()));
1904
1905 write_file(&path, &fm, body).unwrap();
1906
1907 let (read_fm, read_body) = read_file(&path).unwrap();
1908 assert_eq!(read_body, body, "body must be preserved byte-for-byte");
1909 assert_eq!(read_fm.type_.as_deref(), Some("email"));
1910 assert_eq!(read_fm.summary.as_deref(), Some("renewal note"));
1911 assert_eq!(
1912 read_fm.extra.get("from").and_then(|v| v.as_str()),
1913 Some("elena@northstar.io")
1914 );
1915 let raw = std::fs::read_to_string(&path).unwrap();
1917 assert!(raw.starts_with("---\n"));
1918 assert!(raw.ends_with(body));
1919 }
1920
1921 #[test]
1922 fn roundtrip_modify_summary_then_write_changes_only_summary() {
1923 let dir = tempdir().unwrap();
1924 let path = dir.path().join("records/contacts/sarah.md");
1925 let body = "Long-form operator notes about Sarah.\n";
1926 let fm = Frontmatter {
1927 type_: Some("contact".into()),
1928 summary: Some("old summary".into()),
1929 ..Default::default()
1930 };
1931 write_file(&path, &fm, body).unwrap();
1932
1933 let (mut fm2, body2) = read_file(&path).unwrap();
1935 fm2.summary = Some("new summary".into());
1936 write_file(&path, &fm2, &body2).unwrap();
1937
1938 let (fm3, body3) = read_file(&path).unwrap();
1939 assert_eq!(fm3.summary.as_deref(), Some("new summary"));
1940 assert_eq!(fm3.type_.as_deref(), Some("contact"));
1941 assert_eq!(body3, body, "body unchanged across the round-trip");
1942 }
1943
1944 #[test]
1945 fn roundtrip_preserves_handwritten_unquoted_scalar_wiki_link_on_disk() {
1946 let dir = tempdir().unwrap();
1953 let path = dir.path().join("records/contacts/sarah-chen.md");
1954 let file = "---\ntype: contact\nid: sarah-chen\nsummary: Director of Ops\ncompany: [[records/companies/northstar]]\n---\n# Sarah Chen\n\nNotes.\n";
1955 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1956 std::fs::write(&path, file).unwrap();
1957
1958 let (fm, body) = read_file(&path).unwrap();
1960 write_file(&path, &fm, &body).unwrap();
1961
1962 let raw = std::fs::read_to_string(&path).unwrap();
1964 assert!(
1965 raw.contains("[[records/companies/northstar]]"),
1966 "on-disk wiki-link brackets were destroyed; got:\n{raw}"
1967 );
1968 assert!(
1969 !raw.contains("- - "),
1970 "on-disk value became a nested block sequence; got:\n{raw}"
1971 );
1972
1973 let (fm2, _) = read_file(&path).unwrap();
1975 let fields = fm2.link_fields();
1976 let links: Vec<(&str, &str)> = fields
1977 .iter()
1978 .map(|(k, l)| (k.as_str(), l.target.as_str()))
1979 .collect();
1980 assert_eq!(links, vec![("company", "records/companies/northstar")]);
1981 }
1982
1983 #[test]
1984 fn write_file_does_not_leave_temp_files_behind() {
1985 let dir = tempdir().unwrap();
1986 let path = dir.path().join("records/x.md");
1987 let fm = Frontmatter {
1988 type_: Some("note".into()),
1989 ..Default::default()
1990 };
1991 write_file(&path, &fm, "body\n").unwrap();
1992 let entries: Vec<String> = std::fs::read_dir(path.parent().unwrap())
1994 .unwrap()
1995 .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
1996 .collect();
1997 assert_eq!(entries, vec!["x.md".to_string()]);
1998 }
1999
2000 #[test]
2003 fn is_content_file_recognizes_layers_and_excludes_meta() {
2004 assert!(Frontmatter::is_content_file(Path::new(
2005 "sources/emails/2026-05-22.md"
2006 )));
2007 assert!(Frontmatter::is_content_file(Path::new(
2008 "records/contacts/sarah-chen.md"
2009 )));
2010 assert!(Frontmatter::is_content_file(Path::new(
2011 "wiki/people/sarah-chen.md"
2012 )));
2013 assert!(Frontmatter::is_content_file(Path::new(
2015 "/home/db/records/companies/northstar.md"
2016 )));
2017 assert!(!Frontmatter::is_content_file(Path::new(
2019 "records/contacts/index.md"
2020 )));
2021 assert!(!Frontmatter::is_content_file(Path::new("index.md")));
2022 assert!(!Frontmatter::is_content_file(Path::new("DB.md")));
2024 assert!(!Frontmatter::is_content_file(Path::new("log.md")));
2025 }
2026
2027 #[test]
2030 fn effective_id_prefers_explicit_then_derives_from_path() {
2031 let with_id = Frontmatter {
2032 id: Some("explicit-id".into()),
2033 ..Default::default()
2034 };
2035 assert_eq!(
2036 with_id.effective_id(Path::new("wiki/people/sarah-chen.md")),
2037 "explicit-id"
2038 );
2039 let no_id = Frontmatter::default();
2040 assert_eq!(
2041 no_id.effective_id(Path::new("wiki/people/sarah-chen.md")),
2042 "sarah-chen"
2043 );
2044 }
2045
2046 #[test]
2049 fn set_routes_universal_and_custom_keys() {
2050 let mut fm = Frontmatter::default();
2051 fm.set("type", "contact").unwrap();
2052 fm.set("summary", "hi").unwrap();
2053 fm.set("company", "[[records/companies/northstar]]")
2054 .unwrap();
2055 assert_eq!(fm.type_.as_deref(), Some("contact"));
2056 assert_eq!(fm.summary.as_deref(), Some("hi"));
2057 assert_eq!(
2059 fm.extra.get("company").and_then(|v| v.as_str()),
2060 Some("[[records/companies/northstar]]")
2061 );
2062 assert_eq!(
2064 fm.get("type").and_then(|v| v.as_str().map(String::from)),
2065 Some("contact".into())
2066 );
2067 assert_eq!(
2068 fm.get("company").and_then(|v| v.as_str().map(String::from)),
2069 Some("[[records/companies/northstar]]".into())
2070 );
2071 assert!(fm.get("nonexistent").is_none());
2072 }
2073
2074 #[test]
2075 fn set_timestamp_validates_rfc3339() {
2076 let mut fm = Frontmatter::default();
2077 fm.set("created", "2026-05-27T08:00:00-07:00").unwrap();
2078 assert!(fm.created.is_some());
2079 let err = fm.set("updated", "not-a-date").unwrap_err();
2080 assert!(matches!(err, ParseError::BadTimestamp { .. }));
2081 }
2082
2083 #[test]
2086 fn extract_wiki_links_flags_full_path_short_form_and_extension() {
2087 let body = "See [[records/contacts/sarah-chen]] and [[sarah-chen]].\nAlso [[wiki/people/sarah-chen.md|Sarah]].\n";
2088 let links = extract_wiki_links(body, Path::new("doc.md"));
2089 assert_eq!(links.len(), 3);
2090
2091 assert_eq!(links[0].target, "records/contacts/sarah-chen");
2093 assert!(links[0].is_full_path);
2094 assert!(!links[0].has_md_extension);
2095 assert_eq!(links[0].display, None);
2096 assert_eq!(links[0].location.1, 1, "first link on line 1");
2097
2098 assert_eq!(links[1].target, "sarah-chen");
2100 assert!(!links[1].is_full_path, "bare target is short-form");
2101
2102 assert_eq!(links[2].target, "wiki/people/sarah-chen.md");
2104 assert!(links[2].is_full_path);
2105 assert!(links[2].has_md_extension);
2106 assert_eq!(links[2].display.as_deref(), Some("Sarah"));
2107 assert_eq!(links[2].location.1, 2);
2108 }
2109
2110 #[test]
2111 fn extract_wiki_links_reports_1_based_column_counting_chars() {
2112 let body = "café [[records/x/y]]";
2114 let links = extract_wiki_links(body, Path::new("d.md"));
2115 assert_eq!(links.len(), 1);
2116 assert_eq!(links[0].location.2, 6);
2118 }
2119
2120 #[test]
2121 fn extract_wiki_links_ignores_a_lone_path_without_brackets() {
2122 let links = extract_wiki_links(
2123 "records/contacts/sarah-chen is not a link",
2124 Path::new("d.md"),
2125 );
2126 assert!(links.is_empty());
2127 }
2128
2129 #[test]
2132 fn extract_markdown_links_captures_external_and_not_wiki_links() {
2133 let body =
2134 "See [the thread](https://x.com/a) and [[records/contacts/sarah-chen]] internally.\n";
2135 let md = extract_markdown_links(body, Path::new("d.md"));
2136 assert_eq!(
2137 md.len(),
2138 1,
2139 "wiki-link must not be captured as a markdown link"
2140 );
2141 assert_eq!(md[0].text, "the thread");
2142 assert_eq!(md[0].url, "https://x.com/a");
2143 assert_eq!(md[0].location.1, 1);
2144
2145 let wl = extract_wiki_links(body, Path::new("d.md"));
2147 assert_eq!(wl.len(), 1);
2148 assert_eq!(wl[0].target, "records/contacts/sarah-chen");
2149 }
2150
2151 #[test]
2154 fn link_fields_extracts_scalar_list_and_summary_links() {
2155 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";
2159 let fm = Frontmatter::parse(yaml, Path::new("m.md")).unwrap();
2160 assert!(fm.extra.get("company").and_then(|v| v.as_str()).is_some());
2162 let fields = fm.link_fields();
2163
2164 let company: Vec<&str> = fields
2166 .iter()
2167 .filter(|(k, _)| k == "company")
2168 .map(|(_, l)| l.target.as_str())
2169 .collect();
2170 assert_eq!(company, vec!["records/companies/northstar"]);
2171 let attendees: Vec<&str> = fields
2173 .iter()
2174 .filter(|(k, _)| k == "attendees")
2175 .map(|(_, l)| l.target.as_str())
2176 .collect();
2177 assert_eq!(
2178 attendees,
2179 vec!["records/contacts/elena", "records/contacts/sarah"]
2180 );
2181 assert_eq!(fields.iter().filter(|(k, _)| k == "summary").count(), 1);
2183 assert_eq!(fields.iter().filter(|(k, _)| k == "notes").count(), 0);
2185 }
2186
2187 #[test]
2188 fn link_fields_surfaces_canonical_unquoted_scalar_link() {
2189 let yaml = "type: meeting\ncompany: [[records/companies/northstar]]";
2195 let fm = Frontmatter::parse(yaml, Path::new("m.md")).unwrap();
2196 assert!(fm.extra.get("company").and_then(|v| v.as_str()).is_none());
2198
2199 let fields = fm.link_fields();
2200 let links: Vec<(&str, &str, Option<&str>)> = fields
2201 .iter()
2202 .map(|(k, l)| (k.as_str(), l.target.as_str(), l.display.as_deref()))
2203 .collect();
2204 assert_eq!(
2205 links,
2206 vec![("company", "records/companies/northstar", None)]
2207 );
2208
2209 let fm2 = Frontmatter::parse(
2211 "type: meeting\ncompany: [[records/companies/northstar|Northstar]]",
2212 Path::new("m.md"),
2213 )
2214 .unwrap();
2215 let f2 = fm2.link_fields();
2216 assert_eq!(f2.len(), 1);
2217 assert_eq!(f2[0].0, "company");
2218 assert_eq!(f2[0].1.target, "records/companies/northstar");
2219 assert_eq!(f2[0].1.display.as_deref(), Some("Northstar"));
2220 }
2221
2222 #[test]
2223 fn link_fields_ignores_plain_one_item_flow_list() {
2224 let yaml = "type: contact\naliases: [foo]";
2228 let fm = Frontmatter::parse(yaml, Path::new("c.md")).unwrap();
2229 assert_eq!(fm.link_fields(), Vec::new());
2230 }
2231
2232 #[test]
2235 fn detect_flow_form_flags_list_misencodings_not_scalars() {
2236 let bad = "attendees: [[[records/x]], [[records/y]]]\nscalar_inline: [[records/z]]";
2239 let flagged = detect_flow_form_link_lists(bad);
2240 assert_eq!(flagged, vec!["attendees".to_string()]);
2241
2242 let unquoted_block = "attendees:\n - [[records/x]]\n - [[records/y]]";
2244 assert_eq!(
2245 detect_flow_form_link_lists(unquoted_block),
2246 vec!["attendees".to_string()]
2247 );
2248
2249 let good = "attendees:\n - \"[[records/x]]\"\n - \"[[records/y]]\"";
2251 assert!(detect_flow_form_link_lists(good).is_empty());
2252
2253 let plain = "tags: [a, b, c]";
2255 assert!(detect_flow_form_link_lists(plain).is_empty());
2256 }
2257
2258 #[test]
2261 fn extract_sections_levels_nesting_and_boundaries() {
2262 let body = "intro text\n## First\nalpha\n### Sub\nbeta\n## Second\ngamma\n";
2263 let secs = extract_sections(body);
2264 let headings: Vec<(&str, u8)> =
2265 secs.iter().map(|s| (s.heading.as_str(), s.level)).collect();
2266 assert_eq!(headings, vec![("First", 2), ("Sub", 3), ("Second", 2)]);
2267
2268 let first = &secs[0];
2270 assert!(first.body.contains("alpha"));
2271 assert!(first.body.contains("### Sub"));
2272 assert!(first.body.contains("beta"));
2273 assert!(!first.body.contains("Second"));
2274
2275 let sub = &secs[1];
2277 assert!(sub.body.contains("beta"));
2278 assert!(!sub.body.contains("gamma"));
2279
2280 assert_eq!(first.line, 2);
2282 assert_eq!(secs[2].line, 6);
2283 }
2284
2285 #[test]
2286 fn extract_sections_ignores_headings_in_fenced_code() {
2287 let body = "## Real\n```\n## Fake heading in code\n```\nafter\n";
2288 let secs = extract_sections(body);
2289 assert_eq!(secs.len(), 1);
2290 assert_eq!(secs[0].heading, "Real");
2291 assert!(secs[0].body.contains("## Fake heading in code"));
2293 }
2294
2295 #[test]
2298 fn parse_field_spec_required_and_shape() {
2299 let f = parse_field_spec("- email (required, email)");
2300 assert_eq!(f.name, "email");
2301 assert!(f.required);
2302 assert_eq!(f.shape, Some(Shape::Email));
2303 assert!(f.unknown_modifiers.is_empty());
2304 }
2305
2306 #[test]
2307 fn parse_field_spec_link_prefix_strips_trailing_slash() {
2308 let f = parse_field_spec("- company (required, link to records/companies/)");
2309 assert!(f.required);
2310 assert_eq!(f.link_prefix, Some(PathBuf::from("records/companies")));
2311 assert_eq!(f.shape, None);
2312 }
2313
2314 #[test]
2315 fn parse_field_spec_default_preserves_case_and_value() {
2316 let f = parse_field_spec("- currency (default USD)");
2317 assert_eq!(f.name, "currency");
2318 assert_eq!(f.default, Some(Value::String("USD".into())));
2319 }
2320
2321 #[test]
2322 fn parse_field_spec_enum_captures_comma_list_as_last_modifier() {
2323 let f = parse_field_spec("- status (required, enum: open, closed, pending)");
2324 assert!(f.required);
2325 assert_eq!(
2326 f.enum_values,
2327 Some(vec![
2328 "open".to_string(),
2329 "closed".to_string(),
2330 "pending".to_string()
2331 ])
2332 );
2333 }
2334
2335 #[test]
2336 fn parse_field_spec_unknown_modifier_is_captured_not_errored() {
2337 let f = parse_field_spec("- weird (required, frobnicate, string)");
2338 assert!(f.required);
2339 assert_eq!(f.shape, Some(Shape::String));
2340 assert_eq!(f.unknown_modifiers, vec!["frobnicate".to_string()]);
2341 }
2342
2343 #[test]
2344 fn parse_field_spec_no_parens_is_freeform_optional() {
2345 let f = parse_field_spec("- nickname");
2346 assert_eq!(f.name, "nickname");
2347 assert!(!f.required);
2348 assert_eq!(f.shape, None);
2349 assert!(f.link_prefix.is_none());
2350 assert!(f.enum_values.is_none());
2351 assert!(f.unknown_modifiers.is_empty());
2352 }
2353
2354 #[test]
2357 fn schema_bullet_unique_single_field() {
2358 match parse_schema_bullet("- unique: email") {
2359 SchemaBullet::Unique(fields) => assert_eq!(fields, vec!["email".to_string()]),
2360 other => panic!("expected Unique, got {other:?}"),
2361 }
2362 }
2363
2364 #[test]
2365 fn schema_bullet_unique_compound_trims_and_splits() {
2366 match parse_schema_bullet("- unique: date, amount , vendor") {
2367 SchemaBullet::Unique(fields) => assert_eq!(
2368 fields,
2369 vec![
2370 "date".to_string(),
2371 "amount".to_string(),
2372 "vendor".to_string()
2373 ]
2374 ),
2375 other => panic!("expected Unique, got {other:?}"),
2376 }
2377 }
2378
2379 #[test]
2380 fn schema_bullet_summary_template_keeps_braces_and_inner_colons() {
2381 match parse_schema_bullet("- summary_template: {role} at {company} (x: y)") {
2382 SchemaBullet::SummaryTemplate(t) => assert_eq!(t, "{role} at {company} (x: y)"),
2383 other => panic!("expected SummaryTemplate, got {other:?}"),
2384 }
2385 }
2386
2387 #[test]
2388 fn schema_bullet_field_with_enum_modifier_is_not_a_directive() {
2389 match parse_schema_bullet("- status (enum: open, closed)") {
2392 SchemaBullet::Field(f) => {
2393 assert_eq!(f.name, "status");
2394 assert_eq!(
2395 f.enum_values,
2396 Some(vec!["open".to_string(), "closed".to_string()])
2397 );
2398 }
2399 other => panic!("expected Field, got {other:?}"),
2400 }
2401 }
2402
2403 #[test]
2404 fn parse_db_md_schema_captures_unique_and_summary_template() {
2405 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";
2406 let config = parse_db_md(db, Path::new("DB.md")).unwrap();
2407 let s = config.schemas.get("contact").expect("contact schema");
2408 assert_eq!(s.fields.len(), 1, "directives are not parsed as fields");
2409 assert_eq!(s.unique_keys, vec![vec!["email".to_string()]]);
2410 assert_eq!(s.summary_template.as_deref(), Some("{role} at {company}"));
2411 }
2412
2413 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";
2416
2417 #[test]
2418 fn parse_db_md_extracts_all_canonical_sections() {
2419 let config = parse_db_md(CANONICAL_DB_MD, Path::new("DB.md")).unwrap();
2420
2421 let ai = config
2423 .agent_instructions
2424 .expect("agent instructions present");
2425 assert!(ai.starts_with("Prioritize creating"));
2426 assert!(!ai.contains("## Agent instructions"));
2427
2428 assert_eq!(
2430 config.frozen_pages,
2431 vec![
2432 PathBuf::from("records/decisions/2026-q1-strategy.md"),
2433 PathBuf::from("wiki/synthesis/2026-annual-plan.md"),
2434 ]
2435 );
2436
2437 assert_eq!(
2439 config.ignored_types,
2440 vec!["test".to_string(), "temp".to_string()]
2441 );
2442
2443 assert_eq!(config.schemas.len(), 2);
2445 let contact = config.schemas.get("contact").expect("contact schema");
2446 let names: Vec<&str> = contact.fields.iter().map(|f| f.name.as_str()).collect();
2447 assert_eq!(names, vec!["name", "email", "company", "role"]);
2448 assert!(contact.fields[0].required); assert_eq!(contact.fields[1].shape, Some(Shape::Email)); assert_eq!(
2451 contact.fields[2].link_prefix,
2452 Some(PathBuf::from("records/companies"))
2453 ); let expense = config.schemas.get("expense").expect("expense schema");
2456 let cur = expense
2457 .fields
2458 .iter()
2459 .find(|f| f.name == "currency")
2460 .unwrap();
2461 assert_eq!(cur.default, Some(Value::String("USD".into())));
2462 }
2463
2464 #[test]
2465 fn parse_db_md_handles_malformed_and_unknown_modifiers() {
2466 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";
2470 let config = parse_db_md(text, Path::new("DB.md")).unwrap();
2471
2472 assert_eq!(config.schemas.len(), 1);
2475 let ticket = config.schemas.get("ticket").expect("ticket schema");
2476 assert_eq!(ticket.fields.len(), 2);
2477
2478 let priority = &ticket.fields[0];
2479 assert!(priority.required);
2480 assert_eq!(priority.unknown_modifiers, vec!["mystery".to_string()]);
2481 assert_eq!(
2482 priority.enum_values,
2483 Some(vec!["low".to_string(), "high".to_string()])
2484 );
2485
2486 let broken = &ticket.fields[1];
2488 assert_eq!(broken.name, "broken");
2489 }
2490
2491 #[test]
2492 fn parse_db_md_missing_frontmatter_errors() {
2493 let text = "# No frontmatter\n\n## Agent instructions\nhi\n";
2494 let err = parse_db_md(text, Path::new("DB.md")).unwrap_err();
2495 assert!(matches!(err, ParseError::MissingFrontmatter { .. }));
2496 }
2497
2498 #[test]
2499 fn parse_db_md_absent_sections_default_empty() {
2500 let text = "---\ntype: db-md\n---\n\n# Title only\n";
2501 let config = parse_db_md(text, Path::new("DB.md")).unwrap();
2502 assert_eq!(config, Config::default());
2503 }
2504
2505 #[test]
2515 fn set_list_of_wiki_links_becomes_block_sequence_both_spellings() {
2516 for value in [
2517 "[[[records/contacts/a]], [[records/contacts/b]]]",
2518 r#"["[[records/contacts/a]]", "[[records/contacts/b]]"]"#,
2519 ] {
2520 let mut fm = Frontmatter::default();
2521 fm.set("attendees", value).unwrap();
2522
2523 let stored = fm.extra.get("attendees").expect("attendees set");
2525 let Value::Sequence(items) = stored else {
2526 panic!("attendees must be a Sequence, got {stored:?} for input {value}");
2527 };
2528 assert_eq!(items.len(), 2, "input {value}");
2529 assert_eq!(items[0], Value::String("[[records/contacts/a]]".into()));
2530 assert_eq!(items[1], Value::String("[[records/contacts/b]]".into()));
2531
2532 let links: Vec<_> = links_in_field_value(stored)
2535 .into_iter()
2536 .map(|l| l.target)
2537 .collect();
2538 assert_eq!(
2539 links,
2540 vec!["records/contacts/a", "records/contacts/b"],
2541 "input {value}"
2542 );
2543
2544 let yaml = fm.to_yaml();
2546 assert!(
2547 yaml.contains("attendees:\n"),
2548 "expected block list in:\n{yaml}"
2549 );
2550 assert!(
2551 !yaml.contains("attendees: '[["),
2552 "must not be a flow-form scalar string in:\n{yaml}"
2553 );
2554 }
2555 }
2556
2557 #[test]
2561 fn set_single_inline_wiki_link_stays_scalar() {
2562 let mut fm = Frontmatter::default();
2563 fm.set("company", "[[records/companies/tideform]]").unwrap();
2564 assert_eq!(
2565 fm.extra.get("company"),
2566 Some(&Value::String("[[records/companies/tideform]]".into())),
2567 );
2568 let links: Vec<_> = links_in_field_value(fm.extra.get("company").unwrap())
2570 .into_iter()
2571 .map(|l| l.target)
2572 .collect();
2573 assert_eq!(links, vec!["records/companies/tideform"]);
2574 }
2575
2576 #[test]
2579 fn set_non_link_values_stay_scalar_strings() {
2580 let mut fm = Frontmatter::default();
2581 fm.set("location", "Video call (remote)").unwrap();
2582 assert_eq!(
2583 fm.extra.get("location"),
2584 Some(&Value::String("Video call (remote)".into())),
2585 );
2586
2587 fm.set("note", "[draft, wip]").unwrap();
2590 assert_eq!(
2591 fm.extra.get("note"),
2592 Some(&Value::String("[draft, wip]".into()))
2593 );
2594 }
2595}