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#[allow(
102    clippy::literal_string_with_formatting_args,
103    reason = "the braces are the destination template's placeholder, not a formatting argument"
104)]
105pub fn resolve_destination(destination: &str, docs_root: DocsRoot) -> Utf8PathBuf {
106    Utf8PathBuf::from(destination.replace("{docs_root}", docs_root.as_str()))
107}
108
109/// What one profile installs.
110#[derive(Debug)]
111pub struct Profile {
112    /// The profile this declaration belongs to.
113    pub id: ProfileId,
114    /// The documentation root the instance uses.
115    pub docs_root: DocsRoot,
116    /// Byte projections the canon keeps owning.
117    pub managed: &'static [Projection],
118    /// Seeds the instance owns from the moment they land.
119    pub adopted: &'static [Projection],
120}
121
122/// Byte projections every profile installs.
123///
124/// No skill appears here. A skill name is what an agent's picker keys on,
125/// so an instance copy and the user-scope copy of one skill are two entries
126/// under one name in every session opened inside that instance. User scope
127/// owns them alone.
128///
129/// SATISFIES distribution:a-skill-has-one-owner
130const MANAGED: &[Projection] = &[
131    proj(
132        ".markdownlint/adr.markdownlint-cli2.jsonc",
133        ".spec-driven-docs/markdownlint/adr.markdownlint-cli2.jsonc",
134    ),
135    proj(
136        ".markdownlint/spec.markdownlint-cli2.jsonc",
137        ".spec-driven-docs/markdownlint/spec.markdownlint-cli2.jsonc",
138    ),
139    proj(
140        ".markdownlint/relative-links.markdownlint-cli2.jsonc",
141        ".spec-driven-docs/markdownlint/relative-links.markdownlint-cli2.jsonc",
142    ),
143];
144
145/// The template copies this repository keeps in its own documentation tree.
146///
147/// A canon-side copy exists only for a class this repository authors, so
148/// this list is shorter than the template projections above: the record
149/// generator writes these and the self-layout check expects them. One
150/// declaration is what stops the two from disagreeing, which is how a
151/// template once reached the tree that neither of them named.
152pub const CANON_TEMPLATES: &[&str] = &[
153    "_docs/decisions/TEMPLATE-adr.md",
154    "_docs/reference/TEMPLATE-agents-digest.md",
155];
156
157const ADOPTED: &[Projection] = &[
158    // What the project declares about the files its gates judge. Seeded
159    // once and then the project's, which is what adopted means. It is not
160    // under `{docs_root}`: it configures the tool rather than the corpus.
161    proj(
162        "instance/seeds/config.yaml",
163        ".spec-driven-docs/config.yaml",
164    ),
165    // The specifications that authorize what a project can declare. Each
166    // one is an addition rather than an edit to an existing seed, because
167    // an addition reaches an existing instance on its next upgrade and an
168    // edit to an adopted file never does.
169    proj(
170        "_docs/specs/SPEC-budget-debt.md",
171        "{docs_root}/specs/SPEC-budget-debt.md",
172    ),
173    proj(
174        "_docs/specs/SPEC-writing-policy.md",
175        "{docs_root}/specs/SPEC-writing-policy.md",
176    ),
177    proj(
178        "_docs/specs/SPEC-decision-records.md",
179        "{docs_root}/specs/SPEC-decision-records.md",
180    ),
181    proj(
182        "_docs/specs/SPEC-instance.md",
183        "{docs_root}/specs/SPEC-instance.md",
184    ),
185    proj(
186        "_docs/specs/SPEC-docs-format.md",
187        "{docs_root}/specs/SPEC-docs-format.md",
188    ),
189    proj(
190        "_docs/specs/SPEC-docs-foundations.md",
191        "{docs_root}/specs/SPEC-docs-foundations.md",
192    ),
193    proj(
194        "_docs/specs/SPEC-docs-specs.md",
195        "{docs_root}/specs/SPEC-docs-specs.md",
196    ),
197    proj(
198        "_docs/specs/SPEC-comparison-docs.md",
199        "{docs_root}/specs/SPEC-comparison-docs.md",
200    ),
201    proj(
202        "_docs/specs/SPEC-known-issues.md",
203        "{docs_root}/specs/SPEC-known-issues.md",
204    ),
205    proj(
206        "_docs/specs/SPEC-spec-to-code.md",
207        "{docs_root}/specs/SPEC-spec-to-code.md",
208    ),
209    proj(
210        "_docs/specs/SPEC-guides.md",
211        "{docs_root}/specs/SPEC-guides.md",
212    ),
213    proj(
214        "_docs/specs/SPEC-writing-style.md",
215        "{docs_root}/specs/SPEC-writing-style.md",
216    ),
217    proj(
218        "_docs/specs/SPEC-tracking.md",
219        "{docs_root}/specs/SPEC-tracking.md",
220    ),
221    proj(
222        "_docs/specs/SPEC-tracking/tracking.schema.json",
223        "{docs_root}/specs/SPEC-tracking/tracking.schema.json",
224    ),
225    proj(
226        "templates/TEMPLATE-tracking.yaml",
227        "{docs_root}/reference/tracking.yaml",
228    ),
229    proj(
230        "templates/TEMPLATE-spec.md",
231        "{docs_root}/specs/TEMPLATE-spec.md",
232    ),
233    proj(
234        "templates/TEMPLATE-adr.md",
235        "{docs_root}/decisions/TEMPLATE-adr.md",
236    ),
237    proj(
238        "templates/TEMPLATE-agents-digest.md",
239        "{docs_root}/reference/TEMPLATE-agents-digest.md",
240    ),
241    proj(
242        "templates/TEMPLATE-guide.md",
243        "{docs_root}/guides/TEMPLATE-guide.md",
244    ),
245    proj(
246        "templates/TEMPLATE-known-issue.md",
247        "{docs_root}/reference/TEMPLATE-known-issue.md",
248    ),
249];
250
251static CODEBASE: Profile = Profile {
252    id: ProfileId::Codebase,
253    docs_root: DocsRoot::Docs,
254    managed: MANAGED,
255    adopted: ADOPTED,
256};
257
258static KNOWLEDGE_BASE: Profile = Profile {
259    id: ProfileId::KnowledgeBase,
260    docs_root: DocsRoot::UnderscoreDocs,
261    managed: MANAGED,
262    adopted: ADOPTED,
263};
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn profiles_bind_their_roots() {
271        assert_eq!(ProfileId::Codebase.profile().docs_root, DocsRoot::Docs);
272        assert_eq!(
273            ProfileId::KnowledgeBase.profile().docs_root,
274            DocsRoot::UnderscoreDocs
275        );
276    }
277
278    #[test]
279    fn destinations_resolve_per_root() {
280        assert_eq!(
281            resolve_destination("{docs_root}/specs/SPEC-distribution.md", DocsRoot::Docs),
282            Utf8PathBuf::from("docs/specs/SPEC-distribution.md")
283        );
284        assert_eq!(
285            resolve_destination(
286                ".spec-driven-docs/markdownlint/x.jsonc",
287                DocsRoot::UnderscoreDocs
288            ),
289            Utf8PathBuf::from(".spec-driven-docs/markdownlint/x.jsonc")
290        );
291    }
292
293    #[test]
294    fn serde_uses_the_kebab_names() {
295        assert_eq!(
296            serde_json::to_string(&ProfileId::KnowledgeBase).unwrap(),
297            "\"knowledge-base\""
298        );
299        assert_eq!(
300            serde_json::to_string(&DocsRoot::UnderscoreDocs).unwrap(),
301            "\"_docs\""
302        );
303    }
304
305    #[test]
306    fn destination_templates_only_use_the_placeholder_in_adopted_paths() {
307        for entry in MANAGED {
308            assert!(
309                !entry.destination.contains('{'),
310                "{} is templated",
311                entry.destination
312            );
313        }
314        for entry in ADOPTED {
315            // The declaration is the one adopted file outside the corpus: it
316            // configures the tool rather than being documentation, so no
317            // documentation root names it.
318            if entry.destination == ".spec-driven-docs/config.yaml" {
319                continue;
320            }
321            assert!(
322                entry.destination.starts_with("{docs_root}/"),
323                "{} is not rooted",
324                entry.destination
325            );
326        }
327    }
328}