agentstatedeveloper_cli/commands/
repo.rs1use anyhow::{Context, Result};
5use clap::{Args, Subcommand};
6use serde_json::{Value, json};
7use std::collections::{HashMap, HashSet};
8use std::path::{Path, PathBuf};
9
10use agentstatedeveloper_core::registry::Registry;
11use agentstatedeveloper_core::{
12 AsgIndexStore, AsgLedgerStore, Direction, Engine, IndexStore, LedgerKind, LedgerStore,
13 ServiceEndpoint, ServiceManifest, endpoints_from_tree, federated_edges,
14};
15
16use crate::config::Config;
17
18#[derive(Debug, Subcommand)]
19pub enum RepoCmd {
20 Add(AddArgs),
23 List(ListArgs),
25 Use(UseArgs),
27 Rm(RmArgs),
29 Prune(PruneArgs),
33 Show(ShowArgs),
35 Edges(EdgesArgs),
39 Impact(ImpactArgs),
46}
47
48#[derive(Debug, Args)]
49pub struct AddArgs {
50 pub name: Option<String>,
52 pub path: Option<PathBuf>,
55 #[arg(long)]
57 pub activate: bool,
58}
59
60#[derive(Debug, Args)]
61pub struct ListArgs {
62 #[arg(long)]
64 pub json: bool,
65}
66
67#[derive(Debug, Args)]
68pub struct UseArgs {
69 pub name: String,
70}
71
72#[derive(Debug, Args)]
73pub struct RmArgs {
74 pub name: String,
75}
76
77#[derive(Debug, Args)]
78pub struct PruneArgs {
79 #[arg(long)]
81 pub dry_run: bool,
82 #[arg(long)]
84 pub json: bool,
85}
86
87#[derive(Debug, Args)]
88pub struct ShowArgs {
89 #[arg(long)]
91 pub json: bool,
92}
93
94#[derive(Debug, Args)]
95pub struct EdgesArgs {
96 #[arg(long)]
98 pub agent: bool,
99 #[arg(long)]
101 pub include_in_repo: bool,
102}
103
104#[derive(Debug, Args)]
105pub struct ImpactArgs {
106 pub target: String,
110 #[arg(long)]
112 pub agent: bool,
113}
114
115pub fn run(_cfg: &Config, cmd: RepoCmd) -> Result<()> {
116 match cmd {
117 RepoCmd::Add(args) => run_add(args),
118 RepoCmd::List(args) => run_list(args),
119 RepoCmd::Use(args) => run_use(args),
120 RepoCmd::Rm(args) => run_rm(args),
121 RepoCmd::Prune(args) => run_prune(args),
122 RepoCmd::Show(args) => run_show(args),
123 RepoCmd::Edges(args) => run_edges(args),
124 RepoCmd::Impact(args) => run_impact(args),
125 }
126}
127
128fn run_add(args: AddArgs) -> Result<()> {
129 let path = match args.path {
130 Some(p) => absolutize(&p)?,
131 None => std::env::current_dir()
132 .context("could not read current directory")?
133 .join(".asd-state.db"),
134 };
135 let name = match args.name {
136 Some(n) => n,
137 None => default_name_for(&path)
138 .context("could not derive a default name from the path; pass one explicitly")?,
139 };
140
141 let mut reg = Registry::load().context("loading registry")?;
142 reg.register(&name, &path)
143 .with_context(|| format!("registering {name}"))?;
144 if args.activate {
145 reg.set_active(&name)?;
146 }
147 reg.save().context("saving registry")?;
148
149 println!("Registered {name} -> {}", path.display());
150 if args.activate {
151 println!("Active: {name}");
152 } else if reg.active().is_none() {
153 println!("Tip: run `asd repo use {name}` to make it the active repo.");
154 }
155 Ok(())
156}
157
158fn run_list(args: ListArgs) -> Result<()> {
159 let reg = Registry::load().context("loading registry")?;
160 let entries = reg.list();
161 let active = reg.active().map(|e| e.name.clone());
162
163 if args.json {
164 let payload = serde_json::json!({
165 "active": active,
166 "repos": entries
167 .iter()
168 .map(|e| serde_json::json!({
169 "name": e.name,
170 "path": e.path.display().to_string(),
171 "active": Some(&e.name) == active.as_ref(),
172 }))
173 .collect::<Vec<_>>(),
174 });
175 println!("{}", serde_json::to_string_pretty(&payload)?);
176 return Ok(());
177 }
178
179 if entries.is_empty() {
180 println!("No repos registered.");
181 println!();
182 println!("Register one with:");
183 println!(" asd repo add # uses cwd + dir name");
184 println!(" asd repo add myapp /path/to/.asd-state.db");
185 return Ok(());
186 }
187
188 let name_width = entries
189 .iter()
190 .map(|e| e.name.len())
191 .max()
192 .unwrap_or(4)
193 .max(4);
194 for e in entries {
195 let marker = if Some(&e.name) == active.as_ref() {
196 "*"
197 } else {
198 " "
199 };
200 println!(
201 "{marker} {:width$} {}",
202 e.name,
203 e.path.display(),
204 width = name_width
205 );
206 }
207 if active.is_none() {
208 println!();
209 println!("No active repo. Run `asd repo use <name>`.");
210 }
211 Ok(())
212}
213
214fn run_use(args: UseArgs) -> Result<()> {
215 let mut reg = Registry::load().context("loading registry")?;
216 reg.set_active(&args.name)?;
217 reg.save().context("saving registry")?;
218 let path = reg
219 .active()
220 .map(|e| e.path.display().to_string())
221 .unwrap_or_default();
222 println!("Active: {} -> {}", args.name, path);
223 Ok(())
224}
225
226fn run_rm(args: RmArgs) -> Result<()> {
227 let mut reg = Registry::load().context("loading registry")?;
228 reg.remove(&args.name)?;
229 reg.save().context("saving registry")?;
230 println!("Removed {}", args.name);
231 Ok(())
232}
233
234fn run_prune(args: PruneArgs) -> Result<()> {
235 let mut reg = Registry::load().context("loading registry")?;
236
237 let dead: Vec<(String, String)> = reg
240 .list()
241 .iter()
242 .filter(|e| !e.path.exists())
243 .map(|e| (e.name.clone(), e.path.display().to_string()))
244 .collect();
245
246 if !args.dry_run && !dead.is_empty() {
247 reg.prune_missing();
248 reg.save().context("saving registry")?;
249 }
250
251 if args.json {
252 println!(
253 "{}",
254 serde_json::to_string_pretty(&json!({
255 "dry_run": args.dry_run,
256 "removed": dead.iter().map(|(n, p)| json!({ "name": n, "path": p })).collect::<Vec<_>>(),
257 "remaining": reg.list().len(),
258 }))?
259 );
260 return Ok(());
261 }
262
263 if dead.is_empty() {
264 println!("Nothing to prune — every registered repo's db still exists.");
265 return Ok(());
266 }
267 let verb = if args.dry_run {
268 "Would remove"
269 } else {
270 "Removed"
271 };
272 println!("{verb} {} dead repo(s) (db missing):", dead.len());
273 for (name, path) in &dead {
274 println!(" {name} → {path}");
275 }
276 if args.dry_run {
277 println!("\nRe-run without --dry-run to apply.");
278 } else {
279 println!("\n{} repo(s) remain.", reg.list().len());
280 }
281 Ok(())
282}
283
284fn run_show(args: ShowArgs) -> Result<()> {
285 let reg = Registry::load().context("loading registry")?;
286 match reg.active() {
287 Some(e) => {
288 if args.json {
289 println!(
290 "{}",
291 serde_json::json!({ "name": e.name, "path": e.path.display().to_string() })
292 );
293 } else {
294 println!("{} -> {}", e.name, e.path.display());
295 }
296 }
297 None => {
298 if args.json {
299 println!("{}", serde_json::json!({ "name": null, "path": null }));
300 } else {
301 println!("No active repo.");
302 }
303 }
304 }
305 Ok(())
306}
307
308fn run_edges(args: EdgesArgs) -> Result<()> {
309 let reg = Registry::load().context("loading registry")?;
310 let entries = reg.list();
311 if entries.is_empty() {
312 println!("No repos registered. Add a couple with `asd repo add`, then re-index each.");
313 return Ok(());
314 }
315
316 let mut manifests = Vec::new();
318 let mut loaded: Vec<(String, usize)> = Vec::new();
319 for e in &entries {
320 match Engine::open_sqlite(&e.path) {
321 Ok(engine) => {
322 let tree = engine
323 .repo
324 .get_tree(&engine.ref_name, "/asd/v1/index/endpoints")
325 .unwrap_or(Value::Null);
326 let endpoints = endpoints_from_tree(&tree);
327 let repo_id = endpoints
328 .first()
329 .map(|ep| ep.repo_id.clone())
330 .unwrap_or_else(|| e.name.clone());
331 loaded.push((e.name.clone(), endpoints.len()));
332 manifests.push(ServiceManifest { repo_id, endpoints });
333 }
334 Err(err) => eprintln!("skip {}: {err}", e.name),
335 }
336 }
337
338 let edges = federated_edges(&manifests, !args.include_in_repo);
339
340 if args.agent {
341 println!(
342 "{}",
343 serde_json::to_string_pretty(&json!({
344 "repos": loaded.iter().map(|(n, c)| json!({ "name": n, "endpoints": c })).collect::<Vec<_>>(),
345 "edges": edges,
346 }))?
347 );
348 return Ok(());
349 }
350
351 println!(
352 "{} repos loaded ({} endpoints total).",
353 loaded.len(),
354 loaded.iter().map(|(_, c)| c).sum::<usize>()
355 );
356 let label = if args.include_in_repo {
357 "edge(s)"
358 } else {
359 "cross-repo edge(s)"
360 };
361 if edges.is_empty() {
362 println!("No {label}. (Index each repo first so its contracts are current.)");
363 return Ok(());
364 }
365 println!("{} {label}:", edges.len());
366 for ed in &edges {
367 let scope = if ed.cross_repo { " [cross-repo]" } else { "" };
368 println!(
369 " {} → {} {}{}",
370 ed.from.repo_id, ed.to.repo_id, ed.contract, scope
371 );
372 println!(
373 " {} → {}",
374 short_qname(&ed.from.qname),
375 short_qname(&ed.to.qname)
376 );
377 }
378 Ok(())
379}
380
381fn short_qname(q: &str) -> &str {
382 q.rsplit(['.', ':']).next().unwrap_or(q)
383}
384
385fn run_impact(args: ImpactArgs) -> Result<()> {
386 let reg = Registry::load().context("loading registry")?;
387 let entries = reg.list();
388 if entries.is_empty() {
389 println!("No repos registered. Add repos with `asd repo add`, then re-index each.");
390 return Ok(());
391 }
392
393 let mut all: Vec<ServiceEndpoint> = Vec::new();
395 let mut repo_db: HashMap<String, PathBuf> = HashMap::new();
396 for e in &entries {
397 if let Ok(engine) = Engine::open_sqlite(&e.path) {
398 let tree = engine
399 .repo
400 .get_tree(&engine.ref_name, "/asd/v1/index/endpoints")
401 .unwrap_or(Value::Null);
402 let eps = endpoints_from_tree(&tree);
403 if let Some(rid) = eps.first().map(|x| x.repo_id.clone()) {
404 repo_db.entry(rid).or_insert_with(|| e.path.clone());
405 }
406 all.extend(eps);
407 }
408 }
409
410 let target = args.target.trim();
412 let target_contracts: HashSet<String> =
413 if target.starts_with("http:") || target.starts_with("topic:") {
414 std::iter::once(target.to_string()).collect()
415 } else {
416 all.iter()
417 .filter(|e| {
418 e.direction == Direction::Inbound
419 && (e.qname == target || e.qname.ends_with(&format!(".{target}")))
420 })
421 .map(|e| e.contract.clone())
422 .collect()
423 };
424 if target_contracts.is_empty() {
425 println!(
426 "No inbound endpoint matches '{target}'. Pass a route-handler qname \
427 or a contract like 'http:GET /api/x'."
428 );
429 return Ok(());
430 }
431
432 let producer_repos: HashSet<String> = all
434 .iter()
435 .filter(|e| e.direction == Direction::Inbound && target_contracts.contains(&e.contract))
436 .map(|e| e.repo_id.clone())
437 .collect();
438 let mut consumers: Vec<&ServiceEndpoint> = all
439 .iter()
440 .filter(|e| e.direction == Direction::Outbound && target_contracts.contains(&e.contract))
441 .collect();
442 consumers.sort_by(|a, b| (&a.repo_id, &a.qname).cmp(&(&b.repo_id, &b.qname)));
443 consumers.dedup_by(|a, b| a.repo_id == b.repo_id && a.qname == b.qname);
444
445 let rows: Vec<Value> = consumers
447 .iter()
448 .map(|c| {
449 let (inv, haz) = repo_db
450 .get(&c.repo_id)
451 .map(|db| judgment_for(db, &c.qname))
452 .unwrap_or_default();
453 json!({
454 "repo": c.repo_id,
455 "cross_repo": !producer_repos.contains(&c.repo_id),
456 "symbol": c.qname,
457 "file": c.file,
458 "line": c.line,
459 "contract": c.contract,
460 "invariants": inv,
461 "hazards": haz,
462 })
463 })
464 .collect();
465
466 if args.agent {
467 println!(
468 "{}",
469 serde_json::to_string_pretty(&json!({
470 "target": target,
471 "contracts": target_contracts.iter().collect::<Vec<_>>(),
472 "served_by": producer_repos.iter().collect::<Vec<_>>(),
473 "consumers": rows,
474 }))?
475 );
476 return Ok(());
477 }
478
479 let ctr = target_contracts
480 .iter()
481 .cloned()
482 .collect::<Vec<_>>()
483 .join(", ");
484 let served = producer_repos
485 .iter()
486 .cloned()
487 .collect::<Vec<_>>()
488 .join(", ");
489 println!("Changing {target} (contract: {ctr}; served by: {served})");
490 if rows.is_empty() {
491 println!(" No downstream consumers found across registered repos.");
492 return Ok(());
493 }
494 let cross = rows
495 .iter()
496 .filter(|r| r["cross_repo"].as_bool().unwrap_or(false))
497 .count();
498 println!(
499 " {} downstream consumer(s), {cross} cross-repo:",
500 rows.len()
501 );
502 for r in &rows {
503 let scope = if r["cross_repo"].as_bool().unwrap_or(false) {
504 "[cross-repo] "
505 } else {
506 ""
507 };
508 println!(
509 " {}{} — {} ({}:{})",
510 scope,
511 r["repo"].as_str().unwrap_or("?"),
512 r["symbol"].as_str().unwrap_or("?"),
513 r["file"].as_str().unwrap_or("?"),
514 r["line"].as_u64().unwrap_or(0),
515 );
516 for inv in r["invariants"].as_array().into_iter().flatten() {
517 println!(" invariant: {}", inv.as_str().unwrap_or(""));
518 }
519 for haz in r["hazards"].as_array().into_iter().flatten() {
520 println!(" hazard: {}", haz.as_str().unwrap_or(""));
521 }
522 }
523 Ok(())
524}
525
526fn judgment_for(db_path: &Path, qname: &str) -> (Vec<String>, Vec<String>) {
528 let mut invariants = Vec::new();
529 let mut hazards = Vec::new();
530 let Ok(engine) = Engine::open_sqlite(db_path) else {
531 return (invariants, hazards);
532 };
533 let index = AsgIndexStore::from_engine(&engine);
534 let ledger = AsgLedgerStore::from_engine(&engine);
535 if let Ok(Some(sym)) = index.get_symbol_by_qname(&engine.ref_name, qname) {
536 if let Ok(entries) = ledger.list_entries(&engine.ref_name, &sym.symbol_id) {
537 for e in entries {
538 match e.kind {
539 LedgerKind::Invariant => invariants.push(e.summary),
540 LedgerKind::Hazard => hazards.push(e.summary),
541 _ => {}
542 }
543 }
544 }
545 }
546 (invariants, hazards)
547}
548
549fn default_name_for(path: &std::path::Path) -> Option<String> {
550 let candidate = path
553 .parent()
554 .and_then(|p| p.file_name())
555 .and_then(|s| s.to_str())
556 .map(str::to_string)
557 .or_else(|| {
558 path.file_stem()
559 .and_then(|s| s.to_str())
560 .map(str::to_string)
561 })?;
562 let sanitized: String = candidate
563 .chars()
564 .map(|c| {
565 if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
566 c
567 } else {
568 '-'
569 }
570 })
571 .collect();
572 if sanitized.is_empty() {
573 None
574 } else {
575 Some(sanitized)
576 }
577}
578
579fn absolutize(p: &std::path::Path) -> Result<PathBuf> {
580 if p.is_absolute() {
581 return Ok(p.to_path_buf());
582 }
583 let cwd = std::env::current_dir().context("could not read current directory")?;
584 Ok(cwd.join(p))
585}
586
587#[cfg(test)]
588mod tests {
589 use super::*;
590 use std::path::PathBuf;
591
592 #[test]
593 fn default_name_uses_parent_dir() {
594 let p = PathBuf::from("/Users/user/code/myapp/.asd-state.db");
595 assert_eq!(default_name_for(&p).as_deref(), Some("myapp"));
596 }
597
598 #[test]
599 fn default_name_sanitizes_unsafe_chars() {
600 let p = PathBuf::from("/tmp/foo bar.baz/.asd-state.db");
601 assert_eq!(default_name_for(&p).as_deref(), Some("foo-bar-baz"));
602 }
603}