1use anyhow::{Result, bail};
20use clap::{Args, Subcommand};
21
22use agentstatedeveloper_core::{
23 AsgIndexStore, AsgLedgerStore, AsgScratchStore, Author, AuthorKind, CleanFilter, Engine,
24 IndexStore, LedgerEntry, LedgerKind, LedgerStore, ScratchEntry, ScratchFilter, ScratchStatus,
25 ScratchStore,
26};
27
28use crate::commands::ledger::open_engine_public;
29use crate::config::Config;
30
31#[derive(Debug, Subcommand)]
36pub enum ScratchCmd {
37 Write(WriteArgs),
39
40 List(ListArgs),
42
43 Read(ReadArgs),
45
46 Update(UpdateArgs),
48
49 Discard(DiscardArgs),
51
52 Promote(PromoteArgs),
54
55 Clean(CleanArgs),
57}
58
59#[derive(Debug, Args)]
60pub struct WriteArgs {
61 pub content: String,
63
64 #[arg(long)]
66 pub symbol: Option<String>,
67
68 #[arg(long)]
70 pub workflow: Option<String>,
71
72 #[arg(long = "tag", value_name = "TAG")]
74 pub tags: Vec<String>,
75
76 #[arg(long)]
78 pub ttl: Option<String>,
79
80 #[arg(long)]
82 pub json: bool,
83}
84
85#[derive(Debug, Args)]
86pub struct ListArgs {
87 #[arg(long)]
89 pub symbol: Option<String>,
90
91 #[arg(long)]
93 pub workflow: Option<String>,
94
95 #[arg(long)]
97 pub session: Option<String>,
98
99 #[arg(long)]
101 pub status: Option<String>,
102
103 #[arg(long)]
105 pub all: bool,
106
107 #[arg(long)]
109 pub json: bool,
110}
111
112#[derive(Debug, Args)]
113pub struct ReadArgs {
114 pub scratch_id: String,
116
117 #[arg(long)]
119 pub json: bool,
120}
121
122#[derive(Debug, Args)]
123pub struct UpdateArgs {
124 pub scratch_id: String,
126
127 pub content: String,
129}
130
131#[derive(Debug, Args)]
132pub struct DiscardArgs {
133 pub scratch_id: String,
135}
136
137#[derive(Debug, Args)]
138pub struct PromoteArgs {
139 pub scratch_id: String,
141
142 #[arg(long, short = 'k')]
145 pub kind: String,
146
147 #[arg(long, short = 's')]
150 pub summary: Option<String>,
151
152 #[arg(long)]
156 pub symbol: Option<String>,
157
158 #[arg(long)]
160 pub author: Option<String>,
161}
162
163#[derive(Debug, Args)]
164pub struct CleanArgs {
165 #[arg(long)]
167 pub older_than: String,
168
169 #[arg(long, default_value = "discarded,promoted")]
172 pub status: String,
173
174 #[arg(long)]
176 pub dry_run: bool,
177}
178
179pub fn run(cfg: &Config, cmd: ScratchCmd) -> Result<()> {
184 match cmd {
185 ScratchCmd::Write(args) => write_cmd(cfg, args),
186 ScratchCmd::List(args) => list_cmd(cfg, args),
187 ScratchCmd::Read(args) => read_cmd(cfg, args),
188 ScratchCmd::Update(args) => update_cmd(cfg, args),
189 ScratchCmd::Discard(args) => discard_cmd(cfg, args),
190 ScratchCmd::Promote(args) => promote_cmd(cfg, args),
191 ScratchCmd::Clean(args) => clean_cmd(cfg, args),
192 }
193}
194
195fn parse_ttl(s: &str) -> Result<chrono::Duration> {
200 let s = s.trim();
201 if let Some(days) = s.strip_suffix('d') {
202 let n: i64 = days
203 .parse()
204 .map_err(|_| anyhow::anyhow!("invalid TTL: {s}"))?;
205 return Ok(chrono::Duration::days(n));
206 }
207 if let Some(hours) = s.strip_suffix('h') {
208 let n: i64 = hours
209 .parse()
210 .map_err(|_| anyhow::anyhow!("invalid TTL: {s}"))?;
211 return Ok(chrono::Duration::hours(n));
212 }
213 bail!("TTL must be in the form `24h` or `7d`")
214}
215
216fn parse_statuses(s: &str) -> Vec<ScratchStatus> {
217 s.split(',')
218 .filter_map(|t| match t.trim() {
219 "draft" => Some(ScratchStatus::Draft),
220 "promoted" => Some(ScratchStatus::Promoted),
221 "discarded" => Some(ScratchStatus::Discarded),
222 _ => None,
223 })
224 .collect()
225}
226
227fn parse_kind(s: &str) -> Result<LedgerKind> {
228 match s {
229 "decision" => Ok(LedgerKind::Decision),
230 "assumption" => Ok(LedgerKind::Assumption),
231 "constraint" => Ok(LedgerKind::Constraint),
232 "rationale" => Ok(LedgerKind::Rationale),
233 "hazard" => Ok(LedgerKind::Hazard),
234 "tradeoff" => Ok(LedgerKind::Tradeoff),
235 "invariant" => Ok(LedgerKind::Invariant),
236 "ownership" => Ok(LedgerKind::Ownership),
237 "proof" => Ok(LedgerKind::Proof),
238 other => bail!(
239 "unknown ledger kind: '{}'. Valid: decision, assumption, constraint, rationale, hazard, tradeoff, invariant, ownership, proof",
240 other
241 ),
242 }
243}
244
245fn first_line(content: &str) -> String {
247 content
248 .lines()
249 .find(|l| !l.trim().is_empty())
250 .unwrap_or(content)
251 .chars()
252 .take(140)
253 .collect()
254}
255
256fn write_cmd(cfg: &Config, args: WriteArgs) -> Result<()> {
261 let engine = Engine::open_sqlite(&cfg.db_path)?;
262 let store = AsgScratchStore { repo: &engine.repo };
263
264 let mut entry = ScratchEntry::new(&args.content, &cfg.agent_id);
265 entry.tags = args.tags;
266
267 if let Some(ref wf) = args.workflow {
268 entry.workflow = Some(wf.clone());
269 }
270
271 if let Some(ref ttl) = args.ttl {
272 let dur = parse_ttl(ttl)?;
273 entry.expires_at = Some(chrono::Utc::now() + dur);
274 }
275
276 if let Some(ref qname) = args.symbol {
278 let index = AsgIndexStore::from_engine(&engine);
279 let sym = index
280 .get_symbol_by_qname(&engine.ref_name, qname)?
281 .ok_or_else(|| anyhow::anyhow!("symbol not found: {qname}"))?;
282 entry.symbol_id = Some(sym.symbol_id);
283 }
284
285 let stored = store.write_entry(&engine.ref_name, &entry, &cfg.agent_id)?;
286
287 if args.json {
288 println!("{}", serde_json::to_string_pretty(&stored)?);
289 } else {
290 println!("scratch_id: {}", stored.scratch_id);
291 println!("status: draft");
292 if let Some(ref wf) = stored.workflow {
293 println!("workflow: {wf}");
294 }
295 if let Some(ref sym) = stored.symbol_id {
296 println!("symbol_id: {sym}");
297 }
298 }
299 Ok(())
300}
301
302fn list_cmd(cfg: &Config, args: ListArgs) -> Result<()> {
303 let engine = Engine::open_sqlite(&cfg.db_path)?;
304 let store = AsgScratchStore { repo: &engine.repo };
305
306 let filter = if args.all {
307 ScratchFilter::all()
308 } else {
309 let status = args
310 .status
311 .as_deref()
312 .and_then(|s| match s {
313 "draft" => Some(ScratchStatus::Draft),
314 "promoted" => Some(ScratchStatus::Promoted),
315 "discarded" => Some(ScratchStatus::Discarded),
316 _ => None,
317 })
318 .or(Some(ScratchStatus::Draft)); ScratchFilter {
321 symbol_id: None, workflow: args.workflow.clone(),
323 session: args.session.clone(),
324 status,
325 exclude_expired: true,
326 }
327 };
328
329 let sym_id_filter: Option<String> = if let Some(ref qname) = args.symbol {
331 let index = AsgIndexStore::from_engine(&engine);
332 let sym = index
333 .get_symbol_by_qname(&engine.ref_name, qname)?
334 .ok_or_else(|| anyhow::anyhow!("symbol not found: {qname}"))?;
335 Some(sym.symbol_id)
336 } else {
337 None
338 };
339
340 let mut filter = filter;
341 filter.symbol_id = sym_id_filter;
342
343 let entries = store.list_entries(&engine.ref_name, &filter)?;
344
345 if args.json {
346 println!("{}", serde_json::to_string_pretty(&entries)?);
347 return Ok(());
348 }
349
350 if entries.is_empty() {
351 println!("No scratch entries found.");
352 return Ok(());
353 }
354
355 println!(
356 "{:<36} {:<10} {:<22} {}",
357 "scratch_id", "status", "workflow", "content (first 60 chars)"
358 );
359 println!("{}", "-".repeat(120));
360 for e in &entries {
361 let snippet: String = e.content.chars().take(60).collect();
362 let snippet = snippet.replace('\n', " ");
363 println!(
364 "{:<36} {:<10} {:<22} {}",
365 e.scratch_id,
366 format!("{:?}", e.status).to_lowercase(),
367 e.workflow.as_deref().unwrap_or("—"),
368 snippet,
369 );
370 }
371 println!("\n{} entry/entries.", entries.len());
372 Ok(())
373}
374
375fn read_cmd(cfg: &Config, args: ReadArgs) -> Result<()> {
376 let engine = Engine::open_sqlite(&cfg.db_path)?;
377 let store = AsgScratchStore { repo: &engine.repo };
378 let entry = store.read_entry(&engine.ref_name, &args.scratch_id)?;
379
380 if args.json {
381 println!("{}", serde_json::to_string_pretty(&entry)?);
382 return Ok(());
383 }
384
385 println!("scratch_id: {}", entry.scratch_id);
386 println!("status: {:?}", entry.status);
387 println!("session: {}", entry.session);
388 if let Some(ref wf) = entry.workflow {
389 println!("workflow: {wf}");
390 }
391 if let Some(ref sid) = entry.symbol_id {
392 println!("symbol_id: {sid}");
393 }
394 if let Some(ref pt) = entry.promoted_to {
395 println!("promoted_to: {pt}");
396 }
397 if !entry.tags.is_empty() {
398 println!("tags: {}", entry.tags.join(", "));
399 }
400 println!("created_at: {}", entry.created_at);
401 println!("updated_at: {}", entry.updated_at);
402 if let Some(exp) = entry.expires_at {
403 println!("expires_at: {}", exp);
404 }
405 println!("\n{}", entry.content);
406 Ok(())
407}
408
409fn update_cmd(cfg: &Config, args: UpdateArgs) -> Result<()> {
410 let engine = Engine::open_sqlite(&cfg.db_path)?;
411 let store = AsgScratchStore { repo: &engine.repo };
412 let updated = store.update_entry(
413 &engine.ref_name,
414 &args.scratch_id,
415 &args.content,
416 &cfg.agent_id,
417 )?;
418 println!("Updated {}", updated.scratch_id);
419 Ok(())
420}
421
422fn discard_cmd(cfg: &Config, args: DiscardArgs) -> Result<()> {
423 let engine = Engine::open_sqlite(&cfg.db_path)?;
424 let store = AsgScratchStore { repo: &engine.repo };
425 store.discard_entry(&engine.ref_name, &args.scratch_id, &cfg.agent_id)?;
426 println!("Discarded {}", args.scratch_id);
427 Ok(())
428}
429
430fn promote_cmd(cfg: &Config, args: PromoteArgs) -> Result<()> {
431 let engine = open_engine_public(cfg)?;
433 let scratch_store = AsgScratchStore { repo: &engine.repo };
434 let ledger_store = AsgLedgerStore::from_engine(&engine);
435
436 let entry = scratch_store.read_entry(&engine.ref_name, &args.scratch_id)?;
438
439 let symbol_id = if let Some(ref qname) = args.symbol {
441 let index = AsgIndexStore::from_engine(&engine);
442 let sym = index
443 .get_symbol_by_qname(&engine.ref_name, qname)?
444 .ok_or_else(|| anyhow::anyhow!("symbol not found: {qname}"))?;
445 sym.symbol_id
446 } else if let Some(ref sid) = entry.symbol_id {
447 sid.clone()
448 } else {
449 bail!(
450 "no symbol attached to scratch entry and --symbol was not provided. \
451 Use --symbol <qname> to attach the ledger entry to a symbol."
452 );
453 };
454
455 let summary = args.summary.unwrap_or_else(|| first_line(&entry.content));
457
458 let kind = parse_kind(&args.kind)?;
460 let author_id = args.author.unwrap_or_else(|| cfg.agent_id.clone());
461 let author = Author {
462 kind: AuthorKind::Agent,
463 id: author_id.clone(),
464 };
465 let mut ledger_entry = LedgerEntry::new(&symbol_id, kind, &summary, author);
466 ledger_entry.body = Some(entry.content.clone());
467
468 ledger_store.append_entry(&engine.ref_name, &ledger_entry, &author_id)?;
470
471 let promoted = scratch_store.mark_promoted(
473 &engine.ref_name,
474 &entry.scratch_id,
475 &ledger_entry.entry_id,
476 &author_id,
477 )?;
478
479 println!("scratch_id: {}", promoted.scratch_id);
480 println!("promoted_to: {}", ledger_entry.entry_id);
481 println!("symbol_id: {symbol_id}");
482 Ok(())
483}
484
485fn clean_cmd(cfg: &Config, args: CleanArgs) -> Result<()> {
486 let engine = Engine::open_sqlite(&cfg.db_path)?;
487 let store = AsgScratchStore { repo: &engine.repo };
488
489 let dur = parse_ttl(&args.older_than)?;
490 let statuses = parse_statuses(&args.status);
491
492 let filter = CleanFilter {
493 older_than: Some(dur),
494 statuses,
495 };
496
497 let count = store.clean_entries(&engine.ref_name, &filter, args.dry_run)?;
498
499 if args.dry_run {
500 println!(
501 "dry-run: {} entry/entries would be deleted. Run without --dry-run to apply.",
502 count
503 );
504 } else {
505 println!("Deleted {} entry/entries.", count);
506 }
507 Ok(())
508}