mempal 0.5.0

Project memory for coding agents. Single binary, hybrid search, knowledge graph.
Documentation
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, bail};
use serde::Serialize;

use crate::core::{
    anchor::{self, DerivedAnchor},
    db::Database,
    types::{AnchorKind, Drawer, KnowledgeStatus, MemoryDomain, MemoryKind},
    utils::current_timestamp,
};

#[derive(Debug, Clone)]
pub struct PublishAnchorRequest {
    pub drawer_id: String,
    pub to: String,
    pub target_anchor_id: Option<String>,
    pub cwd: Option<PathBuf>,
    pub reason: String,
    pub reviewer: Option<String>,
}

#[derive(Debug, Clone, Serialize)]
pub struct PublishAnchorOutcome {
    pub drawer_id: String,
    pub old_anchor_kind: String,
    pub old_anchor_id: String,
    pub old_parent_anchor_id: Option<String>,
    pub new_anchor_kind: String,
    pub new_anchor_id: String,
    pub new_parent_anchor_id: Option<String>,
}

pub fn publish_anchor(
    db: &Database,
    request: PublishAnchorRequest,
) -> Result<PublishAnchorOutcome> {
    let drawer = load_publishable_knowledge(db, &request.drawer_id)?;
    let target = resolve_target_anchor(&drawer, &request)?;
    if drawer.anchor_kind == target.anchor_kind && drawer.anchor_id == target.anchor_id {
        bail!("same-anchor publication is not allowed");
    }
    validate_publication_chain(&drawer, &target)?;
    anchor::validate_anchor_domain(&drawer.domain, &target.anchor_kind)
        .map_err(|message| anyhow::anyhow!(message))?;
    anchor::validate_explicit_anchor(&target.anchor_kind, &target.anchor_id)?;

    db.update_knowledge_anchor(
        &request.drawer_id,
        &target.anchor_kind,
        &target.anchor_id,
        target.parent_anchor_id.as_deref(),
    )
    .context("failed to update knowledge anchor")?;
    append_audit_entry(
        db,
        "knowledge_publish_anchor",
        &serde_json::json!({
            "drawer_id": request.drawer_id,
            "old_anchor_kind": anchor_kind_slug(&drawer.anchor_kind),
            "old_anchor_id": drawer.anchor_id,
            "old_parent_anchor_id": drawer.parent_anchor_id,
            "new_anchor_kind": anchor_kind_slug(&target.anchor_kind),
            "new_anchor_id": target.anchor_id,
            "new_parent_anchor_id": target.parent_anchor_id,
            "reason": request.reason,
            "reviewer": request.reviewer,
        }),
    )
    .context("failed to append audit log")?;

    Ok(PublishAnchorOutcome {
        drawer_id: drawer.id,
        old_anchor_kind: anchor_kind_slug(&drawer.anchor_kind).to_string(),
        old_anchor_id: drawer.anchor_id,
        old_parent_anchor_id: drawer.parent_anchor_id,
        new_anchor_kind: anchor_kind_slug(&target.anchor_kind).to_string(),
        new_anchor_id: target.anchor_id,
        new_parent_anchor_id: target.parent_anchor_id,
    })
}

fn load_publishable_knowledge(db: &Database, drawer_id: &str) -> Result<Drawer> {
    let drawer = db
        .get_drawer(drawer_id)
        .context("failed to look up drawer")?
        .with_context(|| format!("drawer not found: {drawer_id}"))?;
    if drawer.memory_kind != MemoryKind::Knowledge {
        bail!("knowledge anchor publication requires a knowledge drawer");
    }
    match drawer.status {
        Some(KnowledgeStatus::Promoted | KnowledgeStatus::Canonical) => Ok(drawer),
        _ => bail!("publish-anchor requires promoted or canonical knowledge"),
    }
}

fn resolve_target_anchor(drawer: &Drawer, request: &PublishAnchorRequest) -> Result<DerivedAnchor> {
    match request.to.trim() {
        "repo" => resolve_repo_target(drawer, request),
        "global" => resolve_global_target(drawer, request),
        other => bail!("unsupported publish target: {other}"),
    }
}

fn resolve_repo_target(drawer: &Drawer, request: &PublishAnchorRequest) -> Result<DerivedAnchor> {
    if let Some(anchor_id) = request.target_anchor_id.as_deref() {
        anchor::validate_explicit_anchor(&AnchorKind::Repo, anchor_id)?;
        return Ok(DerivedAnchor {
            anchor_kind: AnchorKind::Repo,
            anchor_id: anchor_id.to_string(),
            parent_anchor_id: None,
        });
    }
    if let Some(parent_anchor_id) = drawer.parent_anchor_id.as_deref() {
        anchor::validate_explicit_anchor(&AnchorKind::Repo, parent_anchor_id)?;
        return Ok(DerivedAnchor {
            anchor_kind: AnchorKind::Repo,
            anchor_id: parent_anchor_id.to_string(),
            parent_anchor_id: None,
        });
    }
    if let Some(cwd) = request.cwd.as_deref() {
        let derived = anchor::derive_anchor_from_cwd(Some(Path::new(cwd)))?;
        if let Some(parent_anchor_id) = derived.parent_anchor_id {
            return Ok(DerivedAnchor {
                anchor_kind: AnchorKind::Repo,
                anchor_id: parent_anchor_id,
                parent_anchor_id: None,
            });
        }
    }
    bail!("repo publication requires --target-anchor-id, parent_anchor_id, or --cwd with a repo")
}

fn resolve_global_target(drawer: &Drawer, request: &PublishAnchorRequest) -> Result<DerivedAnchor> {
    if drawer.domain != MemoryDomain::Global {
        bail!("global anchor requires domain=global");
    }
    let anchor_id = request
        .target_anchor_id
        .as_deref()
        .context("--target-anchor-id is required for global publication")?;
    anchor::validate_explicit_anchor(&AnchorKind::Global, anchor_id)?;
    Ok(DerivedAnchor {
        anchor_kind: AnchorKind::Global,
        anchor_id: anchor_id.to_string(),
        parent_anchor_id: None,
    })
}

fn validate_publication_chain(drawer: &Drawer, target: &DerivedAnchor) -> Result<()> {
    match (&drawer.anchor_kind, &target.anchor_kind) {
        (AnchorKind::Worktree, AnchorKind::Repo) => Ok(()),
        (AnchorKind::Repo, AnchorKind::Global) => Ok(()),
        (AnchorKind::Worktree, AnchorKind::Global) => {
            bail!("worktree -> global publication is not allowed")
        }
        (AnchorKind::Global, _) => bail!("inward publication from global is not allowed"),
        (AnchorKind::Repo, AnchorKind::Worktree) | (AnchorKind::Worktree, AnchorKind::Worktree) => {
            bail!("inward publication is not allowed")
        }
        (AnchorKind::Repo, AnchorKind::Repo) => bail!("same-anchor publication is not allowed"),
    }
}

fn append_audit_entry(db: &Database, command: &str, details: &serde_json::Value) -> Result<()> {
    let audit_path = db
        .path()
        .parent()
        .map(|parent| parent.join("audit.jsonl"))
        .unwrap_or_else(|| PathBuf::from("audit.jsonl"));
    let mut file = OpenOptions::new()
        .create(true)
        .append(true)
        .open(&audit_path)
        .with_context(|| format!("failed to open audit log {}", audit_path.display()))?;
    let entry = serde_json::json!({
        "timestamp": current_timestamp(),
        "command": command,
        "details": details,
    });
    writeln!(file, "{entry}")
        .with_context(|| format!("failed to write audit log {}", audit_path.display()))?;
    Ok(())
}

fn anchor_kind_slug(value: &AnchorKind) -> &'static str {
    match value {
        AnchorKind::Global => "global",
        AnchorKind::Repo => "repo",
        AnchorKind::Worktree => "worktree",
    }
}