Skip to main content

spec_driven_docs/domain/
profile.rs

1//! Installation profiles: what a target repository receives.
2//!
3//! A profile declares the documentation root and the payload projection —
4//! which embedded files land managed and which land adopted, and where.
5//! The declarations are code so a profile referencing an asset the payload
6//! does not carry fails a test instead of an install. Copying bytes and
7//! recording hashes is the installer's work.
8
9use std::fmt;
10
11use camino::Utf8PathBuf;
12use clap::ValueEnum;
13use serde::{Deserialize, Serialize};
14
15/// The two installable profiles.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum, Serialize, Deserialize)]
17#[value(rename_all = "kebab-case")]
18#[serde(rename_all = "kebab-case")]
19pub enum ProfileId {
20    /// A codebase whose records live under `docs/`.
21    Codebase,
22    /// A knowledge base whose records live under `_docs/`.
23    KnowledgeBase,
24}
25
26impl ProfileId {
27    /// The profile's declaration.
28    #[must_use]
29    pub const fn profile(self) -> &'static Profile {
30        match self {
31            Self::Codebase => &CODEBASE,
32            Self::KnowledgeBase => &KNOWLEDGE_BASE,
33        }
34    }
35
36    /// The kebab-case name used on the command line and in the manifest.
37    #[must_use]
38    pub const fn as_str(self) -> &'static str {
39        match self {
40            Self::Codebase => "codebase",
41            Self::KnowledgeBase => "knowledge-base",
42        }
43    }
44}
45
46impl fmt::Display for ProfileId {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        f.write_str(self.as_str())
49    }
50}
51
52/// Where an instance keeps the documents the gates read.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
54pub enum DocsRoot {
55    /// `docs/` — the codebase convention.
56    #[serde(rename = "docs")]
57    Docs,
58    /// `_docs/` — the knowledge-base convention.
59    #[serde(rename = "_docs")]
60    UnderscoreDocs,
61}
62
63impl DocsRoot {
64    /// The directory name.
65    #[must_use]
66    pub const fn as_str(self) -> &'static str {
67        match self {
68            Self::Docs => "docs",
69            Self::UnderscoreDocs => "_docs",
70        }
71    }
72}
73
74impl fmt::Display for DocsRoot {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        f.write_str(self.as_str())
77    }
78}
79
80/// One payload projection: an embedded source and its instance destination.
81///
82/// An adopted destination may carry a `{docs_root}` placeholder, resolved
83/// per profile by [`resolve_destination`].
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub struct Projection {
86    /// The embedded payload path.
87    pub source: &'static str,
88    /// The destination, relative to the instance root.
89    pub destination: &'static str,
90}
91
92const fn proj(source: &'static str, destination: &'static str) -> Projection {
93    Projection {
94        source,
95        destination,
96    }
97}
98
99/// Substitute the profile's documentation root into a destination template.
100#[must_use]
101// The braces are the template placeholder itself, not a formatting argument.
102#[allow(clippy::literal_string_with_formatting_args)]
103pub fn resolve_destination(destination: &str, docs_root: DocsRoot) -> Utf8PathBuf {
104    Utf8PathBuf::from(destination.replace("{docs_root}", docs_root.as_str()))
105}
106
107/// What one profile installs.
108#[derive(Debug)]
109pub struct Profile {
110    /// The profile this declaration belongs to.
111    pub id: ProfileId,
112    /// The documentation root the instance uses.
113    pub docs_root: DocsRoot,
114    /// Byte projections the canon keeps owning.
115    pub managed: &'static [Projection],
116    /// Seeds the instance owns from the moment they land.
117    pub adopted: &'static [Projection],
118}
119
120const MANAGED: &[Projection] = &[
121    proj(
122        ".markdownlint/adr.markdownlint-cli2.jsonc",
123        ".spec-driven-docs/markdownlint/adr.markdownlint-cli2.jsonc",
124    ),
125    proj(
126        ".markdownlint/spec.markdownlint-cli2.jsonc",
127        ".spec-driven-docs/markdownlint/spec.markdownlint-cli2.jsonc",
128    ),
129    proj(
130        ".markdownlint/relative-links.markdownlint-cli2.jsonc",
131        ".spec-driven-docs/markdownlint/relative-links.markdownlint-cli2.jsonc",
132    ),
133];
134
135const ADOPTED: &[Projection] = &[
136    proj(
137        "_docs/specs/SPEC-decision-records.md",
138        "{docs_root}/specs/SPEC-decision-records.md",
139    ),
140    proj(
141        "_docs/specs/SPEC-distribution.md",
142        "{docs_root}/specs/SPEC-distribution.md",
143    ),
144    proj(
145        "_docs/specs/SPEC-docs-format.md",
146        "{docs_root}/specs/SPEC-docs-format.md",
147    ),
148    proj(
149        "_docs/specs/SPEC-docs-foundations.md",
150        "{docs_root}/specs/SPEC-docs-foundations.md",
151    ),
152    proj(
153        "_docs/specs/SPEC-docs-specs.md",
154        "{docs_root}/specs/SPEC-docs-specs.md",
155    ),
156    proj(
157        "_docs/specs/SPEC-comparison-docs.md",
158        "{docs_root}/specs/SPEC-comparison-docs.md",
159    ),
160    proj(
161        "_docs/specs/SPEC-known-issues.md",
162        "{docs_root}/specs/SPEC-known-issues.md",
163    ),
164    proj(
165        "_docs/specs/SPEC-spec-to-code.md",
166        "{docs_root}/specs/SPEC-spec-to-code.md",
167    ),
168    proj(
169        "templates/TEMPLATE-spec.md",
170        "{docs_root}/specs/TEMPLATE-spec.md",
171    ),
172    proj(
173        "templates/TEMPLATE-adr.md",
174        "{docs_root}/decisions/TEMPLATE-adr.md",
175    ),
176    proj(
177        "templates/TEMPLATE-agents-digest.md",
178        "{docs_root}/reference/TEMPLATE-agents-digest.md",
179    ),
180];
181
182static CODEBASE: Profile = Profile {
183    id: ProfileId::Codebase,
184    docs_root: DocsRoot::Docs,
185    managed: MANAGED,
186    adopted: ADOPTED,
187};
188
189static KNOWLEDGE_BASE: Profile = Profile {
190    id: ProfileId::KnowledgeBase,
191    docs_root: DocsRoot::UnderscoreDocs,
192    managed: MANAGED,
193    adopted: ADOPTED,
194};
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn profiles_bind_their_roots() {
202        assert_eq!(ProfileId::Codebase.profile().docs_root, DocsRoot::Docs);
203        assert_eq!(
204            ProfileId::KnowledgeBase.profile().docs_root,
205            DocsRoot::UnderscoreDocs
206        );
207    }
208
209    #[test]
210    fn destinations_resolve_per_root() {
211        assert_eq!(
212            resolve_destination("{docs_root}/specs/SPEC-distribution.md", DocsRoot::Docs),
213            Utf8PathBuf::from("docs/specs/SPEC-distribution.md")
214        );
215        assert_eq!(
216            resolve_destination(
217                ".spec-driven-docs/markdownlint/x.jsonc",
218                DocsRoot::UnderscoreDocs
219            ),
220            Utf8PathBuf::from(".spec-driven-docs/markdownlint/x.jsonc")
221        );
222    }
223
224    #[test]
225    fn serde_uses_the_kebab_names() {
226        assert_eq!(
227            serde_json::to_string(&ProfileId::KnowledgeBase).unwrap(),
228            "\"knowledge-base\""
229        );
230        assert_eq!(
231            serde_json::to_string(&DocsRoot::UnderscoreDocs).unwrap(),
232            "\"_docs\""
233        );
234    }
235
236    #[test]
237    fn destination_templates_only_use_the_placeholder_in_adopted_paths() {
238        for entry in MANAGED {
239            assert!(
240                !entry.destination.contains('{'),
241                "{} is templated",
242                entry.destination
243            );
244        }
245        for entry in ADOPTED {
246            assert!(
247                entry.destination.starts_with("{docs_root}/"),
248                "{} is not rooted",
249                entry.destination
250            );
251        }
252    }
253}