govctl 0.9.2

Project governance CLI for RFC, ADR, and Work Item management
use serde::Deserialize;
use serde_json::Value;
use std::collections::HashSet;
use std::error::Error;
use std::fs;
use std::path::Path;

#[derive(Debug, Deserialize)]
pub(super) struct EditOpsSpec {
    pub(super) version: u32,
    pub(super) aliases: Vec<AliasRule>,
    pub(super) legacy_prefixes: Vec<LegacyPrefixRule>,
    pub(super) simple_rules: Vec<SimpleFieldRule>,
    pub(super) runtime_fields: Vec<RuntimeFieldRule>,
    pub(super) nested_rules: Vec<NestedRootRule>,
    pub(super) validation_rules: Vec<FieldValidationRule>,
}

#[derive(Debug, Deserialize)]
pub(super) struct AliasRule {
    pub(super) alias: String,
    pub(super) canonical: String,
}

#[derive(Debug, Deserialize)]
pub(super) struct LegacyPrefixRule {
    pub(super) prefix: String,
    pub(super) allowed_fields: Vec<String>,
}

#[derive(Debug, Deserialize)]
pub(super) struct NestedRootRule {
    pub(super) artifact: String,
    pub(super) root: String,
    pub(super) content_path: Vec<String>,
    pub(super) node: NestedNodeRule,
}

#[derive(Debug, Deserialize)]
pub(super) struct NestedFieldRule {
    pub(super) name: String,
    pub(super) node: NestedNodeRule,
}

#[derive(Debug, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub(super) enum NestedNodeRule {
    Scalar {
        verbs: Vec<String>,
        set_mode: Option<RuntimeSetMode>,
    },
    Object {
        verbs: Vec<String>,
        fields: Vec<NestedFieldRule>,
    },
    List {
        verbs: Vec<String>,
        text_key: Option<String>,
        item: Box<NestedNodeRule>,
    },
}

#[derive(Debug, Deserialize)]
pub(super) struct SimpleFieldRule {
    pub(super) artifact: String,
    pub(super) name: String,
    pub(super) kind: String,
    pub(super) verbs: Vec<String>,
}

#[derive(Debug, Deserialize)]
pub(super) struct RuntimeFieldRule {
    pub(super) artifact: String,
    pub(super) name: String,
    pub(super) get: Option<RuntimeGetRule>,
    pub(super) set: Option<RuntimeSetRule>,
    pub(super) list_path: Option<Vec<String>>,
}

#[derive(Debug, Deserialize)]
pub(super) struct RuntimeGetRule {
    pub(super) path: Vec<String>,
    pub(super) render: String,
    pub(super) status_key: Option<String>,
    pub(super) text_key: Option<String>,
}

#[derive(Debug, Deserialize)]
pub(super) struct RuntimeSetRule {
    pub(super) path: Vec<String>,
    pub(super) mode: RuntimeSetMode,
}

#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(super) enum RuntimeSetMode {
    String,
    Integer,
    Enum {
        allowed: Vec<String>,
        invalid_msg: String,
        code: Option<String>,
    },
}

#[derive(Debug, Deserialize)]
pub(super) struct FieldValidationRule {
    pub(super) artifact: String,
    pub(super) field: String,
    pub(super) rule: String,
}

pub(super) fn load_edit_ops_spec(
    spec_path: &Path,
    schema_path: &Path,
) -> Result<EditOpsSpec, Box<dyn Error>> {
    let spec_text = fs::read_to_string(spec_path)?;
    let schema_text = fs::read_to_string(schema_path)?;
    let spec_value: Value = serde_json::from_str(&spec_text)?;
    let schema_value: Value = serde_json::from_str(&schema_text)?;

    validate_spec_against_schema(&schema_value, &spec_value)?;
    let spec: EditOpsSpec = serde_json::from_value(spec_value)?;
    validate_runtime_fields(&spec)?;
    Ok(spec)
}

fn validate_spec_against_schema(schema: &Value, instance: &Value) -> Result<(), Box<dyn Error>> {
    let compiled = jsonschema::validator_for(schema)
        .map_err(|err| format!("invalid edit-ops schema: {err}"))?;
    let mut diagnostics: Vec<String> = compiled
        .iter_errors(instance)
        .map(|err| err.to_string())
        .collect();
    if !diagnostics.is_empty() {
        diagnostics.sort();
        diagnostics.dedup();
        let body = diagnostics
            .into_iter()
            .map(|d| format!("  - {d}"))
            .collect::<Vec<_>>()
            .join("\n");
        return Err(format!("edit-ops.json failed schema validation:\n{body}").into());
    }
    Ok(())
}

fn validate_runtime_fields(spec: &EditOpsSpec) -> Result<(), Box<dyn Error>> {
    let mut seen = HashSet::new();
    for field in &spec.runtime_fields {
        let key = format!("{}:{}", field.artifact, field.name);
        if !seen.insert(key.clone()) {
            return Err(format!("duplicate runtime_fields entry in SSOT: {key}").into());
        }
        let simple = spec
            .simple_rules
            .iter()
            .find(|rule| rule.artifact == field.artifact && rule.name == field.name)
            .ok_or_else(|| format!("runtime_fields entry missing matching simple_rule: {}", key))?;

        if field.get.is_some() && !simple.verbs.iter().any(|v| v == "get") {
            return Err(format!(
                "runtime_fields {} defines get but simple_rules does not allow get",
                key
            )
            .into());
        }
        if field.set.is_some() && !simple.verbs.iter().any(|v| v == "set") {
            return Err(format!(
                "runtime_fields {} defines set but simple_rules does not allow set",
                key
            )
            .into());
        }
        if field.list_path.is_some() && !simple.verbs.iter().any(|v| v == "add" || v == "remove") {
            return Err(format!(
                "runtime_fields {} defines list_path but simple_rules has no add/remove verb",
                key
            )
            .into());
        }
    }
    Ok(())
}