spec_spine_types/config.rs
1//! The `spec-spine.toml` configuration model.
2//!
3//! Everything the reference repos had to fork over is a knob here. An absent
4//! config yields a working default for a single-Cargo-workspace repo with
5//! `specs/` at the root ([`Config::default`]). Every struct is
6//! `#[serde(default, deny_unknown_fields)]`: missing keys default, and a
7//! *misspelled* knob is a loud [`Error::Config`] rather than a silently-ignored
8//! setting: the exact failure class that left template-encore blind to its npm
9//! packages. See `docs/design/00-architecture.md` §3.
10
11use std::collections::BTreeMap;
12
13use serde::{Deserialize, Serialize};
14
15use crate::error::{Error, Result};
16
17/// The full configuration. All sections are optional. `Default` is derived;
18/// each field's own `Default` supplies the conventional value.
19#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
20#[serde(default, deny_unknown_fields)]
21pub struct Config {
22 pub manifest: ManifestConfig,
23 /// Opt-in `domain` taxonomy (empty `allowed` ⇒ free-text/disabled).
24 pub domains: AllowlistConfig,
25 /// Opt-in `kind` taxonomy, symmetric with `domains` (empty ⇒ disabled).
26 pub kind: AllowlistConfig,
27 pub layout: LayoutConfig,
28 pub index: IndexConfig,
29 pub branding: BrandingConfig,
30 pub coupling: CouplingConfig,
31 pub provenance: ProvenanceConfig,
32 pub frontmatter: FrontmatterConfig,
33}
34
35/// `[manifest]`: how a manifest links a compilation unit back to its spec.
36#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
37#[serde(default, deny_unknown_fields)]
38pub struct ManifestConfig {
39 /// Drives both `[package.metadata.<ns>].spec` (Cargo) and `"<ns>".spec`
40 /// (package.json). OAP used `oap`; aide/encore used `spec`.
41 pub metadata_namespace: String,
42}
43
44impl Default for ManifestConfig {
45 fn default() -> Self {
46 ManifestConfig {
47 metadata_namespace: "spec-spine".to_string(),
48 }
49 }
50}
51
52/// A reusable opt-in categorical allowlist (used by `[domains]` and `[kind]`).
53///
54/// Empty ⇒ the field is free-text / disabled (no enum check). Non-empty ⇒ a
55/// closed enum: the field value, *when present*, must be a member (a `V`-error
56/// otherwise). Field absence is allowed.
57#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
58#[serde(default, deny_unknown_fields)]
59pub struct AllowlistConfig {
60 pub allowed: Vec<String>,
61}
62
63impl AllowlistConfig {
64 /// True if this taxonomy is disabled (no allowlist configured).
65 pub fn is_disabled(&self) -> bool {
66 self.allowed.is_empty()
67 }
68
69 /// True if `value` is permitted: always when disabled, else membership.
70 pub fn permits(&self, value: &str) -> bool {
71 self.is_disabled() || self.allowed.iter().any(|a| a == value)
72 }
73}
74
75/// `[layout]`: path conventions. Never hardcode `specs/`, `.derived/`, etc.
76#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
77#[serde(default, deny_unknown_fields)]
78pub struct LayoutConfig {
79 pub specs_dir: String,
80 pub derived_dir: String,
81 pub standards_dir: String,
82 pub schemas_dir: String,
83 /// Root Cargo workspace manifest (relative to repo root).
84 pub cargo_workspace: String,
85 /// Manifests that DECLARE npm/pnpm workspace members. The indexer reads
86 /// member globs from whichever exists. The default reads root
87 /// `package.json#workspaces`, fixing the template-encore bug where a
88 /// hardcoded `public/pnpm-workspace.yaml` made all npm packages invisible.
89 pub npm_workspaces: Vec<String>,
90 /// Crates outside the root Cargo workspace.
91 pub standalone_rust_workspaces: Vec<String>,
92 /// npm packages outside the declared workspaces.
93 pub standalone_npm_packages: Vec<String>,
94}
95
96impl Default for LayoutConfig {
97 fn default() -> Self {
98 LayoutConfig {
99 specs_dir: "specs".to_string(),
100 derived_dir: ".derived".to_string(),
101 standards_dir: "standards/spec".to_string(),
102 schemas_dir: "standards/schemas".to_string(),
103 cargo_workspace: "Cargo.toml".to_string(),
104 npm_workspaces: vec![
105 "package.json".to_string(),
106 "pnpm-workspace.yaml".to_string(),
107 ],
108 standalone_rust_workspaces: Vec::new(),
109 standalone_npm_packages: Vec::new(),
110 }
111 }
112}
113
114/// `[index]`: inputs and exclusions for the codebase indexer.
115#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
116#[serde(default, deny_unknown_fields)]
117pub struct IndexConfig {
118 /// Globs folded into the content-hash beyond the always-hashed core
119 /// (all spec.md + discovered manifests + `spec-spine.toml`).
120 pub extra_hashed_inputs: Vec<String>,
121 /// Directory names pruned from symbol/section resolution walks.
122 pub resolver_exclusions: Vec<String>,
123 /// `[index.slices]` (spec 012): named glob groups, each emitted as a
124 /// `build.sliceHashes` entry and gated by `index check --slice <name>`.
125 /// Names match `[a-z0-9][a-z0-9-]*`; each list is non-empty, with
126 /// `extra_hashed_inputs` pattern semantics. Slices are independent of the
127 /// global hash: listing a file here does NOT fold it into `contentHash`.
128 pub slices: BTreeMap<String, Vec<String>>,
129}
130
131impl Default for IndexConfig {
132 fn default() -> Self {
133 IndexConfig {
134 extra_hashed_inputs: vec![
135 "standards/**".to_string(),
136 ".github/workflows/**".to_string(),
137 ],
138 slices: BTreeMap::new(),
139 resolver_exclusions: vec![
140 "target".to_string(),
141 "node_modules".to_string(),
142 ".derived".to_string(),
143 "dist".to_string(),
144 "build".to_string(),
145 ".next".to_string(),
146 ],
147 }
148 }
149}
150
151/// `[branding]`: identifiers stamped into emitted `build` metadata.
152#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
153#[serde(default, deny_unknown_fields)]
154pub struct BrandingConfig {
155 pub compiler_id: String,
156 pub indexer_id: String,
157}
158
159impl Default for BrandingConfig {
160 fn default() -> Self {
161 BrandingConfig {
162 compiler_id: "spec-spine".to_string(),
163 indexer_id: "spec-spine".to_string(),
164 }
165 }
166}
167
168/// `[coupling]`: the PR-time gate's exemptions and waiver keyword.
169#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
170#[serde(default, deny_unknown_fields)]
171pub struct CouplingConfig {
172 /// **Additional** paths exempt from the gate, on top of the always-applied
173 /// hardcoded floor (`spec_spine_core::DEFAULT_BYPASS_PREFIXES`). Match rules:
174 /// trailing `/` ⇒ dir prefix; leading `**/` ⇒ tail-suffix anywhere; else
175 /// exact file. This list is **additive**: it adds to the floor and can never
176 /// remove a floor entry. The default is **empty**: the floor is the single
177 /// built-in source, so an adopter declares only their own additions rather
178 /// than restating (and seeming able to override) the floor.
179 pub bypass_prefixes: Vec<String>,
180 /// The PR-body waiver keyword; the free-text reason follows the colon.
181 pub waiver_keyword: String,
182 /// Opt-in mechanical auto-waiver for dependency-only diffs (spec 005
183 /// §3.5). When `true` and no PR-body waiver is present, the CLI compares
184 /// the parsed base/head JSON of every non-bypassed changed path: if all
185 /// are `package.json` manifests whose only differences are version
186 /// strings inside the standard dependency tables (same package keys),
187 /// the gate self-waives: the path dependabot-class PRs cannot take
188 /// (they can edit neither specs nor PR bodies). Anything beyond a
189 /// version string (a new package, a `scripts` edit, spec-binding
190 /// metadata) refuses the auto-waiver, fail-closed. Default `false`.
191 pub auto_waive_dependency_only: bool,
192}
193
194impl Default for CouplingConfig {
195 fn default() -> Self {
196 CouplingConfig {
197 // Empty by design: the floor lives in `couple.rs` and is always
198 // unioned in; duplicating it here was redundant and misleadingly
199 // implied it was overridable.
200 bypass_prefixes: Vec::new(),
201 waiver_keyword: "Spec-Drift-Waiver:".to_string(),
202 auto_waive_dependency_only: false,
203 }
204 }
205}
206
207/// `[provenance]`: the OPEN provenance-scheme registry (kind → URI scheme).
208#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
209#[serde(default, deny_unknown_fields)]
210pub struct ProvenanceConfig {
211 pub uri_schemes: BTreeMap<String, String>,
212}
213
214impl Default for ProvenanceConfig {
215 fn default() -> Self {
216 let mut uri_schemes = BTreeMap::new();
217 uri_schemes.insert("knowledge".to_string(), "knowledge://".to_string());
218 uri_schemes.insert("code-fingerprint".to_string(), "fingerprint://".to_string());
219 ProvenanceConfig { uri_schemes }
220 }
221}
222
223/// `[frontmatter]`: recognized-key extensions.
224#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
225#[serde(default, deny_unknown_fields)]
226pub struct FrontmatterConfig {
227 /// Keys an adopter recognizes (suppresses the lint's unknown-key warning);
228 /// they still overflow into `extra_frontmatter`.
229 pub extra_known_keys: Vec<String>,
230}
231
232/// Load and validate a `spec-spine.toml` from its source text.
233///
234/// Returns [`Error::Config`] (mapped to exit code 3) on any malformed or
235/// unknown-key error; never panics.
236pub fn load_config(toml_src: &str) -> Result<Config> {
237 let config: Config = toml::from_str(toml_src).map_err(|e| Error::Config(e.to_string()))?;
238 validate_slices(&config)?;
239 Ok(config)
240}
241
242/// `[index.slices]` grammar (spec 012 §3.1): names match
243/// `[a-z0-9][a-z0-9-]*`, glob lists are non-empty.
244fn validate_slices(config: &Config) -> Result<()> {
245 for (name, globs) in &config.index.slices {
246 let mut chars = name.chars();
247 let head_ok = chars
248 .next()
249 .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit());
250 let tail_ok = chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
251 if !(head_ok && tail_ok) {
252 return Err(Error::Config(format!(
253 "[index.slices] name '{name}' must match [a-z0-9][a-z0-9-]*"
254 )));
255 }
256 if globs.is_empty() {
257 return Err(Error::Config(format!(
258 "[index.slices] '{name}' must list at least one glob"
259 )));
260 }
261 }
262 Ok(())
263}