Skip to main content

agentstatedeveloper_cli/commands/
repo.rs

1//! `asd repo {add,list,use,rm,show}` — manage the shared ASD repo registry
2//! at `~/.config/asd/repos.toml`. See `docs/repo-registry.md`.
3
4use 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    /// Register a repo. `name` defaults to the directory stem of `path`;
21    /// `path` defaults to `<cwd>/.asd-state.db`.
22    Add(AddArgs),
23    /// List every registered repo with an active marker.
24    List(ListArgs),
25    /// Set the active repo.
26    Use(UseArgs),
27    /// Remove a repo.
28    Rm(RmArgs),
29    /// Remove every registered repo whose `.asd-state.db` no longer exists —
30    /// the dead entries left behind by ephemeral/test databases. Use
31    /// `--dry-run` to preview.
32    Prune(PruneArgs),
33    /// Print the active repo's name and path.
34    Show(ShowArgs),
35    /// Cross-repo service edges across all registered repos: a client call in
36    /// one repo matched to a route served by another, keyed by contract hash
37    /// (Plan Q t-005). Index each repo first so its contracts are current.
38    Edges(EdgesArgs),
39    /// Decision-aware federated impact (Plan Q t-006/7): given an endpoint you're
40    /// about to change (a route-handler qname, or a contract like
41    /// `http:GET /api/x`), show the downstream consumers in OTHER repos AND the
42    /// invariants/hazards those consuming symbols carry — pulled from each
43    /// consumer repo's own ledger. Answers "what breaks if I change this, and
44    /// what did those callers promise?"
45    Impact(ImpactArgs),
46}
47
48#[derive(Debug, Args)]
49pub struct AddArgs {
50    /// Repo name. Defaults to the directory stem of `path`.
51    pub name: Option<String>,
52    /// Absolute path to the `.asd-state.db` file.
53    /// Defaults to `<cwd>/.asd-state.db`.
54    pub path: Option<PathBuf>,
55    /// Set this repo as active after registering.
56    #[arg(long)]
57    pub activate: bool,
58}
59
60#[derive(Debug, Args)]
61pub struct ListArgs {
62    /// Emit JSON instead of the default human-readable table.
63    #[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    /// Show what would be removed without changing the registry.
80    #[arg(long)]
81    pub dry_run: bool,
82    /// Emit JSON instead of the human-readable summary.
83    #[arg(long)]
84    pub json: bool,
85}
86
87#[derive(Debug, Args)]
88pub struct ShowArgs {
89    /// Emit JSON instead of the default plain-text line.
90    #[arg(long)]
91    pub json: bool,
92}
93
94#[derive(Debug, Args)]
95pub struct EdgesArgs {
96    /// Machine-readable JSON.
97    #[arg(long)]
98    pub agent: bool,
99    /// Also include in-repo edges (default: cross-repo edges only).
100    #[arg(long)]
101    pub include_in_repo: bool,
102}
103
104#[derive(Debug, Args)]
105pub struct ImpactArgs {
106    /// The endpoint being changed: a route-handler qname (e.g.
107    /// `app.routes.get_orders`, or just `get_orders`) or a contract
108    /// (`http:GET /api/orders/{}`).
109    pub target: String,
110    /// Machine-readable JSON.
111    #[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    // Collect the dead entries (owned) before any mutation so the report is the
238    // same whether or not we then write.
239    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    // Load each registered repo's detected endpoints from its index.
317    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    // Load every registered repo's endpoints + map each repo_id -> its db path.
394    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    // Resolve the target to the contract(s) of the endpoint being changed.
411    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    // Producers (repos serving it) + downstream consumers.
433    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    // Decision-aware rows: each consumer + its own repo's invariants/hazards.
446    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
526/// Invariant + hazard summaries recorded on `qname` in the repo at `db_path`.
527fn 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    // The db lives at `<repo-root>/.asd-state.db`, so the parent dir's file
551    // name is the project name. Fall back to the file stem for unusual paths.
552    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}