cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! Conversion `RawConfig` → `CartularyConfig`.
//!
//! Holds all the domain-side validation: path rooting, preset
//! resolution, status-name validation, cardinality parsing, weight
//! length checks. Raw types (`super::raw`) carry the wire shape; this
//! module promotes them to the typed domain model.
//!
//! Behaviour mismatches between this module and the legacy `parse_*`
//! functions are deliberate — see ISSUE-01937AGR5CWT1 § "Behaviour
//! change accepted":
//! - Shape errors (unknown field, wrong type) abort the load.
//! - Value-level fallbacks are preserved (unknown preset name,
//!   non-canonical category string, mismatched weights length).

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 {
        // Stays here: the dispatcher distinguishes "absent" (legacy
        // config, route through migration) from "explicit 0".
        None => 0, // dispatcher decides whether to refuse — see runner::dispatch
        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);
            // Stays here: the default `docs/<kind>` depends on the
            // dynamic key and cannot be expressed as a per-field serde
            // default.
            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;
    // Stays here: the preset/statuses cascade resolves several fields
    // jointly (preset name → built-in entries → optional initial
    // override) — not expressible as a per-field serde default.
    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);
        // Drop weights if length doesn't match levels — silent,
        // matches the legacy permissive style for value-level
        // mismatches.
        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()
}