agentstatedeveloper_cli/commands/
sidecar.rs1use 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(MigrateArgs),
29}
30
31#[derive(Debug, Args)]
32pub struct MigrateArgs {
33 #[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
105fn 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}