specfold_core/corpus/
location.rs1use crate::model::doctype::KeyKind;
5use crate::model::registry::Registry;
6use crate::model::stamp;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum Role {
10 Main,
11 Chapter,
12 Attachment,
13}
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Placed {
18 pub ty: String,
19 pub key: String,
20 pub slug: String,
21 pub stem: String,
23 pub bundle: bool,
24 pub role: Role,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum Location {
29 Doc(Placed),
30 Stray(String),
32}
33
34pub fn split_stem(kind: KeyKind, stem: &str) -> Result<(String, String), String> {
36 match kind {
37 KeyKind::Stamp => {
38 let (key, slug) = stem.split_once('-').unwrap_or((stem, ""));
39 if !stamp::is_stamp(key) {
40 return Err(format!("name must start with a UTC timestamp YYYYMMDDHHMMSS, got '{stem}'"));
41 }
42 if !stamp::is_slug(slug) {
43 return Err(format!("'{slug}' after the timestamp is not a kebab-case slug"));
44 }
45 Ok((key.to_string(), slug.to_string()))
46 }
47 KeyKind::Slug => {
48 if !stamp::is_slug(stem) {
49 return Err(format!("'{stem}' is not a kebab-case slug"));
50 }
51 Ok((stem.to_string(), stem.to_string()))
52 }
53 }
54}
55
56pub fn locate(registry: &Registry, rest: &str) -> Location {
58 let parts: Vec<&str> = rest.split('/').collect();
59 let Some(ty) = registry.get(parts[0]) else {
60 return Location::Stray(format!("'{}' is not a document type directory", parts[0]));
61 };
62 if parts.len() < 2 {
63 return Location::Stray("a file directly in a spec directory".into());
64 }
65 let suffix = format!(".{}.md", ty.name);
66 if parts.len() == 2 {
67 let name = parts[1];
68 let Some(stem) = name.strip_suffix(&suffix) else {
69 let why = if name.ends_with(".md") {
70 format!("a Markdown file without the .{}.md suffix", ty.name)
71 } else {
72 "a file outside a document bundle".to_string()
73 };
74 return Location::Stray(why);
75 };
76 if ty.form == crate::model::doctype::Form::Dir {
77 return Location::Stray(format!("{} documents are directories with index{suffix}", ty.name));
78 }
79 return match split_stem(ty.key, stem) {
80 Ok((key, slug)) => Location::Doc(Placed {
81 ty: ty.name.clone(),
82 key,
83 slug,
84 stem: stem.into(),
85 bundle: false,
86 role: Role::Main,
87 }),
88 Err(e) => Location::Stray(e),
89 };
90 }
91 let stem = parts[1];
92 let (key, slug) = match split_stem(ty.key, stem) {
93 Ok(v) => v,
94 Err(e) => return Location::Stray(e),
95 };
96 let inner = &parts[2..];
97 let role = if inner.len() == 1 && inner[0] == format!("index{suffix}") {
98 Role::Main
99 } else if inner.len() == 1 && inner[0].ends_with(".md") {
100 Role::Chapter
101 } else {
102 Role::Attachment
103 };
104 Location::Doc(Placed { ty: ty.name.clone(), key, slug, stem: stem.into(), bundle: true, role })
105}
106
107#[cfg(test)]
108mod tests {
109 use super::*;
110 use std::collections::BTreeMap;
111
112 fn reg() -> Registry {
113 Registry::new(&BTreeMap::new())
114 }
115
116 #[test]
117 fn places_files() {
118 let r = reg();
119 let Location::Doc(p) = locate(&r, "adr/20260101000000-pick-db.adr.md") else { panic!() };
120 assert_eq!((p.key.as_str(), p.slug.as_str(), p.role), ("20260101000000", "pick-db", Role::Main));
121 let Location::Doc(p) = locate(&r, "sot/checkout.sot.md") else { panic!() };
122 assert_eq!(p.key, "checkout");
123 let Location::Doc(p) = locate(&r, "research/20260101000000-cart/10-api.md") else { panic!() };
124 assert_eq!(p.role, Role::Chapter);
125 let Location::Doc(p) = locate(&r, "design/20260101000000-cart/assets/a.webp") else { panic!() };
126 assert_eq!(p.role, Role::Attachment);
127 assert!(matches!(locate(&r, "plans/x.md"), Location::Stray(_)));
128 assert!(matches!(locate(&r, "adr/ADR-001-x.adr.md"), Location::Stray(_)));
129 assert!(matches!(locate(&r, "evidence/20260101000000-x.evidence.md"), Location::Stray(_)));
130 assert!(matches!(locate(&r, "sot/notes.md"), Location::Stray(_)));
131 }
132}