rtango 0.1.0

Package manager for AI agent skills, agents, and system instruction files
Documentation
use std::collections::HashSet;
use std::fs;
use std::path::Path;

use anyhow::Context;

use crate::error::RtangoError;

use super::{Lock, Spec};

const RTANGO_DIR: &str = ".rtango";
const SPEC_FILE: &str = "spec.yaml";
const LOCK_FILE: &str = "lock.yaml";

pub fn rtango_dir(root: &Path) -> std::path::PathBuf {
    root.join(RTANGO_DIR)
}

pub fn spec_path(root: &Path) -> std::path::PathBuf {
    rtango_dir(root).join(SPEC_FILE)
}

pub fn lock_path(root: &Path) -> std::path::PathBuf {
    rtango_dir(root).join(LOCK_FILE)
}

pub fn load_spec(root: &Path) -> anyhow::Result<Spec> {
    let path = spec_path(root);
    let content =
        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
    let spec: Spec = serde_yml::from_str(&content)
        .with_context(|| format!("failed to parse {}", path.display()))?;
    validate_spec(&spec)?;
    Ok(spec)
}

pub fn validate_spec(spec: &Spec) -> anyhow::Result<()> {
    if spec.version != 1 {
        anyhow::bail!(RtangoError::InvalidSpec(format!(
            "unsupported version {}, expected 1",
            spec.version
        )));
    }
    if spec.agents.is_empty() {
        anyhow::bail!(RtangoError::InvalidSpec(
            "agents list must not be empty".into()
        ));
    }
    let mut seen = HashSet::new();
    for rule in &spec.rules {
        if !seen.insert(&rule.id) {
            anyhow::bail!(RtangoError::InvalidSpec(format!(
                "duplicate rule id '{}'",
                rule.id
            )));
        }
    }
    Ok(())
}

pub fn save_spec(root: &Path, spec: &Spec) -> anyhow::Result<()> {
    let path = spec_path(root);
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)
            .with_context(|| format!("failed to create {}", parent.display()))?;
    }
    let yaml = serde_yml::to_string(spec)?;
    fs::write(&path, yaml).with_context(|| format!("failed to write {}", path.display()))?;
    Ok(())
}

pub fn load_lock(root: &Path) -> anyhow::Result<Lock> {
    let path = lock_path(root);
    let content =
        fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
    let lock: Lock = serde_yml::from_str(&content)
        .with_context(|| format!("failed to parse {}", path.display()))?;
    Ok(lock)
}

pub fn load_lock_or_empty(root: &Path) -> anyhow::Result<Lock> {
    let path = lock_path(root);
    if !path.exists() {
        return Ok(Lock {
            version: 1,
            tracked_agents: vec![],
            owners: vec![],
            deployments: vec![],
        });
    }
    load_lock(root)
}

pub fn save_lock(root: &Path, lock: &Lock) -> anyhow::Result<()> {
    let path = lock_path(root);
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)
            .with_context(|| format!("failed to create {}", parent.display()))?;
    }
    let yaml = serde_yml::to_string(lock)?;
    fs::write(&path, yaml).with_context(|| format!("failed to write {}", path.display()))?;
    Ok(())
}