use std::path::{Path, PathBuf};
use crate::domain::model::status::{StatusCategory, StatusConfig, StatusName, StatusesConfig};
use crate::domain::model::tag_descriptor::{
AggregateOp, Cardinality, TagDescriptor, TagDescriptors,
};
use super::cartulary_config::NavEntry;
use super::cartulary_config::CURRENT_SCHEMA_VERSION;
use super::raw::{
RawConfig, RawDecisionKind, RawDecisions, RawDocsEntry, RawIssues, RawQuery, RawSite,
RawSource, RawStatusConfig, RawTagDescriptor,
};
use super::{CartularyConfig, DocsConfig, DocsKind, KindConfig, SourceConfig};
pub(super) fn from_raw(raw: RawConfig, root: &Path) -> Result<CartularyConfig, String> {
let schema_version = resolve_schema_version(raw.version);
let decision_kinds = build_decision_kinds(raw.decisions, root);
let (issues_dir, issues_union, issues_id_prefix, issues_statuses) =
build_issues_section(raw.issues, root)?;
let tag_descriptors = build_tag_descriptors(raw.tags)?;
let sources = build_sources(raw.sources);
let docs = build_docs_entries(raw.docs, root)?;
let (site_title, site_nav, site_theme, site_out) = build_site_section(raw.site, root);
let query_dir = build_query_dir(raw.query, root);
Ok(CartularyConfig {
schema_version,
decision_kinds,
issues_dir,
issues_union,
issues_id_prefix,
issues_statuses,
tag_descriptors,
sources,
docs,
site_title,
site_nav,
site_theme,
site_out,
query_dir,
})
}
fn build_docs_entries(
raw: std::collections::BTreeMap<String, RawDocsEntry>,
root: &Path,
) -> Result<Vec<DocsConfig>, String> {
raw.into_iter()
.map(|(name, entry)| {
let kind = DocsKind::parse(&entry.kind).map_err(|e| format!("[docs.{name}]: {e}"))?;
Ok(DocsConfig {
name,
kind,
source: root.join(entry.source),
publish: entry.publish,
})
})
.collect()
}
fn build_query_dir(query: Option<RawQuery>, root: &Path) -> PathBuf {
let configured = query.and_then(|q| q.dir);
match configured {
Some(dir) => root.join(dir),
None => root.join("docs/queries"),
}
}
fn build_site_section(
site: Option<RawSite>,
root: &Path,
) -> (
Option<String>,
Vec<NavEntry>,
Option<PathBuf>,
Option<PathBuf>,
) {
let Some(site) = site else {
return (None, Vec::new(), None, None);
};
let nav = site
.nav
.into_iter()
.map(|e| NavEntry {
label: e.label,
url: e.url,
})
.collect();
let theme = site.theme.map(|t| root.join(t));
let out = site.out.map(|o| root.join(o));
(site.title, nav, theme, out)
}
fn resolve_schema_version(version: Option<u32>) -> u32 {
match version {
None => 0, Some(v) if v > CURRENT_SCHEMA_VERSION => {
eprintln!(
"error: cartulary.toml declares schema version {v} but this binary only \
supports up to version {CURRENT_SCHEMA_VERSION}. Please upgrade cartulary.",
);
std::process::exit(1);
}
Some(v) => v,
}
}
fn build_decision_kinds(decisions: Option<RawDecisions>, root: &Path) -> Vec<KindConfig> {
let Some(decisions) = decisions else {
return Vec::new();
};
decisions
.types
.into_iter()
.map(|kind| {
let raw_kind = decisions.kinds.get(&kind);
warn_on_obsolete_workflow_keys(raw_kind, &kind);
let dir = raw_kind
.and_then(|k| k.dir.as_deref())
.map(|d| root.join(d))
.unwrap_or_else(|| root.join(format!("docs/{kind}")));
let id_prefix = raw_kind.and_then(|k| k.id_prefix.clone());
let union = raw_kind
.map(|k| k.union.iter().map(|d| root.join(d)).collect())
.unwrap_or_default();
KindConfig {
kind,
dir,
union,
id_prefix,
}
})
.collect()
}
fn warn_on_obsolete_workflow_keys(raw_kind: Option<&RawDecisionKind>, kind: &str) {
let Some(raw_kind) = raw_kind else {
return;
};
if raw_kind.statuses.is_some() {
eprintln!(
"warning: [decisions.{kind}.statuses] ignored — decision-record workflow is \
hardcoded (DDR-018QWJVHRH35B)"
);
}
if raw_kind.preset.is_some() {
eprintln!(
"warning: [decisions.{kind}].preset ignored — decision-record workflow is \
hardcoded (DDR-018QWJVHRH35B)"
);
}
}
fn build_issues_section(
issues: Option<RawIssues>,
root: &Path,
) -> Result<(PathBuf, Vec<PathBuf>, Option<String>, StatusesConfig), String> {
let issues = issues.unwrap_or_default();
let dir = root.join(&issues.dir);
let union = issues.union.iter().map(|d| root.join(d)).collect();
let id_prefix = issues.id_prefix;
let statuses = build_statuses(
issues.statuses,
issues.preset.as_deref(),
issues.initial.as_deref(),
StatusesConfig::default_issue(),
)?;
Ok((dir, union, id_prefix, statuses))
}
fn build_statuses(
raw: std::collections::BTreeMap<String, RawStatusConfig>,
preset: Option<&str>,
initial_override: Option<&str>,
default: StatusesConfig,
) -> Result<StatusesConfig, String> {
if !raw.is_empty() {
let mut entries: Vec<(String, StatusConfig)> = Vec::new();
for (name, cfg) in raw {
if StatusName::new(&name).is_err() {
continue;
}
let next = cfg
.next
.into_iter()
.filter(|n| StatusName::new(n).is_ok())
.collect();
let category = StatusCategory::parse(&cfg.category)
.map_err(|e| format!("status {name:?}: {e}"))?;
entries.push((
name,
StatusConfig {
next,
active: cfg.active,
terminal: cfg.terminal,
label: cfg.label,
category,
},
));
}
if entries.is_empty() {
return Ok(default);
}
let initial = initial_override
.map(String::from)
.or_else(|| entries.first().map(|(n, _)| n.clone()))
.unwrap_or_else(|| default.initial().to_string());
return Ok(StatusesConfig::new(entries, initial));
}
let Some(preset_name) = preset else {
return Ok(default);
};
match StatusesConfig::from_preset(preset_name) {
Some(mut cfg) => {
if let Some(initial) = initial_override {
cfg = StatusesConfig::new(cfg.into_entries(), initial.to_string());
}
Ok(cfg)
}
None => {
eprintln!("warning: unknown status preset '{preset_name}', using default");
Ok(default)
}
}
}
fn build_tag_descriptors(
raw: std::collections::BTreeMap<String, RawTagDescriptor>,
) -> Result<TagDescriptors, String> {
let mut descriptors = Vec::with_capacity(raw.len());
for (key, raw) in raw {
let cardinality = parse_cardinality(&raw.cardinality);
let weights = match raw.weights {
Some(w) if !raw.levels.is_empty() && w.len() == raw.levels.len() => Some(w),
_ => None,
};
let aggregate = match raw.aggregate.as_deref() {
None => None,
Some(s) => Some(AggregateOp::parse(s).ok_or_else(|| {
format!(
"tag descriptor '[tags.{key}]': unknown aggregate {s:?} \
(expected one of: or, and, max, min)"
)
})?),
};
if let Some(op) = aggregate {
if op.requires_ordered() && !raw.ordered {
return Err(format!(
"tag descriptor '[tags.{key}]': aggregate = {:?} \
requires ordered = true",
op.as_str()
));
}
}
let descriptor = TagDescriptor {
key: key.clone(),
levels: raw.levels,
cardinality,
ordered: raw.ordered,
weights,
aggregate,
applies_to: raw.applies_to,
};
if descriptor.is_no_op() {
eprintln!(
"warning: tag descriptor '[tags.{key}]' has no constraints \
(no levels, cardinality = \"any\") — equivalent to having no descriptor"
);
}
if descriptor.applies_to.is_empty() {
eprintln!(
"warning: tag descriptor '[tags.{key}]' has empty applies_to \
— it will not validate any record kind"
);
}
descriptors.push(descriptor);
}
Ok(TagDescriptors::new(descriptors))
}
fn parse_cardinality(s: &str) -> Cardinality {
match s {
"exactly-one" => Cardinality::ExactlyOne,
"at-most-one" => Cardinality::AtMostOne,
"at-least-one" => Cardinality::AtLeastOne,
_ => Cardinality::Any,
}
}
fn build_sources(raw: std::collections::BTreeMap<String, RawSource>) -> Vec<SourceConfig> {
raw.into_iter()
.map(|(name, raw)| SourceConfig {
name,
source_type: raw.source_type,
url: raw.url,
project: raw.project,
token_env: raw.token_env,
status_map: raw.status_map,
})
.collect()
}