Skip to main content

agentstatedeveloper_cli/commands/
hydrate.rs

1//! `asd hydrate [--dir <path>] [--verify]` — read the `.asd/v1/` sidecar and write
2//! its contents back into the ASG repo. Inverse of `asd sync`.
3//!
4//! Intended for fresh `git clone` flows: after `asd init`, `asd hydrate`
5//! populates the ASG repo from the committed sidecar so the local
6//! machine has full current-state without a registry call.
7//!
8//! `--verify` performs a read-back pass after hydration and checks that
9//! the symbol, effect, and ledger counts (including invariant count) match
10//! what was loaded.  Exits with a non-zero status and prints a `verify`
11//! object on mismatch so callers and CI can detect silent failures.
12
13use std::path::PathBuf;
14
15use anyhow::Result;
16use clap::Args;
17use serde_json::json;
18
19use agentstatedeveloper_core::{Engine, LedgerEntry, LedgerKind, hydrate_from_dir_with_cache};
20
21use crate::config::Config;
22
23#[derive(Debug, Args)]
24pub struct HydrateArgs {
25    /// Project root to hydrate from. `.asd/v1/` is appended internally.
26    /// Defaults to the current working directory. The store filled is the
27    /// `.asd-state.db` in that same directory unless `--db`/`ASD_DB` names
28    /// another — never a parent's or the registry's active repo.
29    #[arg(long)]
30    pub dir: Option<PathBuf>,
31
32    /// After hydrating, read the engine back and verify symbol, ledger,
33    /// and effect counts match what was loaded. Exits non-zero on mismatch.
34    #[arg(long)]
35    pub verify: bool,
36}
37
38pub fn run(cfg: &Config, args: HydrateArgs) -> Result<()> {
39    let engine = Engine::open_sqlite(&cfg.db_path)?;
40    let dir = resolve_dir(args.dir)?;
41
42    // sidecar::hydrate_from_dir returns a clear error if `.asd/v1/`
43    // doesn't exist. Surface it as-is; the message already says "did
44    // you mean to run `asd sync` first?".
45    if let Some(w) = agentstatedeveloper_core::outdated_hooks(&dir) {
46        eprintln!("warning: {w}");
47    }
48    let summary = hydrate_from_dir_with_cache(
49        &engine.repo,
50        engine.fts.as_ref(),
51        &engine.ref_name,
52        &dir,
53        &cfg.agent_id,
54    )?;
55
56    // Plan T: hydrate used to leave the SQLite fast-read caches
57    // (asd_symbols_cache, asd_call_edges, FTS) empty — a born-cold DB where
58    // every read paid the slow git-walk path until the next `asd index`.
59    // We just wrote every symbol anyway, so populate the caches before exit.
60    // Non-fatal on failure: the open-time self-heal retries on the next run.
61    let (caches_synced, cache_sync_warning) = match engine.warm_caches() {
62        Ok(w) => (!w.skipped, None),
63        Err(e) => (false, Some(e.to_string())),
64    };
65
66    let mut out = json!({
67        "db": cfg.db_path.display().to_string(),
68        "dir": dir.join(".asd/v1").display().to_string(),
69        "symbols_loaded": summary.symbols_loaded,
70        "symbols_skipped": summary.symbols_skipped,
71        "effects_loaded": summary.effects_loaded,
72        "ledger_entries_loaded": summary.ledger_entries_loaded,
73        "ledger_entries_skipped": summary.ledger_entries_skipped,
74        "rebinds_replayed": summary.rebinds_replayed,
75        "blobs_rejected": summary.blobs_rejected,
76        "refs_dropped": summary.refs_dropped,
77        "missing_schema_version": summary.missing_schema_version,
78        "caches_synced": caches_synced,
79        "note": "commit history not restored; run `asd index` to rebuild the semantic index and call graph",
80    });
81    if let Some(w) = cache_sync_warning {
82        if let Some(obj) = out.as_object_mut() {
83            obj.insert("cache_sync_warning".to_string(), json!(w));
84        }
85    }
86
87    if args.verify {
88        let result = verify_hydration(&engine, &summary);
89        let ok = result.ok;
90        if let Some(obj) = out.as_object_mut() {
91            obj.insert("verify".to_string(), serde_json::to_value(&result)?);
92        }
93        println!("{}", serde_json::to_string_pretty(&out)?);
94        if !ok {
95            std::process::exit(1);
96        }
97    } else {
98        println!("{}", serde_json::to_string_pretty(&out)?);
99    }
100
101    Ok(())
102}
103
104#[derive(serde::Serialize)]
105struct VerifyResult {
106    ok: bool,
107    symbols_expected: usize,
108    symbols_actual: usize,
109    effects_expected: usize,
110    effects_actual: usize,
111    ledger_entries_expected: usize,
112    ledger_entries_actual: usize,
113    invariants_actual: usize,
114    discrepancies: Vec<String>,
115}
116
117fn verify_hydration(
118    engine: &Engine,
119    summary: &agentstatedeveloper_core::HydrateSummary,
120) -> VerifyResult {
121    let ref_name = &engine.ref_name;
122    let mut discrepancies: Vec<String> = Vec::new();
123
124    // --- Symbol count -------------------------------------------------------
125    let symbols_actual = match engine.repo.get_tree(ref_name, "/asd/v1/index/by-qname") {
126        Ok(serde_json::Value::Object(map)) => map.len(),
127        _ => 0,
128    };
129    // symbols_loaded + symbols_skipped = total symbols in DB (skipped means
130    // already-present and up-to-date, not a failure).
131    let symbols_expected = summary.symbols_loaded + summary.symbols_skipped;
132    if symbols_actual < summary.symbols_loaded {
133        discrepancies.push(format!(
134            "symbols: loaded {} but only {} readable from index",
135            summary.symbols_loaded, symbols_actual
136        ));
137    }
138
139    // --- Effect count -------------------------------------------------------
140    let effects_actual = match engine.repo.get_tree(ref_name, "/asd/v1/effects") {
141        Ok(serde_json::Value::Object(map)) => map.len(),
142        _ => 0,
143    };
144    let effects_expected = summary.effects_loaded;
145    if effects_actual < effects_expected {
146        discrepancies.push(format!(
147            "effects: loaded {} but only {} readable",
148            effects_expected, effects_actual
149        ));
150    }
151
152    // --- Ledger count + invariant count -------------------------------------
153    let (ledger_entries_actual, invariants_actual) = count_ledger_entries(engine);
154    let ledger_entries_expected = summary.ledger_entries_loaded;
155    if ledger_entries_actual < ledger_entries_expected {
156        discrepancies.push(format!(
157            "ledger: loaded {} but only {} readable",
158            ledger_entries_expected, ledger_entries_actual
159        ));
160    }
161
162    // Flag if sidecar had ledger entries but no invariants surfaced.
163    if ledger_entries_expected > 0 && invariants_actual == 0 {
164        discrepancies.push(
165            "invariants: ledger entries were loaded but no invariant-kind entries found — possible kind mismatch".to_string(),
166        );
167    }
168
169    // Any rejected blobs are a hard warning.
170    if summary.blobs_rejected > 0 {
171        discrepancies.push(format!(
172            "{} sidecar file(s) were rejected due to parse errors",
173            summary.blobs_rejected
174        ));
175    }
176
177    VerifyResult {
178        ok: discrepancies.is_empty(),
179        symbols_expected,
180        symbols_actual,
181        effects_expected,
182        effects_actual,
183        ledger_entries_expected,
184        ledger_entries_actual,
185        invariants_actual,
186        discrepancies,
187    }
188}
189
190fn count_ledger_entries(engine: &Engine) -> (usize, usize) {
191    let tree = match engine.repo.get_tree(&engine.ref_name, "/asd/v1/ledger") {
192        Ok(v) => v,
193        _ => return (0, 0),
194    };
195    let mut total = 0usize;
196    let mut invariants = 0usize;
197    if let Some(sym_map) = tree.as_object() {
198        for per_symbol in sym_map.values() {
199            if let Some(entry_map) = per_symbol.as_object() {
200                for entry_val in entry_map.values() {
201                    if let Ok(e) = serde_json::from_value::<LedgerEntry>(entry_val.clone()) {
202                        total += 1;
203                        if e.kind == LedgerKind::Invariant {
204                            invariants += 1;
205                        }
206                    }
207                }
208            }
209        }
210    }
211    (total, invariants)
212}
213
214fn resolve_dir(explicit: Option<PathBuf>) -> Result<PathBuf> {
215    match explicit {
216        Some(p) => Ok(p),
217        None => Ok(std::env::current_dir()?),
218    }
219}