ritalin 0.4.6

Executive function for AI coding agents. Focus their intelligence, ground their work, stop the avoidable mistakes.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fs::OpenOptions;
use std::io::Write;
use std::path::Path;

use crate::cli::ObligationKind;
use crate::error::AppError;

/// A single obligation in the contract.
///
/// Stored append-only in `.ritalin/obligations.jsonl`. Each line is one JSON
/// object. The ledger is never edited in place — adding a new obligation
/// always appends a new line.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Obligation {
    pub id: String,
    pub claim: String,
    pub kind: ObligationKind,
    pub critical: bool,
    pub proof_cmd: String,
    pub created_at: DateTime<Utc>,
    /// Files (relative to repo root) whose contents this proof depends on.
    /// When non-empty, evidence freshness is checked against the SHA-256 of
    /// just these files instead of the whole workspace — so unrelated edits
    /// in a parallel session don't invalidate this obligation's evidence.
    /// When empty (the default), the global workspace hash is used, which
    /// preserves v0.3 behavior for existing contracts.
    #[serde(default)]
    pub depends_on: Vec<String>,
}

pub fn ledger_path(state_dir: &Path) -> std::path::PathBuf {
    state_dir.join("obligations.jsonl")
}

/// Append a new obligation to the ledger.
///
/// The record and its trailing newline are written in a single `write_all`
/// on an `O_APPEND` handle, so concurrent appenders cannot interleave bytes
/// within a line — this is what makes the ledger's "line-atomic on POSIX"
/// guarantee actually hold. (`writeln!` would issue two writes: payload,
/// then newline.)
pub fn append(state_dir: &Path, ob: &Obligation) -> Result<(), AppError> {
    std::fs::create_dir_all(state_dir)?;
    let path = ledger_path(state_dir);
    let mut file = OpenOptions::new().create(true).append(true).open(path)?;
    let mut line = serde_json::to_string(ob)?;
    line.push('\n');
    file.write_all(line.as_bytes())?;
    Ok(())
}

/// Allocate the next id and append in one critical section, guarded by the
/// contract lock. Without it, two concurrent `ritalin add` invocations both
/// read the same ledger state and mint the same `O-NNN` — after which
/// `prove <id>` can only ever reach the first duplicate. The lock is
/// advisory but every ritalin writer takes it; it is released when the
/// guard drops (including on crash).
pub fn append_with_new_id(
    state_dir: &Path,
    build: impl FnOnce(String) -> Obligation,
) -> Result<Obligation, AppError> {
    let _guard = crate::ledger::lock_state(state_dir)?;
    let id = next_id(state_dir)?;
    let ob = build(id);
    append(state_dir, &ob)?;
    Ok(ob)
}

/// Read the raw ledger bytes. Missing file = empty string. Callers that
/// need to detect concurrent appends (gate's pass-commit) compare raw
/// contents — the ledger is append-only, so byte equality means "unchanged".
pub fn read_raw(state_dir: &Path) -> Result<String, AppError> {
    let path = ledger_path(state_dir);
    if !path.exists() {
        return Ok(String::new());
    }
    Ok(std::fs::read_to_string(path)?)
}

/// Parse ledger content into obligations, skipping blank lines.
pub fn parse(content: &str) -> Result<Vec<Obligation>, AppError> {
    let mut out = Vec::new();
    for line in content.lines() {
        if line.trim().is_empty() {
            continue;
        }
        let ob: Obligation = serde_json::from_str(line)?;
        out.push(ob);
    }
    Ok(out)
}

/// Read all obligations from the ledger. Empty file = empty vector.
pub fn read_all(state_dir: &Path) -> Result<Vec<Obligation>, AppError> {
    parse(&read_raw(state_dir)?)
}

/// Compute the next obligation id (O-001, O-002, ...).
///
/// Uses max-existing-numeric-id + 1 rather than count + 1, so a ledger that
/// already contains duplicate or hand-edited ids never mints another
/// collision going forward.
pub fn next_id(state_dir: &Path) -> Result<String, AppError> {
    let max = read_all(state_dir)?
        .iter()
        .filter_map(|o| o.id.strip_prefix("O-").and_then(|n| n.parse::<u32>().ok()))
        .max()
        .unwrap_or(0);
    Ok(format!("O-{:03}", max + 1))
}

/// Normalize a list of dependency paths: trim, drop empties, sort, dedupe,
/// reject anything that escapes the repo (absolute paths or `..` segments).
/// Shared by `add` and `seed` so a manifest cannot smuggle in paths that the
/// CLI flags would reject.
pub fn normalize_depends_on(raw: &[String]) -> Result<Vec<String>, AppError> {
    let mut out: Vec<String> = Vec::with_capacity(raw.len());
    for p in raw {
        let trimmed = p.trim();
        if trimmed.is_empty() {
            continue;
        }
        let path = std::path::Path::new(trimmed);
        if path.is_absolute() {
            return Err(AppError::InvalidInput(format!(
                "depends_on path must be repo-relative, got absolute: {trimmed}"
            )));
        }
        if path
            .components()
            .any(|c| matches!(c, std::path::Component::ParentDir))
        {
            return Err(AppError::InvalidInput(format!(
                "depends_on path must not contain `..`: {trimmed}"
            )));
        }
        out.push(trimmed.to_string());
    }
    out.sort();
    out.dedup();
    Ok(out)
}

/// Find an obligation by id.
pub fn find(state_dir: &Path, id: &str) -> Result<Obligation, AppError> {
    read_all(state_dir)?
        .into_iter()
        .find(|o| o.id == id)
        .ok_or_else(|| AppError::UnknownObligation(id.into()))
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    fn make_obligation(id: &str) -> Obligation {
        Obligation {
            id: id.into(),
            claim: "test claim".into(),
            kind: ObligationKind::Other,
            critical: true,
            proof_cmd: "true".into(),
            created_at: chrono::Utc::now(),
            depends_on: Vec::new(),
        }
    }

    #[test]
    fn next_id_empty_ledger() {
        let tmp = TempDir::new().unwrap();
        assert_eq!(next_id(tmp.path()).unwrap(), "O-001");
    }

    #[test]
    fn next_id_increments() {
        let tmp = TempDir::new().unwrap();
        append(tmp.path(), &make_obligation("O-001")).unwrap();
        assert_eq!(next_id(tmp.path()).unwrap(), "O-002");
        append(tmp.path(), &make_obligation("O-002")).unwrap();
        assert_eq!(next_id(tmp.path()).unwrap(), "O-003");
    }

    #[test]
    fn next_id_uses_max_not_count() {
        // A ledger that (from a historical race or hand edit) contains a
        // duplicate id must not mint yet another collision.
        let tmp = TempDir::new().unwrap();
        append(tmp.path(), &make_obligation("O-001")).unwrap();
        append(tmp.path(), &make_obligation("O-001")).unwrap();
        assert_eq!(next_id(tmp.path()).unwrap(), "O-002");
        append(tmp.path(), &make_obligation("O-007")).unwrap();
        assert_eq!(next_id(tmp.path()).unwrap(), "O-008");
    }

    #[test]
    fn append_with_new_id_allocates_sequentially() {
        let tmp = TempDir::new().unwrap();
        let a = append_with_new_id(tmp.path(), |id| {
            let mut ob = make_obligation("");
            ob.id = id;
            ob
        })
        .unwrap();
        let b = append_with_new_id(tmp.path(), |id| {
            let mut ob = make_obligation("");
            ob.id = id;
            ob
        })
        .unwrap();
        assert_eq!(a.id, "O-001");
        assert_eq!(b.id, "O-002");
        assert_eq!(read_all(tmp.path()).unwrap().len(), 2);
    }

    #[test]
    fn read_all_empty_file() {
        let tmp = TempDir::new().unwrap();
        std::fs::write(tmp.path().join("obligations.jsonl"), "").unwrap();
        assert!(read_all(tmp.path()).unwrap().is_empty());
    }

    #[test]
    fn read_all_skips_blank_lines() {
        let tmp = TempDir::new().unwrap();
        let ob = make_obligation("O-001");
        append(tmp.path(), &ob).unwrap();
        let path = ledger_path(tmp.path());
        let mut content = std::fs::read_to_string(&path).unwrap();
        content.push_str("\n\n");
        std::fs::write(&path, content).unwrap();
        assert_eq!(read_all(tmp.path()).unwrap().len(), 1);
    }

    #[test]
    fn read_all_no_file() {
        let tmp = TempDir::new().unwrap();
        assert!(read_all(tmp.path()).unwrap().is_empty());
    }

    #[test]
    fn find_unknown_id_errors() {
        let tmp = TempDir::new().unwrap();
        assert!(find(tmp.path(), "O-999").is_err());
    }

    #[test]
    fn find_existing_id() {
        let tmp = TempDir::new().unwrap();
        let ob = make_obligation("O-001");
        append(tmp.path(), &ob).unwrap();
        let found = find(tmp.path(), "O-001").unwrap();
        assert_eq!(found.id, "O-001");
        assert_eq!(found.claim, "test claim");
    }
}