Skip to main content

agentstatedeveloper_cli/commands/
sidecar.rs

1//! `asd sidecar migrate` — one-shot migration from the old `.asd/v1/`
2//! sidecar to the Plan B `.asd/conclusions/` layout.
3//!
4//! Ledger entries already live in the ASG repo (not the sidecar), so the
5//! actual "drain" is just an `asd conclusions export` call. The work this
6//! command adds is operational:
7//!
8//!   1. run export → write `.asd/conclusions/*.jsonl`
9//!   2. measure both the new bytes and the old `.asd/v1/` bytes
10//!   3. print the `git rm -r --cached .asd/v1` instructions the user needs
11//!      to actually drop the old sidecar from tracking
12//!
13//! Lossy by design: anything derivable from source (symbols, effects,
14//! call edges, code blobs) is not preserved. Only conclusion entries
15//! survive, in their compact committable form.
16
17use anyhow::Result;
18use clap::{Args, Subcommand};
19use serde_json::json;
20
21use agentstatedeveloper_core::{Engine, conclusions_export};
22
23use crate::config::Config;
24
25#[derive(Debug, Subcommand)]
26pub enum SidecarCmd {
27    /// Migrate from the legacy .asd/v1/ sidecar to .asd/conclusions/*.jsonl.
28    Migrate(MigrateArgs),
29}
30
31#[derive(Debug, Args)]
32pub struct MigrateArgs {
33    /// Output directory for the JSONL files. Defaults to `.asd/conclusions/`
34    /// relative to the database parent directory.
35    #[arg(long)]
36    pub out: Option<std::path::PathBuf>,
37}
38
39pub fn run(cfg: &Config, cmd: SidecarCmd) -> Result<()> {
40    match cmd {
41        SidecarCmd::Migrate(args) => migrate(cfg, args),
42    }
43}
44
45fn migrate(cfg: &Config, args: MigrateArgs) -> Result<()> {
46    let engine = Engine::open_sqlite(&cfg.db_path)?;
47    let project_root = cfg
48        .db_path
49        .parent()
50        .map(|p| p.to_path_buf())
51        .unwrap_or_else(|| std::path::PathBuf::from("."));
52    let out_dir = args
53        .out
54        .unwrap_or_else(|| project_root.join(".asd/conclusions"));
55
56    let counts = conclusions_export::export_all(&engine, &out_dir)?;
57    let total_entries: usize = counts.iter().map(|(_, n, _)| n).sum();
58    let new_bytes: u64 = counts.iter().map(|(_, _, b)| b).sum();
59
60    let legacy_dir = project_root.join(".asd/v1");
61    let legacy_present = legacy_dir.exists();
62    let legacy_bytes = if legacy_present {
63        dir_byte_count(&legacy_dir)
64    } else {
65        0
66    };
67
68    let tracked_in_git = legacy_present && git_tracks(&project_root, ".asd/v1");
69
70    let mut next_steps: Vec<String> = Vec::new();
71    if tracked_in_git {
72        next_steps.push("git rm -r --cached .asd/v1".to_string());
73        next_steps.push(
74            "git commit -m 'plan-b: drop legacy .asd/v1/ sidecar (regenerable cache)'".to_string(),
75        );
76    } else if legacy_present {
77        next_steps.push("rm -rf .asd/v1  # local-only directory; safe to delete".to_string());
78    }
79    next_steps.push("git add .asd/conclusions/".to_string());
80
81    let payload = json!({
82        "out_dir": out_dir.display().to_string(),
83        "exported": {
84            "total_entries": total_entries,
85            "total_bytes": new_bytes,
86            "per_class": counts.iter().map(|(stem, n, b)| json!({
87                "class": stem,
88                "entries": n,
89                "bytes": b,
90            })).collect::<Vec<_>>(),
91        },
92        "legacy_sidecar": {
93            "path": legacy_dir.display().to_string(),
94            "present": legacy_present,
95            "tracked_in_git": tracked_in_git,
96            "estimated_bytes": legacy_bytes,
97        },
98        "savings_bytes": legacy_bytes.saturating_sub(new_bytes),
99        "next_steps": next_steps,
100    });
101    println!("{}", serde_json::to_string_pretty(&payload)?);
102    Ok(())
103}
104
105/// Recursively count bytes under `dir`. Errors are silently swallowed —
106/// this is a best-effort report, not an audit.
107fn dir_byte_count(dir: &std::path::Path) -> u64 {
108    let mut total: u64 = 0;
109    let entries = match std::fs::read_dir(dir) {
110        Ok(e) => e,
111        Err(_) => return 0,
112    };
113    for entry in entries.flatten() {
114        let path = entry.path();
115        if let Ok(meta) = entry.metadata() {
116            if meta.is_dir() {
117                total = total.saturating_add(dir_byte_count(&path));
118            } else {
119                total = total.saturating_add(meta.len());
120            }
121        }
122    }
123    total
124}
125
126fn git_tracks(root: &std::path::Path, rel: &str) -> bool {
127    std::process::Command::new("git")
128        .args(["ls-files", "--error-unmatch", rel])
129        .current_dir(root)
130        .stdout(std::process::Stdio::null())
131        .stderr(std::process::Stdio::null())
132        .status()
133        .map(|s| s.success())
134        .unwrap_or(false)
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use tempfile::tempdir;
141
142    #[test]
143    fn dir_byte_count_sums_nested_files() {
144        let tmp = tempdir().unwrap();
145        std::fs::create_dir_all(tmp.path().join("a/b")).unwrap();
146        std::fs::write(tmp.path().join("a/x.txt"), "hello").unwrap();
147        std::fs::write(tmp.path().join("a/b/y.txt"), "world!").unwrap();
148        assert_eq!(dir_byte_count(tmp.path()), 11);
149    }
150
151    #[test]
152    fn dir_byte_count_returns_zero_for_missing_dir() {
153        let tmp = tempdir().unwrap();
154        let missing = tmp.path().join("nope");
155        assert_eq!(dir_byte_count(&missing), 0);
156    }
157}