ritalin 0.4.6

Executive function for AI coding agents. Focus their intelligence, ground their work, stop the avoidable mistakes.
use chrono::Utc;
use serde::{Deserialize, Serialize};

use crate::cli::ObligationKind;
use crate::error::AppError;
use crate::ledger::{marker, obligations, obligations::Obligation, scope::Scope};
use crate::output::{self, Ctx};

#[derive(Debug, Deserialize)]
struct Manifest {
    outcome: String,
    #[serde(default)]
    obligations: Vec<ManifestObligation>,
}

#[derive(Debug, Deserialize)]
struct ManifestObligation {
    claim: String,
    proof: String,
    #[serde(default = "default_kind")]
    kind: ObligationKind,
    #[serde(default = "default_critical")]
    critical: bool,
    #[serde(default)]
    depends_on: Vec<String>,
}

fn default_kind() -> ObligationKind {
    ObligationKind::Other
}

fn default_critical() -> bool {
    true
}

#[derive(Serialize)]
struct SeedResult {
    outcome: String,
    obligations_seeded: usize,
    state_dir: String,
}

pub fn run(ctx: Ctx, manifest_path: String, force: bool) -> Result<(), AppError> {
    let content = std::fs::read_to_string(&manifest_path)
        .map_err(|e| AppError::InvalidInput(format!("cannot read {manifest_path}: {e}")))?;

    let manifest: Manifest = if manifest_path.ends_with(".toml") {
        toml::from_str(&content)
            .map_err(|e| AppError::InvalidInput(format!("invalid TOML in {manifest_path}: {e}")))?
    } else if manifest_path.ends_with(".yaml") || manifest_path.ends_with(".yml") {
        serde_yaml::from_str(&content)?
    } else {
        // Try TOML first, fall back to YAML
        toml::from_str(&content)
            .or_else(|_| serde_yaml::from_str(&content).map_err(|e| e.into()))
            .map_err(|e: AppError| {
                AppError::InvalidInput(format!("cannot parse {manifest_path}: {e}"))
            })?
    };

    // Validate the manifest with the same rules `add` enforces, before any
    // state is touched — a manifest must not smuggle in obligations that the
    // CLI flags would reject (empty claims/proofs, absolute or `..` paths).
    let mut validated: Vec<Obligation> = Vec::with_capacity(manifest.obligations.len());
    for (i, mob) in manifest.obligations.iter().enumerate() {
        let claim = mob.claim.trim().to_string();
        if claim.is_empty() {
            return Err(AppError::InvalidInput(format!(
                "obligation {} has an empty claim",
                i + 1
            )));
        }
        let proof_cmd = mob.proof.trim().to_string();
        if proof_cmd.is_empty() {
            return Err(AppError::InvalidInput(format!(
                "obligation {} ({claim}) has an empty proof command",
                i + 1
            )));
        }
        let depends_on = obligations::normalize_depends_on(&mob.depends_on)?;
        validated.push(Obligation {
            id: format!("O-{:03}", i + 1),
            claim,
            kind: mob.kind,
            critical: mob.critical,
            proof_cmd,
            created_at: Utc::now(),
            depends_on,
        });
    }

    let cwd = std::env::current_dir()?;
    // Like init: always target <cwd>/.ritalin, never an ancestor's contract.
    let (dir, _shadows) = crate::commands::init::target_state_dir(&cwd);

    // Serialize the wipe+rewrite against concurrent add/gate/init.
    let _guard = crate::ledger::lock_state(&dir)?;
    let has_contract = dir.join("scope.yaml").exists() || dir.join("obligations.jsonl").exists();

    if has_contract && !force {
        return Err(AppError::InvalidInput(
            "contract already exists — use --force to overwrite".into(),
        ));
    }

    // When forcing, clear old ledgers so seeded IDs start fresh.
    if force && has_contract {
        let _ = std::fs::remove_file(dir.join("obligations.jsonl"));
        let _ = std::fs::remove_file(dir.join("evidence.jsonl"));
    }

    // Write scope
    let scope = Scope::new(manifest.outcome.clone());
    scope.write(&dir)?;

    // Create marker
    let marker_msg = format!(
        "ritalin: outcome = {}\nSeeded from: {}\n",
        manifest.outcome, manifest_path
    );
    marker::create(&dir, &marker_msg)?;

    // Seed obligations
    for ob in &validated {
        obligations::append(&dir, ob)?;
    }

    let result = SeedResult {
        outcome: manifest.outcome,
        obligations_seeded: manifest.obligations.len(),
        state_dir: dir.display().to_string(),
    };

    output::print_success_or(ctx, &result, |r| {
        use owo_colors::OwoColorize;
        println!(
            "{} seeded {} obligations from manifest",
            "+".green().bold(),
            r.obligations_seeded
        );
        println!("  outcome: {}", r.outcome);
        println!("  state:   {}", r.state_dir.dimmed());
    });

    Ok(())
}