use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub manifest: ManifestConfig,
pub domains: AllowlistConfig,
pub kind: AllowlistConfig,
pub layout: LayoutConfig,
pub index: IndexConfig,
pub branding: BrandingConfig,
pub coupling: CouplingConfig,
pub provenance: ProvenanceConfig,
pub frontmatter: FrontmatterConfig,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ManifestConfig {
pub metadata_namespace: String,
}
impl Default for ManifestConfig {
fn default() -> Self {
ManifestConfig {
metadata_namespace: "spec-spine".to_string(),
}
}
}
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct AllowlistConfig {
pub allowed: Vec<String>,
}
impl AllowlistConfig {
pub fn is_disabled(&self) -> bool {
self.allowed.is_empty()
}
pub fn permits(&self, value: &str) -> bool {
self.is_disabled() || self.allowed.iter().any(|a| a == value)
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct LayoutConfig {
pub specs_dir: String,
pub derived_dir: String,
pub standards_dir: String,
pub schemas_dir: String,
pub cargo_workspace: String,
pub npm_workspaces: Vec<String>,
pub standalone_rust_workspaces: Vec<String>,
pub standalone_npm_packages: Vec<String>,
}
impl Default for LayoutConfig {
fn default() -> Self {
LayoutConfig {
specs_dir: "specs".to_string(),
derived_dir: ".derived".to_string(),
standards_dir: "standards/spec".to_string(),
schemas_dir: "standards/schemas".to_string(),
cargo_workspace: "Cargo.toml".to_string(),
npm_workspaces: vec![
"package.json".to_string(),
"pnpm-workspace.yaml".to_string(),
],
standalone_rust_workspaces: Vec::new(),
standalone_npm_packages: Vec::new(),
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct IndexConfig {
pub extra_hashed_inputs: Vec<String>,
pub resolver_exclusions: Vec<String>,
pub slices: BTreeMap<String, Vec<String>>,
}
impl Default for IndexConfig {
fn default() -> Self {
IndexConfig {
extra_hashed_inputs: vec![
"standards/**".to_string(),
".github/workflows/**".to_string(),
],
slices: BTreeMap::new(),
resolver_exclusions: vec![
"target".to_string(),
"node_modules".to_string(),
".derived".to_string(),
"dist".to_string(),
"build".to_string(),
".next".to_string(),
],
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct BrandingConfig {
pub compiler_id: String,
pub indexer_id: String,
}
impl Default for BrandingConfig {
fn default() -> Self {
BrandingConfig {
compiler_id: "spec-spine".to_string(),
indexer_id: "spec-spine".to_string(),
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct CouplingConfig {
pub bypass_prefixes: Vec<String>,
pub waiver_keyword: String,
pub auto_waive_dependency_only: bool,
}
impl Default for CouplingConfig {
fn default() -> Self {
CouplingConfig {
bypass_prefixes: Vec::new(),
waiver_keyword: "Spec-Drift-Waiver:".to_string(),
auto_waive_dependency_only: false,
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ProvenanceConfig {
pub uri_schemes: BTreeMap<String, String>,
}
impl Default for ProvenanceConfig {
fn default() -> Self {
let mut uri_schemes = BTreeMap::new();
uri_schemes.insert("knowledge".to_string(), "knowledge://".to_string());
uri_schemes.insert("code-fingerprint".to_string(), "fingerprint://".to_string());
ProvenanceConfig { uri_schemes }
}
}
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct FrontmatterConfig {
pub extra_known_keys: Vec<String>,
}
pub fn load_config(toml_src: &str) -> Result<Config> {
let config: Config = toml::from_str(toml_src).map_err(|e| Error::Config(e.to_string()))?;
validate_slices(&config)?;
Ok(config)
}
fn validate_slices(config: &Config) -> Result<()> {
for (name, globs) in &config.index.slices {
let mut chars = name.chars();
let head_ok = chars
.next()
.is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit());
let tail_ok = chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
if !(head_ok && tail_ok) {
return Err(Error::Config(format!(
"[index.slices] name '{name}' must match [a-z0-9][a-z0-9-]*"
)));
}
if globs.is_empty() {
return Err(Error::Config(format!(
"[index.slices] '{name}' must list at least one glob"
)));
}
}
Ok(())
}