1use anyhow::Result;
2use clap::{Parser, Subcommand};
3use std::fs;
4use std::path::PathBuf;
5
6use crate::{error::CliError, CommandContext};
7use theater::chain::ChainWriter;
8
9const LOCAL_CHAINS_DIR: &str = "chains";
11
12#[derive(Debug, Parser)]
13pub struct ChainsArgs {
14 #[arg(short, long, conflicts_with = "global")]
16 pub local: bool,
17
18 #[arg(short, long, conflicts_with = "local")]
20 pub global: bool,
21
22 #[command(subcommand)]
23 pub command: Option<ChainsCommand>,
24
25 #[arg(value_name = "ID")]
27 pub id: Option<String>,
28}
29
30#[derive(Debug, Subcommand)]
31pub enum ChainsCommand {
32 #[command(name = "gc")]
34 Gc,
35
36 #[command(name = "save")]
38 Save {
39 id: String,
41
42 #[arg(value_name = "NAME")]
44 name: Option<String>,
45 },
46}
47
48pub async fn execute_async(args: &ChainsArgs, ctx: &CommandContext) -> Result<(), CliError> {
49 match &args.command {
50 Some(ChainsCommand::Gc) => execute_gc(args, ctx).await,
51 Some(ChainsCommand::Save { id, name }) => execute_save(id, name.as_deref(), ctx).await,
52 None => {
53 if let Some(id) = &args.id {
54 execute_inspect(id, args, ctx).await
55 } else {
56 execute_list(args, ctx).await
57 }
58 }
59 }
60}
61
62async fn execute_list(args: &ChainsArgs, _ctx: &CommandContext) -> Result<(), CliError> {
64 let local_dir = PathBuf::from(LOCAL_CHAINS_DIR);
65 let global_dir = ChainWriter::chains_dir();
66
67 let mut found_any = false;
68
69 if !args.global && local_dir.exists() {
71 let chains = list_chains_in_dir(&local_dir)?;
72 if !chains.is_empty() {
73 println!("Local chains ({}):", local_dir.display());
74 for chain in &chains {
75 print_chain_summary(chain)?;
76 }
77 found_any = true;
78 }
79 }
80
81 if !args.local && global_dir.exists() {
83 let chains = list_chains_in_dir(&global_dir)?;
84 if !chains.is_empty() {
85 if found_any {
86 println!();
87 }
88 println!("Global chains ({}):", global_dir.display());
89 for chain in &chains {
90 print_chain_summary(chain)?;
91 }
92 found_any = true;
93 }
94 }
95
96 if !found_any {
97 println!("No chains found.");
98 }
99
100 Ok(())
101}
102
103async fn execute_inspect(
105 id: &str,
106 args: &ChainsArgs,
107 _ctx: &CommandContext,
108) -> Result<(), CliError> {
109 let chain_path = find_chain(id, args)?;
110
111 println!("Chain: {}", chain_path.display());
112 println!();
113
114 let contents = fs::read_to_string(&chain_path).map_err(|e| {
116 CliError::file_operation_failed("read chain", chain_path.display().to_string(), e)
117 })?;
118
119 let mut event_count = 0;
120 let mut event_types: std::collections::HashMap<String, usize> =
121 std::collections::HashMap::new();
122
123 for line in contents.lines() {
125 if line.starts_with("EVENT ") {
126 event_count += 1;
127 } else if !line.is_empty()
128 && !line.starts_with("0000000000000000")
129 && !line.chars().all(|c| c.is_ascii_hexdigit())
130 && !line.chars().all(|c| c.is_ascii_digit())
131 {
132 if !line.starts_with('{') && !line.contains(':') {
134 } else if !line.starts_with('{') {
136 *event_types.entry(line.to_string()).or_insert(0) += 1;
137 }
138 }
139 }
140
141 let file_size = fs::metadata(&chain_path).map(|m| m.len()).unwrap_or(0);
142
143 println!("Events: {}", event_count);
144 println!("Size: {}", format_size(file_size));
145 println!();
146
147 if !event_types.is_empty() {
148 println!("Event types:");
149 let mut types: Vec<_> = event_types.into_iter().collect();
150 types.sort_by(|a, b| b.1.cmp(&a.1));
151 for (event_type, count) in types.iter().take(10) {
152 println!(" {} ({})", event_type, count);
153 }
154 if types.len() > 10 {
155 println!(" ... and {} more types", types.len() - 10);
156 }
157 }
158
159 let meta_path = chain_path.with_extension("meta.json");
161 if meta_path.exists() {
162 if let Ok(meta_contents) = fs::read_to_string(&meta_path) {
163 println!();
164 println!("Metadata:");
165 println!("{}", meta_contents);
166 }
167 }
168
169 Ok(())
170}
171
172async fn execute_gc(args: &ChainsArgs, _ctx: &CommandContext) -> Result<(), CliError> {
174 let dir = if args.local {
175 PathBuf::from(LOCAL_CHAINS_DIR)
176 } else {
177 ChainWriter::chains_dir()
179 };
180
181 if !dir.exists() {
182 println!("No chains directory found at {}", dir.display());
183 return Ok(());
184 }
185
186 let chains = list_chains_in_dir(&dir)?;
187 if chains.is_empty() {
188 println!("No chains to clean up.");
189 return Ok(());
190 }
191
192 let mut total_size = 0u64;
193 let mut count = 0;
194
195 for chain_path in &chains {
196 if let Ok(metadata) = fs::metadata(chain_path) {
198 total_size += metadata.len();
199 }
200
201 if let Err(e) = fs::remove_file(chain_path) {
203 eprintln!("Failed to remove {}: {}", chain_path.display(), e);
204 continue;
205 }
206
207 let meta_path = chain_path.with_extension("meta.json");
209 if meta_path.exists() {
210 if let Ok(metadata) = fs::metadata(&meta_path) {
211 total_size += metadata.len();
212 }
213 let _ = fs::remove_file(&meta_path);
214 }
215
216 count += 1;
217 }
218
219 println!(
220 "Removed {} chain(s), freed {}",
221 count,
222 format_size(total_size)
223 );
224
225 Ok(())
226}
227
228async fn execute_save(id: &str, name: Option<&str>, _ctx: &CommandContext) -> Result<(), CliError> {
230 let global_dir = ChainWriter::chains_dir();
231 let local_dir = PathBuf::from(LOCAL_CHAINS_DIR);
232
233 let source_path = find_chain_in_dir(id, &global_dir)?.ok_or_else(|| {
235 CliError::invalid_manifest(format!("Chain '{}' not found in global directory", id))
236 })?;
237
238 fs::create_dir_all(&local_dir).map_err(|e| {
240 CliError::file_operation_failed("create directory", local_dir.display().to_string(), e)
241 })?;
242
243 let dest_name = name.unwrap_or(id);
245 let dest_path = local_dir.join(format!("{}.chain", dest_name));
246
247 fs::copy(&source_path, &dest_path).map_err(|e| {
249 CliError::file_operation_failed("copy chain", dest_path.display().to_string(), e)
250 })?;
251
252 let source_meta = source_path.with_extension("meta.json");
254 if source_meta.exists() {
255 let dest_meta = dest_path.with_extension("meta.json");
256 let _ = fs::copy(&source_meta, &dest_meta);
257 }
258
259 println!("Saved chain to {}", dest_path.display());
260
261 Ok(())
262}
263
264fn list_chains_in_dir(dir: &PathBuf) -> Result<Vec<PathBuf>, CliError> {
266 let entries = fs::read_dir(dir).map_err(|e| {
267 CliError::file_operation_failed("read directory", dir.display().to_string(), e)
268 })?;
269
270 let mut chains: Vec<PathBuf> = entries
271 .filter_map(|e| e.ok())
272 .map(|e| e.path())
273 .filter(|p| p.extension().map(|e| e == "chain").unwrap_or(false))
274 .collect();
275
276 chains.sort_by(|a, b| {
278 let a_time = fs::metadata(a).and_then(|m| m.modified()).ok();
279 let b_time = fs::metadata(b).and_then(|m| m.modified()).ok();
280 b_time.cmp(&a_time)
281 });
282
283 Ok(chains)
284}
285
286fn find_chain(id: &str, args: &ChainsArgs) -> Result<PathBuf, CliError> {
288 let local_dir = PathBuf::from(LOCAL_CHAINS_DIR);
289 let global_dir = ChainWriter::chains_dir();
290
291 if !args.global {
293 if let Some(path) = find_chain_in_dir(id, &local_dir)? {
294 return Ok(path);
295 }
296 }
297
298 if !args.local {
300 if let Some(path) = find_chain_in_dir(id, &global_dir)? {
301 return Ok(path);
302 }
303 }
304
305 Err(CliError::invalid_manifest(format!(
306 "Chain '{}' not found",
307 id
308 )))
309}
310
311fn find_chain_in_dir(id: &str, dir: &PathBuf) -> Result<Option<PathBuf>, CliError> {
313 if !dir.exists() {
314 return Ok(None);
315 }
316
317 let exact_path = dir.join(format!("{}.chain", id));
319 if exact_path.exists() {
320 return Ok(Some(exact_path));
321 }
322
323 let entries = fs::read_dir(dir).map_err(|e| {
325 CliError::file_operation_failed("read directory", dir.display().to_string(), e)
326 })?;
327
328 for entry in entries.filter_map(|e| e.ok()) {
329 let path = entry.path();
330 if path.extension().map(|e| e == "chain").unwrap_or(false) {
331 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
332 if stem.starts_with(id) || stem.contains(id) {
333 return Ok(Some(path));
334 }
335 }
336 }
337 }
338
339 Ok(None)
340}
341
342fn print_chain_summary(path: &PathBuf) -> Result<(), CliError> {
344 let name = path
345 .file_stem()
346 .and_then(|s| s.to_str())
347 .unwrap_or("unknown");
348
349 let size = fs::metadata(path).map(|m| m.len()).unwrap_or(0);
350
351 let meta_path = path.with_extension("meta.json");
353 let actor_name = if meta_path.exists() {
354 fs::read_to_string(&meta_path)
355 .ok()
356 .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
357 .and_then(|v| v.get("actor_name")?.as_str().map(String::from))
358 } else {
359 None
360 };
361
362 if let Some(actor) = actor_name {
363 println!(" {} ({}) - {}", name, actor, format_size(size));
364 } else {
365 println!(" {} - {}", name, format_size(size));
366 }
367
368 Ok(())
369}
370
371fn format_size(bytes: u64) -> String {
373 const KB: u64 = 1024;
374 const MB: u64 = KB * 1024;
375 const GB: u64 = MB * 1024;
376
377 if bytes >= GB {
378 format!("{:.1} GB", bytes as f64 / GB as f64)
379 } else if bytes >= MB {
380 format!("{:.1} MB", bytes as f64 / MB as f64)
381 } else if bytes >= KB {
382 format!("{:.1} KB", bytes as f64 / KB as f64)
383 } else {
384 format!("{} bytes", bytes)
385 }
386}