Skip to main content

specfold_core/corpus/
location.rs

1//! What a path inside a spec directory is: a document's main file, a chapter, an attachment, or
2//! something that does not belong there.
3
4use 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/// A path that belongs to a document.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Placed {
18    pub ty: String,
19    pub key: String,
20    pub slug: String,
21    /// The file name stem or bundle directory name: `<key>-<slug>` or `<slug>`.
22    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    /// Not a valid place for anything; the reason says why.
31    Stray(String),
32}
33
34/// Splits a document stem into key and slug according to the type's key kind.
35pub 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
56/// `rest` is the path below a spec directory, e.g. `adr/20260101000000-x.adr.md`.
57pub 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}