use chrono::Utc;
use serde::Serialize;
use crate::cli::ObligationKind;
use crate::error::AppError;
use crate::ledger::{is_initialized, marker, obligations, obligations::Obligation, state_dir};
use crate::output::{self, Ctx};
#[derive(Serialize)]
struct AddResult {
id: String,
claim: String,
kind: String,
critical: bool,
proof_cmd: String,
}
fn sh_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
fn synth_literal_match(literal: &str, file: &str) -> String {
format!("grep -F -- {} {}", sh_quote(literal), sh_quote(file))
}
fn synth_literal_regex(pattern: &str, file: &str) -> String {
format!("grep -E -- {} {}", sh_quote(pattern), sh_quote(file))
}
fn normalize_depends_on(raw: Vec<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)
}
#[allow(clippy::too_many_arguments)]
pub fn run(
ctx: Ctx,
claim: String,
proof: Option<String>,
literal: Option<String>,
regex: Option<String>,
file: Option<String>,
kind: ObligationKind,
critical: bool,
depends_on: Vec<String>,
) -> Result<(), AppError> {
let cwd = std::env::current_dir()?;
if !is_initialized(&cwd) {
return Err(AppError::NotInitialized);
}
let dir = state_dir(&cwd);
let claim = claim.trim().to_string();
if claim.is_empty() {
return Err(AppError::InvalidInput("claim cannot be empty".into()));
}
let depends_on = normalize_depends_on(depends_on)?;
let proof_cmd = match (proof, literal.as_deref(), regex.as_deref(), file.as_deref()) {
(Some(p), None, None, None) => {
if matches!(
kind,
ObligationKind::LiteralMatch | ObligationKind::LiteralRegex
) {
return Err(AppError::InvalidInput(format!(
"--kind {kind} requires --literal/--regex and --file, not --proof"
)));
}
let p = p.trim().to_string();
if p.is_empty() {
return Err(AppError::InvalidInput(
"proof command cannot be empty".into(),
));
}
p
}
(None, Some(lit), None, Some(f)) => {
if !matches!(kind, ObligationKind::LiteralMatch) {
return Err(AppError::InvalidInput(
"--literal and --file require --kind literal_match".into(),
));
}
if lit.is_empty() {
return Err(AppError::InvalidInput("--literal cannot be empty".into()));
}
if f.is_empty() {
return Err(AppError::InvalidInput("--file cannot be empty".into()));
}
synth_literal_match(lit, f)
}
(None, None, Some(pat), Some(f)) => {
if !matches!(kind, ObligationKind::LiteralRegex) {
return Err(AppError::InvalidInput(
"--regex and --file require --kind literal_regex".into(),
));
}
if pat.is_empty() {
return Err(AppError::InvalidInput("--regex cannot be empty".into()));
}
if f.is_empty() {
return Err(AppError::InvalidInput("--file cannot be empty".into()));
}
synth_literal_regex(pat, f)
}
_ => unreachable!(
"clap constraints guarantee exactly one of proof, literal+file, or regex+file"
),
};
let id = obligations::next_id(&dir)?;
let ob = Obligation {
id: id.clone(),
claim: claim.clone(),
kind,
critical,
proof_cmd: proof_cmd.clone(),
created_at: Utc::now(),
depends_on: depends_on.clone(),
};
obligations::append(&dir, &ob)?;
if critical && !marker::exists(&dir) {
marker::create(
&dir,
&format!("ritalin: reopened — obligation {id} added after gate\n"),
)?;
}
let result = AddResult {
id,
claim,
kind: kind.to_string(),
critical,
proof_cmd,
};
output::print_success_or(ctx, &result, |r| {
use owo_colors::OwoColorize;
let crit = if r.critical {
"[critical]".red().to_string()
} else {
"[advisory]".dimmed().to_string()
};
println!(
"{} {} {} {}",
"+".green().bold(),
r.id.bold(),
crit,
r.claim
);
println!(" proof: {}", r.proof_cmd.dimmed());
});
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sh_quote_simple() {
assert_eq!(sh_quote("hello"), "'hello'");
}
#[test]
fn sh_quote_with_single_quote() {
assert_eq!(sh_quote("it's"), "'it'\\''s'");
}
#[test]
fn sh_quote_empty() {
assert_eq!(sh_quote(""), "''");
}
#[test]
fn synth_includes_fixed_string_flag() {
let cmd = synth_literal_match("rgba(0,0,0,0.5)", "src/theme.css");
assert!(cmd.starts_with("grep -F -- "));
assert!(cmd.contains("'rgba(0,0,0,0.5)'"));
assert!(cmd.contains("'src/theme.css'"));
}
#[test]
fn synth_quotes_single_quotes_and_spaces() {
let cmd = synth_literal_match("it's a trap", "weird path.txt");
assert_eq!(cmd, r#"grep -F -- 'it'\''s a trap' 'weird path.txt'"#);
}
#[test]
fn synth_handles_literal_starting_with_dash() {
let cmd = synth_literal_match("-webkit-font-smoothing", "src/a.css");
assert!(cmd.contains("-- '-webkit-font-smoothing'"));
}
}