Skip to main content

theater_cli/commands/
chains.rs

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
9/// Default local chains directory
10const LOCAL_CHAINS_DIR: &str = "chains";
11
12#[derive(Debug, Parser)]
13pub struct ChainsArgs {
14    /// Use local ./chains/ directory only
15    #[arg(short, long, conflicts_with = "global")]
16    pub local: bool,
17
18    /// Use global /tmp/theater/chains/ directory only
19    #[arg(short, long, conflicts_with = "local")]
20    pub global: bool,
21
22    #[command(subcommand)]
23    pub command: Option<ChainsCommand>,
24
25    /// Chain ID to inspect (when no subcommand)
26    #[arg(value_name = "ID")]
27    pub id: Option<String>,
28}
29
30#[derive(Debug, Subcommand)]
31pub enum ChainsCommand {
32    /// Garbage collect old chains
33    #[command(name = "gc")]
34    Gc,
35
36    /// Save a chain from global to local
37    #[command(name = "save")]
38    Save {
39        /// Chain ID to save
40        id: String,
41
42        /// Name for the saved chain (default: actor-id)
43        #[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
62/// List chains
63async fn execute_list(args: &ChainsArgs, _ctx: &CommandContext) -> Result<(), CliError> {
64    let local_dir = PathBuf::from(LOCAL_CHAINS_DIR);
65    let global_dir = ChainWriter::default_dir();
66
67    let mut found_any = false;
68
69    // Check local first (unless --global specified)
70    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    // Check global (unless --local specified)
82    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
103/// Inspect a specific chain
104async 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    // Read and parse the chain file
115    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    // Parse events (simple parsing of EVENT format)
124    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            // This is likely an event type line
133            if !line.starts_with('{') && !line.contains(':') {
134                // Skip, probably body content
135            } 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    // Check for meta file
160    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
172/// Garbage collect chains
173async fn execute_gc(args: &ChainsArgs, _ctx: &CommandContext) -> Result<(), CliError> {
174    let dir = if args.local {
175        PathBuf::from(LOCAL_CHAINS_DIR)
176    } else {
177        // Default to global for gc
178        ChainWriter::default_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        // Get size before deleting
197        if let Ok(metadata) = fs::metadata(chain_path) {
198            total_size += metadata.len();
199        }
200
201        // Delete chain file
202        if let Err(e) = fs::remove_file(chain_path) {
203            eprintln!("Failed to remove {}: {}", chain_path.display(), e);
204            continue;
205        }
206
207        // Delete meta file if exists
208        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
228/// Save a chain from global to local
229async fn execute_save(id: &str, name: Option<&str>, _ctx: &CommandContext) -> Result<(), CliError> {
230    let global_dir = ChainWriter::default_dir();
231    let local_dir = PathBuf::from(LOCAL_CHAINS_DIR);
232
233    // Find the chain in global
234    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    // Create local chains directory
239    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    // Determine destination name
244    let dest_name = name.unwrap_or(id);
245    let dest_path = local_dir.join(format!("{}.chain", dest_name));
246
247    // Copy chain file
248    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    // Copy meta file if exists
253    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
264/// List all .chain files in a directory
265fn 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    // Sort by modification time (newest first)
277    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
286/// Find a chain by ID (partial match supported)
287fn find_chain(id: &str, args: &ChainsArgs) -> Result<PathBuf, CliError> {
288    let local_dir = PathBuf::from(LOCAL_CHAINS_DIR);
289    let global_dir = ChainWriter::default_dir();
290
291    // Check local first (unless --global)
292    if !args.global {
293        if let Some(path) = find_chain_in_dir(id, &local_dir)? {
294            return Ok(path);
295        }
296    }
297
298    // Check global (unless --local)
299    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
311/// Find a chain in a specific directory
312fn find_chain_in_dir(id: &str, dir: &PathBuf) -> Result<Option<PathBuf>, CliError> {
313    if !dir.exists() {
314        return Ok(None);
315    }
316
317    // Try exact match first
318    let exact_path = dir.join(format!("{}.chain", id));
319    if exact_path.exists() {
320        return Ok(Some(exact_path));
321    }
322
323    // Try partial match
324    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
342/// Print a summary of a chain file
343fn 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    // Try to get actor name from meta
352    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
371/// Format bytes as human-readable size
372fn 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}