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;
#[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>,
#[serde(default)]
pub depends_on: Vec<String>,
}
pub fn ledger_path(state_dir: &Path) -> std::path::PathBuf {
state_dir.join("obligations.jsonl")
}
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(())
}
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)
}
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)?)
}
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)
}
pub fn read_all(state_dir: &Path) -> Result<Vec<Obligation>, AppError> {
parse(&read_raw(state_dir)?)
}
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))
}
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)
}
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() {
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");
}
}