use anyhow::{Result, bail};
use clap::{Args, Subcommand};
use agentstatedeveloper_core::{
AsgIndexStore, AsgLedgerStore, AsgScratchStore, Author, AuthorKind, CleanFilter, Engine,
IndexStore, LedgerEntry, LedgerKind, LedgerStore, ScratchEntry, ScratchFilter, ScratchStatus,
ScratchStore,
};
use crate::commands::ledger::open_engine_public;
use crate::config::Config;
#[derive(Debug, Subcommand)]
pub enum ScratchCmd {
Write(WriteArgs),
List(ListArgs),
Read(ReadArgs),
Update(UpdateArgs),
Discard(DiscardArgs),
Promote(PromoteArgs),
Clean(CleanArgs),
}
#[derive(Debug, Args)]
pub struct WriteArgs {
pub content: String,
#[arg(long)]
pub symbol: Option<String>,
#[arg(long)]
pub workflow: Option<String>,
#[arg(long = "tag", value_name = "TAG")]
pub tags: Vec<String>,
#[arg(long)]
pub ttl: Option<String>,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct ListArgs {
#[arg(long)]
pub symbol: Option<String>,
#[arg(long)]
pub workflow: Option<String>,
#[arg(long)]
pub session: Option<String>,
#[arg(long)]
pub status: Option<String>,
#[arg(long)]
pub all: bool,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct ReadArgs {
pub scratch_id: String,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct UpdateArgs {
pub scratch_id: String,
pub content: String,
}
#[derive(Debug, Args)]
pub struct DiscardArgs {
pub scratch_id: String,
}
#[derive(Debug, Args)]
pub struct PromoteArgs {
pub scratch_id: String,
#[arg(long, short = 'k')]
pub kind: String,
#[arg(long, short = 's')]
pub summary: Option<String>,
#[arg(long)]
pub symbol: Option<String>,
#[arg(long)]
pub author: Option<String>,
}
#[derive(Debug, Args)]
pub struct CleanArgs {
#[arg(long)]
pub older_than: String,
#[arg(long, default_value = "discarded,promoted")]
pub status: String,
#[arg(long)]
pub dry_run: bool,
}
pub fn run(cfg: &Config, cmd: ScratchCmd) -> Result<()> {
match cmd {
ScratchCmd::Write(args) => write_cmd(cfg, args),
ScratchCmd::List(args) => list_cmd(cfg, args),
ScratchCmd::Read(args) => read_cmd(cfg, args),
ScratchCmd::Update(args) => update_cmd(cfg, args),
ScratchCmd::Discard(args) => discard_cmd(cfg, args),
ScratchCmd::Promote(args) => promote_cmd(cfg, args),
ScratchCmd::Clean(args) => clean_cmd(cfg, args),
}
}
fn parse_ttl(s: &str) -> Result<chrono::Duration> {
let s = s.trim();
if let Some(days) = s.strip_suffix('d') {
let n: i64 = days
.parse()
.map_err(|_| anyhow::anyhow!("invalid TTL: {s}"))?;
return Ok(chrono::Duration::days(n));
}
if let Some(hours) = s.strip_suffix('h') {
let n: i64 = hours
.parse()
.map_err(|_| anyhow::anyhow!("invalid TTL: {s}"))?;
return Ok(chrono::Duration::hours(n));
}
bail!("TTL must be in the form `24h` or `7d`")
}
fn parse_statuses(s: &str) -> Vec<ScratchStatus> {
s.split(',')
.filter_map(|t| match t.trim() {
"draft" => Some(ScratchStatus::Draft),
"promoted" => Some(ScratchStatus::Promoted),
"discarded" => Some(ScratchStatus::Discarded),
_ => None,
})
.collect()
}
fn parse_kind(s: &str) -> Result<LedgerKind> {
match s {
"decision" => Ok(LedgerKind::Decision),
"assumption" => Ok(LedgerKind::Assumption),
"constraint" => Ok(LedgerKind::Constraint),
"rationale" => Ok(LedgerKind::Rationale),
"hazard" => Ok(LedgerKind::Hazard),
"tradeoff" => Ok(LedgerKind::Tradeoff),
"invariant" => Ok(LedgerKind::Invariant),
"ownership" => Ok(LedgerKind::Ownership),
"proof" => Ok(LedgerKind::Proof),
other => bail!(
"unknown ledger kind: '{}'. Valid: decision, assumption, constraint, rationale, hazard, tradeoff, invariant, ownership, proof",
other
),
}
}
fn first_line(content: &str) -> String {
content
.lines()
.find(|l| !l.trim().is_empty())
.unwrap_or(content)
.chars()
.take(140)
.collect()
}
fn write_cmd(cfg: &Config, args: WriteArgs) -> Result<()> {
let engine = Engine::open_sqlite(&cfg.db_path)?;
let store = AsgScratchStore { repo: &engine.repo };
let mut entry = ScratchEntry::new(&args.content, &cfg.agent_id);
entry.tags = args.tags;
if let Some(ref wf) = args.workflow {
entry.workflow = Some(wf.clone());
}
if let Some(ref ttl) = args.ttl {
let dur = parse_ttl(ttl)?;
entry.expires_at = Some(chrono::Utc::now() + dur);
}
if let Some(ref qname) = args.symbol {
let index = AsgIndexStore::from_engine(&engine);
let sym = index
.get_symbol_by_qname(&engine.ref_name, qname)?
.ok_or_else(|| anyhow::anyhow!("symbol not found: {qname}"))?;
entry.symbol_id = Some(sym.symbol_id);
}
let stored = store.write_entry(&engine.ref_name, &entry, &cfg.agent_id)?;
if args.json {
println!("{}", serde_json::to_string_pretty(&stored)?);
} else {
println!("scratch_id: {}", stored.scratch_id);
println!("status: draft");
if let Some(ref wf) = stored.workflow {
println!("workflow: {wf}");
}
if let Some(ref sym) = stored.symbol_id {
println!("symbol_id: {sym}");
}
}
Ok(())
}
fn list_cmd(cfg: &Config, args: ListArgs) -> Result<()> {
let engine = Engine::open_sqlite(&cfg.db_path)?;
let store = AsgScratchStore { repo: &engine.repo };
let filter = if args.all {
ScratchFilter::all()
} else {
let status = args
.status
.as_deref()
.and_then(|s| match s {
"draft" => Some(ScratchStatus::Draft),
"promoted" => Some(ScratchStatus::Promoted),
"discarded" => Some(ScratchStatus::Discarded),
_ => None,
})
.or(Some(ScratchStatus::Draft));
ScratchFilter {
symbol_id: None, workflow: args.workflow.clone(),
session: args.session.clone(),
status,
exclude_expired: true,
}
};
let sym_id_filter: Option<String> = if let Some(ref qname) = args.symbol {
let index = AsgIndexStore::from_engine(&engine);
let sym = index
.get_symbol_by_qname(&engine.ref_name, qname)?
.ok_or_else(|| anyhow::anyhow!("symbol not found: {qname}"))?;
Some(sym.symbol_id)
} else {
None
};
let mut filter = filter;
filter.symbol_id = sym_id_filter;
let entries = store.list_entries(&engine.ref_name, &filter)?;
if args.json {
println!("{}", serde_json::to_string_pretty(&entries)?);
return Ok(());
}
if entries.is_empty() {
println!("No scratch entries found.");
return Ok(());
}
println!(
"{:<36} {:<10} {:<22} {}",
"scratch_id", "status", "workflow", "content (first 60 chars)"
);
println!("{}", "-".repeat(120));
for e in &entries {
let snippet: String = e.content.chars().take(60).collect();
let snippet = snippet.replace('\n', " ");
println!(
"{:<36} {:<10} {:<22} {}",
e.scratch_id,
format!("{:?}", e.status).to_lowercase(),
e.workflow.as_deref().unwrap_or("—"),
snippet,
);
}
println!("\n{} entry/entries.", entries.len());
Ok(())
}
fn read_cmd(cfg: &Config, args: ReadArgs) -> Result<()> {
let engine = Engine::open_sqlite(&cfg.db_path)?;
let store = AsgScratchStore { repo: &engine.repo };
let entry = store.read_entry(&engine.ref_name, &args.scratch_id)?;
if args.json {
println!("{}", serde_json::to_string_pretty(&entry)?);
return Ok(());
}
println!("scratch_id: {}", entry.scratch_id);
println!("status: {:?}", entry.status);
println!("session: {}", entry.session);
if let Some(ref wf) = entry.workflow {
println!("workflow: {wf}");
}
if let Some(ref sid) = entry.symbol_id {
println!("symbol_id: {sid}");
}
if let Some(ref pt) = entry.promoted_to {
println!("promoted_to: {pt}");
}
if !entry.tags.is_empty() {
println!("tags: {}", entry.tags.join(", "));
}
println!("created_at: {}", entry.created_at);
println!("updated_at: {}", entry.updated_at);
if let Some(exp) = entry.expires_at {
println!("expires_at: {}", exp);
}
println!("\n{}", entry.content);
Ok(())
}
fn update_cmd(cfg: &Config, args: UpdateArgs) -> Result<()> {
let engine = Engine::open_sqlite(&cfg.db_path)?;
let store = AsgScratchStore { repo: &engine.repo };
let updated = store.update_entry(
&engine.ref_name,
&args.scratch_id,
&args.content,
&cfg.agent_id,
)?;
println!("Updated {}", updated.scratch_id);
Ok(())
}
fn discard_cmd(cfg: &Config, args: DiscardArgs) -> Result<()> {
let engine = Engine::open_sqlite(&cfg.db_path)?;
let store = AsgScratchStore { repo: &engine.repo };
store.discard_entry(&engine.ref_name, &args.scratch_id, &cfg.agent_id)?;
println!("Discarded {}", args.scratch_id);
Ok(())
}
fn promote_cmd(cfg: &Config, args: PromoteArgs) -> Result<()> {
let engine = open_engine_public(cfg)?;
let scratch_store = AsgScratchStore { repo: &engine.repo };
let ledger_store = AsgLedgerStore::from_engine(&engine);
let entry = scratch_store.read_entry(&engine.ref_name, &args.scratch_id)?;
let symbol_id = if let Some(ref qname) = args.symbol {
let index = AsgIndexStore::from_engine(&engine);
let sym = index
.get_symbol_by_qname(&engine.ref_name, qname)?
.ok_or_else(|| anyhow::anyhow!("symbol not found: {qname}"))?;
sym.symbol_id
} else if let Some(ref sid) = entry.symbol_id {
sid.clone()
} else {
bail!(
"no symbol attached to scratch entry and --symbol was not provided. \
Use --symbol <qname> to attach the ledger entry to a symbol."
);
};
let summary = args.summary.unwrap_or_else(|| first_line(&entry.content));
let kind = parse_kind(&args.kind)?;
let author_id = args.author.unwrap_or_else(|| cfg.agent_id.clone());
let author = Author {
kind: AuthorKind::Agent,
id: author_id.clone(),
};
let mut ledger_entry = LedgerEntry::new(&symbol_id, kind, &summary, author);
ledger_entry.body = Some(entry.content.clone());
ledger_store.append_entry(&engine.ref_name, &ledger_entry, &author_id)?;
let promoted = scratch_store.mark_promoted(
&engine.ref_name,
&entry.scratch_id,
&ledger_entry.entry_id,
&author_id,
)?;
println!("scratch_id: {}", promoted.scratch_id);
println!("promoted_to: {}", ledger_entry.entry_id);
println!("symbol_id: {symbol_id}");
Ok(())
}
fn clean_cmd(cfg: &Config, args: CleanArgs) -> Result<()> {
let engine = Engine::open_sqlite(&cfg.db_path)?;
let store = AsgScratchStore { repo: &engine.repo };
let dur = parse_ttl(&args.older_than)?;
let statuses = parse_statuses(&args.status);
let filter = CleanFilter {
older_than: Some(dur),
statuses,
};
let count = store.clean_entries(&engine.ref_name, &filter, args.dry_run)?;
if args.dry_run {
println!(
"dry-run: {} entry/entries would be deleted. Run without --dry-run to apply.",
count
);
} else {
println!("Deleted {} entry/entries.", count);
}
Ok(())
}