use std::fmt;
use camino::Utf8PathBuf;
use clap::ValueEnum;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum, Serialize, Deserialize)]
#[value(rename_all = "kebab-case")]
#[serde(rename_all = "kebab-case")]
pub enum ProfileId {
Codebase,
KnowledgeBase,
}
impl ProfileId {
#[must_use]
pub const fn profile(self) -> &'static Profile {
match self {
Self::Codebase => &CODEBASE,
Self::KnowledgeBase => &KNOWLEDGE_BASE,
}
}
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Codebase => "codebase",
Self::KnowledgeBase => "knowledge-base",
}
}
}
impl fmt::Display for ProfileId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum DocsRoot {
#[serde(rename = "docs")]
Docs,
#[serde(rename = "_docs")]
UnderscoreDocs,
}
impl DocsRoot {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Docs => "docs",
Self::UnderscoreDocs => "_docs",
}
}
}
impl fmt::Display for DocsRoot {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Projection {
pub source: &'static str,
pub destination: &'static str,
}
const fn proj(source: &'static str, destination: &'static str) -> Projection {
Projection {
source,
destination,
}
}
#[must_use]
#[allow(
clippy::literal_string_with_formatting_args,
reason = "the braces are the destination template's placeholder, not a formatting argument"
)]
pub fn resolve_destination(destination: &str, docs_root: DocsRoot) -> Utf8PathBuf {
Utf8PathBuf::from(destination.replace("{docs_root}", docs_root.as_str()))
}
#[derive(Debug)]
pub struct Profile {
pub id: ProfileId,
pub docs_root: DocsRoot,
pub managed: &'static [Projection],
pub adopted: &'static [Projection],
}
const MANAGED: &[Projection] = &[
proj(
".markdownlint/adr.markdownlint-cli2.jsonc",
".spec-driven-docs/markdownlint/adr.markdownlint-cli2.jsonc",
),
proj(
".markdownlint/spec.markdownlint-cli2.jsonc",
".spec-driven-docs/markdownlint/spec.markdownlint-cli2.jsonc",
),
proj(
".markdownlint/relative-links.markdownlint-cli2.jsonc",
".spec-driven-docs/markdownlint/relative-links.markdownlint-cli2.jsonc",
),
];
pub const CANON_TEMPLATES: &[&str] = &[
"_docs/decisions/TEMPLATE-adr.md",
"_docs/reference/TEMPLATE-agents-digest.md",
];
const ADOPTED: &[Projection] = &[
proj(
"instance/seeds/config.yaml",
".spec-driven-docs/config.yaml",
),
proj(
"_docs/specs/SPEC-budget-debt.md",
"{docs_root}/specs/SPEC-budget-debt.md",
),
proj(
"_docs/specs/SPEC-writing-policy.md",
"{docs_root}/specs/SPEC-writing-policy.md",
),
proj(
"_docs/specs/SPEC-decision-records.md",
"{docs_root}/specs/SPEC-decision-records.md",
),
proj(
"_docs/specs/SPEC-instance.md",
"{docs_root}/specs/SPEC-instance.md",
),
proj(
"_docs/specs/SPEC-docs-format.md",
"{docs_root}/specs/SPEC-docs-format.md",
),
proj(
"_docs/specs/SPEC-docs-foundations.md",
"{docs_root}/specs/SPEC-docs-foundations.md",
),
proj(
"_docs/specs/SPEC-docs-specs.md",
"{docs_root}/specs/SPEC-docs-specs.md",
),
proj(
"_docs/specs/SPEC-comparison-docs.md",
"{docs_root}/specs/SPEC-comparison-docs.md",
),
proj(
"_docs/specs/SPEC-known-issues.md",
"{docs_root}/specs/SPEC-known-issues.md",
),
proj(
"_docs/specs/SPEC-spec-to-code.md",
"{docs_root}/specs/SPEC-spec-to-code.md",
),
proj(
"_docs/specs/SPEC-guides.md",
"{docs_root}/specs/SPEC-guides.md",
),
proj(
"_docs/specs/SPEC-writing-style.md",
"{docs_root}/specs/SPEC-writing-style.md",
),
proj(
"_docs/specs/SPEC-tracking.md",
"{docs_root}/specs/SPEC-tracking.md",
),
proj(
"_docs/specs/SPEC-tracking/tracking.schema.json",
"{docs_root}/specs/SPEC-tracking/tracking.schema.json",
),
proj(
"templates/TEMPLATE-tracking.yaml",
"{docs_root}/reference/tracking.yaml",
),
proj(
"templates/TEMPLATE-spec.md",
"{docs_root}/specs/TEMPLATE-spec.md",
),
proj(
"templates/TEMPLATE-adr.md",
"{docs_root}/decisions/TEMPLATE-adr.md",
),
proj(
"templates/TEMPLATE-agents-digest.md",
"{docs_root}/reference/TEMPLATE-agents-digest.md",
),
proj(
"templates/TEMPLATE-guide.md",
"{docs_root}/guides/TEMPLATE-guide.md",
),
proj(
"templates/TEMPLATE-known-issue.md",
"{docs_root}/reference/TEMPLATE-known-issue.md",
),
];
static CODEBASE: Profile = Profile {
id: ProfileId::Codebase,
docs_root: DocsRoot::Docs,
managed: MANAGED,
adopted: ADOPTED,
};
static KNOWLEDGE_BASE: Profile = Profile {
id: ProfileId::KnowledgeBase,
docs_root: DocsRoot::UnderscoreDocs,
managed: MANAGED,
adopted: ADOPTED,
};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn profiles_bind_their_roots() {
assert_eq!(ProfileId::Codebase.profile().docs_root, DocsRoot::Docs);
assert_eq!(
ProfileId::KnowledgeBase.profile().docs_root,
DocsRoot::UnderscoreDocs
);
}
#[test]
fn destinations_resolve_per_root() {
assert_eq!(
resolve_destination("{docs_root}/specs/SPEC-distribution.md", DocsRoot::Docs),
Utf8PathBuf::from("docs/specs/SPEC-distribution.md")
);
assert_eq!(
resolve_destination(
".spec-driven-docs/markdownlint/x.jsonc",
DocsRoot::UnderscoreDocs
),
Utf8PathBuf::from(".spec-driven-docs/markdownlint/x.jsonc")
);
}
#[test]
fn serde_uses_the_kebab_names() {
assert_eq!(
serde_json::to_string(&ProfileId::KnowledgeBase).unwrap(),
"\"knowledge-base\""
);
assert_eq!(
serde_json::to_string(&DocsRoot::UnderscoreDocs).unwrap(),
"\"_docs\""
);
}
#[test]
fn destination_templates_only_use_the_placeholder_in_adopted_paths() {
for entry in MANAGED {
assert!(
!entry.destination.contains('{'),
"{} is templated",
entry.destination
);
}
for entry in ADOPTED {
if entry.destination == ".spec-driven-docs/config.yaml" {
continue;
}
assert!(
entry.destination.starts_with("{docs_root}/"),
"{} is not rooted",
entry.destination
);
}
}
}