theater_cli/commands/
events.rs

1use clap::Parser;
2use std::net::SocketAddr;
3use std::str::FromStr;
4use tracing::debug;
5
6use crate::error::{CliError, CliResult};
7use crate::output::formatters::ActorEvents;
8use crate::CommandContext;
9use theater::chain::ChainEvent;
10use theater::id::TheaterId;
11
12/// Get events for an actor (falls back to filesystem if actor is not running)
13#[derive(Debug, Parser)]
14pub struct EventsArgs {
15    /// ID of the actor to get events from
16    #[arg(required = true)]
17    pub actor_id: String,
18
19    /// Address of the theater server
20    #[arg(short, long, default_value = "127.0.0.1:9000")]
21    pub address: SocketAddr,
22
23    /// Number of events to show (0 for all)
24    #[arg(short, long, default_value = "0")]
25    pub limit: usize,
26
27    /// Filter events by type (e.g., http.request, runtime.init)
28    #[arg(short = 't', long)]
29    pub event_type: Option<String>,
30
31    /// Show events from this timestamp onward (Unix timestamp or relative time like "1h", "2d")
32    #[arg(long)]
33    pub from: Option<String>,
34
35    /// Show events until this timestamp (Unix timestamp or relative time like "1h", "2d")
36    #[arg(long)]
37    pub to: Option<String>,
38
39    /// Search events for this text (in description and data)
40    #[arg(long)]
41    pub search: Option<String>,
42
43    /// Sort events (chain, time, type, size)
44    #[arg(short, long, default_value = "chain")]
45    pub sort: String,
46
47    /// Reverse the sort order
48    #[arg(short = 'r', long)]
49    pub reverse: bool,
50
51    /// Show detailed event information
52    #[arg(short = 'd', long)]
53    pub detailed: bool,
54
55    #[arg(long, short = 'f', default_value = "pretty")]
56    pub format: Option<String>,
57}
58
59/// Execute the events command asynchronously with modern patterns
60pub async fn execute_async(args: &EventsArgs, ctx: &CommandContext) -> CliResult<()> {
61    debug!("Getting events for actor: {}", args.actor_id);
62    debug!("Connecting to server at: {}", args.address);
63
64    // Parse the actor ID
65    let actor_id = TheaterId::from_str(&args.actor_id).map_err(|_| CliError::InvalidInput {
66        field: "actor_id".to_string(),
67        value: args.actor_id.clone(),
68        suggestion: "Provide a valid actor ID in the correct format".to_string(),
69    })?;
70
71    // Create client and connect
72    let client = ctx.create_client();
73    client
74        .connect()
75        .await
76        .map_err(|e| CliError::connection_failed(args.address, e))?;
77
78    // Get the actor events
79    let mut events = client
80        .get_actor_events(&actor_id.to_string())
81        .await
82        .map_err(|e| CliError::ServerError {
83            message: format!("Failed to get actor events: {}", e),
84        })?;
85
86    // Apply filters
87    apply_filters(&mut events, args)?;
88
89    // Apply sorting
90    apply_sorting(&mut events, &args.sort, args.reverse)?;
91
92    // Limit the number of events if requested
93    if args.limit > 0 && events.len() > args.limit {
94        events = events.into_iter().take(args.limit).collect();
95    }
96
97    // Create formatted output
98    let actor_events = ActorEvents {
99        actor_id: actor_id.to_string(),
100        events,
101    };
102
103    // Output using the configured format
104    let format = if let Some(fmt) = &args.format {
105        fmt.clone()
106    } else if ctx.json {
107        "json".to_string()
108    } else {
109        "pretty".to_string()
110    };
111    ctx.output.output(&actor_events, Some(&format))?;
112
113    Ok(())
114}
115
116/// Apply various filters to the events
117fn apply_filters(events: &mut Vec<ChainEvent>, args: &EventsArgs) -> CliResult<()> {
118    // Filter by event type
119    if let Some(event_type) = &args.event_type {
120        events.retain(|e| e.event_type.contains(event_type));
121    }
122
123    // Parse and apply timestamp filters
124    if let Some(from_str) = &args.from {
125        let from_time = parse_time_spec(from_str)?;
126        events.retain(|e| e.timestamp >= from_time);
127    }
128
129    if let Some(to_str) = &args.to {
130        let to_time = parse_time_spec(to_str)?;
131        events.retain(|e| e.timestamp <= to_time);
132    }
133
134    // Apply text search
135    if let Some(search_text) = &args.search {
136        events.retain(|e| {
137            // Search in event type
138            if e.event_type.contains(search_text) {
139                return true;
140            }
141
142            // Search in description
143            if let Some(desc) = &e.description {
144                if desc.contains(search_text) {
145                    return true;
146                }
147            }
148
149            // Search in data if it's UTF-8 text
150            if let Ok(data_str) = std::str::from_utf8(&e.data) {
151                if data_str.contains(search_text) {
152                    return true;
153                }
154            }
155
156            false
157        });
158    }
159
160    Ok(())
161}
162
163/// Apply sorting to the events
164fn apply_sorting(events: &mut Vec<ChainEvent>, sort_type: &str, reverse: bool) -> CliResult<()> {
165    match sort_type {
166        "chain" => {
167            let ordered_events = order_events_by_chain(events, reverse);
168            *events = ordered_events;
169        }
170        "time" => {
171            if reverse {
172                events.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
173            } else {
174                events.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
175            }
176        }
177        "type" => {
178            if reverse {
179                events.sort_by(|a, b| b.event_type.cmp(&a.event_type));
180            } else {
181                events.sort_by(|a, b| a.event_type.cmp(&b.event_type));
182            }
183        }
184        "size" => {
185            if reverse {
186                events.sort_by(|a, b| a.data.len().cmp(&b.data.len()));
187            } else {
188                events.sort_by(|a, b| b.data.len().cmp(&a.data.len()));
189            }
190        }
191        _ => {
192            return Err(CliError::InvalidInput {
193                field: "sort".to_string(),
194                value: sort_type.to_string(),
195                suggestion: "Use one of: chain, time, type, size".to_string(),
196            });
197        }
198    }
199    Ok(())
200}
201
202// Helper function to parse time specifications like "1h", "2d", or unix timestamps
203fn parse_time_spec(spec: &str) -> CliResult<u64> {
204    // Try parsing as a simple timestamp first
205    if let Ok(timestamp) = spec.parse::<u64>() {
206        return Ok(timestamp);
207    }
208
209    // Try parsing as a relative time
210    let now = std::time::SystemTime::now()
211        .duration_since(std::time::UNIX_EPOCH)
212        .unwrap()
213        .as_secs();
214
215    let (amount_str, unit) = spec.chars().partition::<String, _>(|c| c.is_ascii_digit());
216    let amount = amount_str
217        .parse::<u64>()
218        .map_err(|_| CliError::InvalidInput {
219            field: "time".to_string(),
220            value: spec.to_string(),
221            suggestion: "Use format like '1h', '2d', '30m', or a unix timestamp".to_string(),
222        })?;
223
224    match unit.as_str() {
225        "s" => Ok(now - amount),
226        "m" => Ok(now - amount * 60),
227        "h" => Ok(now - amount * 3600),
228        "d" => Ok(now - amount * 86400),
229        "w" => Ok(now - amount * 604800),
230        _ => Err(CliError::InvalidInput {
231            field: "time_unit".to_string(),
232            value: unit,
233            suggestion: "Use time units: s (seconds), m (minutes), h (hours), d (days), w (weeks)"
234                .to_string(),
235        }),
236    }
237}
238
239// Order events by their chain structure (parent-child relationships)
240fn order_events_by_chain(events: &[ChainEvent], reverse: bool) -> Vec<ChainEvent> {
241    if events.is_empty() {
242        return Vec::new();
243    }
244
245    use std::collections::HashMap;
246
247    // Find the root event (the one without a parent)
248    let root = events.iter().find(|e| e.parent_hash.is_none());
249
250    // If no root is found, return events as-is
251    let root = match root {
252        Some(r) => r,
253        None => return events.to_vec(),
254    };
255
256    // Create a map from parent hash to children
257    let mut parent_to_children: HashMap<Vec<u8>, Vec<&ChainEvent>> = HashMap::new();
258    for event in events {
259        if let Some(parent_hash) = &event.parent_hash {
260            parent_to_children
261                .entry(parent_hash.clone())
262                .or_insert_with(Vec::new)
263                .push(event);
264        }
265    }
266
267    // Function to recursively collect events in order
268    let mut ordered_events = Vec::new();
269
270    fn traverse_chain(
271        event: &ChainEvent,
272        parent_to_children: &HashMap<Vec<u8>, Vec<&ChainEvent>>,
273        ordered_events: &mut Vec<ChainEvent>,
274    ) {
275        ordered_events.push(event.clone());
276
277        if let Some(children) = parent_to_children.get(&event.hash) {
278            for &child in children {
279                traverse_chain(child, parent_to_children, ordered_events);
280            }
281        }
282    }
283
284    // Start traversal from the root
285    traverse_chain(root, &parent_to_children, &mut ordered_events);
286
287    // Reverse if requested
288    if reverse {
289        ordered_events.reverse();
290    }
291
292    ordered_events
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use crate::config::Config;
299    use crate::output::OutputManager;
300
301    #[tokio::test]
302    async fn test_events_command_invalid_actor_id() {
303        let args = EventsArgs {
304            actor_id: "invalid-id".to_string(),
305            address: "127.0.0.1:9000".parse().unwrap(),
306            limit: 0,
307            event_type: None,
308            from: None,
309            to: None,
310            search: None,
311            sort: "chain".to_string(),
312            reverse: false,
313            detailed: false,
314            format: None,
315        };
316        let config = Config::default();
317        let output = OutputManager::new(config.output.clone());
318
319        let ctx = CommandContext {
320            config,
321            output,
322            verbose: false,
323            json: false,
324        };
325
326        let result = execute_async(&args, &ctx).await;
327        assert!(result.is_err());
328        if let Err(CliError::InvalidInput { field, .. }) = result {
329            assert_eq!(field, "actor_id");
330        } else {
331            panic!("Expected InvalidInput error");
332        }
333    }
334
335    #[test]
336    fn test_parse_time_spec() {
337        // Test unix timestamp
338        assert_eq!(parse_time_spec("1000").unwrap(), 1000);
339
340        // Test relative times (will be based on current time, so just check they don't error)
341        assert!(parse_time_spec("1h").is_ok());
342        assert!(parse_time_spec("2d").is_ok());
343        assert!(parse_time_spec("30m").is_ok());
344
345        // Test invalid formats
346        assert!(parse_time_spec("invalid").is_err());
347        assert!(parse_time_spec("1x").is_err());
348    }
349}