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 payload files land managed and which land adopted, and where.
5//! The declaration is data the release carries, so an engine can read what
6//! any release it can fetch lands rather than only what it was compiled
7//! with. Copying bytes and recording hashes is the installer's work.
8
9use std::fmt;
10use std::sync::LazyLock;
11
12use camino::Utf8PathBuf;
13use clap::ValueEnum;
14use serde::{Deserialize, Serialize};
15
16pub use crate::domain::projection::Projection;
17use crate::domain::projection::{DECLARATION_PATH, Declaration};
18
19/// The two installable profiles.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum, Serialize, Deserialize)]
21#[value(rename_all = "kebab-case")]
22#[serde(rename_all = "kebab-case")]
23pub enum ProfileId {
24    /// A codebase whose records live under `docs/`.
25    Codebase,
26    /// A knowledge base whose records live under `_docs/`.
27    KnowledgeBase,
28}
29
30/// Every profile, in declaration order.
31pub const EVERY_PROFILE: [ProfileId; 2] = [ProfileId::Codebase, ProfileId::KnowledgeBase];
32
33impl ProfileId {
34    /// Every profile, in declaration order.
35    pub fn every() -> impl Iterator<Item = Self> {
36        EVERY_PROFILE.into_iter()
37    }
38
39    /// This binary's own release, as this profile.
40    ///
41    /// A verb that lands another release reads that release's declaration
42    /// instead, through [`crate::domain::projection::Declaration::profile`].
43    #[must_use]
44    pub fn profile(self) -> &'static Profile<'static> {
45        match self {
46            Self::Codebase => &CODEBASE,
47            Self::KnowledgeBase => &KNOWLEDGE_BASE,
48        }
49    }
50
51    /// The kebab-case name used on the command line and in the manifest.
52    #[must_use]
53    pub const fn as_str(self) -> &'static str {
54        match self {
55            Self::Codebase => "codebase",
56            Self::KnowledgeBase => "knowledge-base",
57        }
58    }
59}
60
61impl fmt::Display for ProfileId {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        f.write_str(self.as_str())
64    }
65}
66
67/// Where an instance keeps the documents the gates read.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
69pub enum DocsRoot {
70    /// `docs/` — the codebase convention.
71    #[serde(rename = "docs")]
72    Docs,
73    /// `_docs/` — the knowledge-base convention.
74    #[serde(rename = "_docs")]
75    UnderscoreDocs,
76}
77
78impl DocsRoot {
79    /// The directory name.
80    #[must_use]
81    pub const fn as_str(self) -> &'static str {
82        match self {
83            Self::Docs => "docs",
84            Self::UnderscoreDocs => "_docs",
85        }
86    }
87}
88
89impl fmt::Display for DocsRoot {
90    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91        f.write_str(self.as_str())
92    }
93}
94
95/// Substitute the profile's documentation root into a destination template.
96#[must_use]
97#[allow(
98    clippy::literal_string_with_formatting_args,
99    reason = "the braces are the destination template's placeholder, not a formatting argument"
100)]
101pub fn resolve_destination(destination: &str, docs_root: DocsRoot) -> Utf8PathBuf {
102    Utf8PathBuf::from(destination.replace("{docs_root}", docs_root.as_str()))
103}
104
105/// What one profile installs, as one declaration describes it.
106#[derive(Debug, Clone, Copy)]
107pub struct Profile<'a> {
108    /// The profile this view belongs to.
109    pub id: ProfileId,
110    /// The documentation root the instance uses.
111    pub docs_root: DocsRoot,
112    /// Byte projections the canon keeps owning.
113    pub managed: &'a [Projection],
114    /// Seeds the instance owns from the moment they land.
115    pub adopted: &'a [Projection],
116}
117
118/// What this binary's own release declares.
119///
120/// The bytes are embedded, so a declaration that does not parse is a defect
121/// in the build rather than a state a command can meet. The canon suite
122/// parses the same file, so the failure lands in the test run.
123#[expect(
124    clippy::expect_used,
125    reason = "the declaration is compiled in; a parse failure is a build defect the canon suite catches first"
126)]
127pub static DECLARATION: LazyLock<Declaration> = LazyLock::new(|| {
128    let bytes = crate::embedded::asset(DECLARATION_PATH)
129        .expect("the payload carries instance/projection.toml");
130    Declaration::parse(bytes).expect("the embedded projection declaration parses")
131});
132
133/// The template copies this repository keeps in its own documentation tree.
134pub static CANON_TEMPLATES: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
135    DECLARATION
136        .canon_templates
137        .iter()
138        .map(String::as_str)
139        .collect()
140});
141
142static CODEBASE: LazyLock<Profile<'static>> = LazyLock::new(|| profile_of(ProfileId::Codebase));
143static KNOWLEDGE_BASE: LazyLock<Profile<'static>> =
144    LazyLock::new(|| profile_of(ProfileId::KnowledgeBase));
145
146/// One profile of this binary's own release.
147#[expect(
148    clippy::expect_used,
149    reason = "a profile the declaration omits is the same build defect as a declaration that does not parse"
150)]
151fn profile_of(id: ProfileId) -> Profile<'static> {
152    DECLARATION
153        .profile(id)
154        .expect("the embedded declaration offers every profile")
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn profiles_bind_their_roots() {
163        assert_eq!(ProfileId::Codebase.profile().docs_root, DocsRoot::Docs);
164        assert_eq!(
165            ProfileId::KnowledgeBase.profile().docs_root,
166            DocsRoot::UnderscoreDocs
167        );
168    }
169
170    #[test]
171    fn destinations_resolve_per_root() {
172        assert_eq!(
173            resolve_destination("{docs_root}/specs/SPEC-distribution.md", DocsRoot::Docs),
174            Utf8PathBuf::from("docs/specs/SPEC-distribution.md")
175        );
176        assert_eq!(
177            resolve_destination(
178                ".spec-driven-docs/markdownlint/x.jsonc",
179                DocsRoot::UnderscoreDocs
180            ),
181            Utf8PathBuf::from(".spec-driven-docs/markdownlint/x.jsonc")
182        );
183    }
184
185    #[test]
186    fn serde_uses_the_kebab_names() {
187        assert_eq!(
188            serde_json::to_string(&ProfileId::KnowledgeBase).unwrap(),
189            "\"knowledge-base\""
190        );
191        assert_eq!(
192            serde_json::to_string(&DocsRoot::UnderscoreDocs).unwrap(),
193            "\"_docs\""
194        );
195    }
196
197    #[test]
198    fn destination_templates_only_use_the_placeholder_in_adopted_paths() {
199        let profile = ProfileId::KnowledgeBase.profile();
200        for entry in profile.managed {
201            assert!(
202                !entry.destination.contains('{'),
203                "{} is templated",
204                entry.destination
205            );
206        }
207        for entry in profile.adopted {
208            // The declaration is the one adopted file outside the corpus: it
209            // configures the tool rather than being documentation, so no
210            // documentation root names it.
211            if entry.destination == crate::domain::paths::CONFIG_PATH {
212                continue;
213            }
214            assert!(
215                entry.destination.starts_with("{docs_root}/"),
216                "{} is not rooted",
217                entry.destination
218            );
219        }
220    }
221
222    #[test]
223    fn the_canon_templates_come_from_the_declaration() {
224        assert_eq!(*CANON_TEMPLATES, DECLARATION.canon_templates);
225    }
226}