theater_cli/commands/
events_explore.rs

1use clap::Parser;
2use std::net::SocketAddr;
3use std::str::FromStr;
4use tracing::debug;
5
6use crate::error::{CliError, CliResult};
7use crate::tui::event_explorer;
8use crate::CommandContext;
9use theater::chain::ChainEvent;
10use theater::id::TheaterId;
11
12/// Interactively explore actor events with a rich TUI interface
13#[derive(Debug, Parser)]
14pub struct ExploreArgs {
15    /// ID of the actor to explore events for
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    /// Connect to running actor for live events
24    #[arg(short, long)]
25    pub live: bool,
26
27    /// Start in follow mode (auto-scroll new events)
28    #[arg(short, long)]
29    pub follow: bool,
30
31    /// Number of events to load initially (0 for all)
32    #[arg(short = 'n', long, default_value = "1000")]
33    pub limit: usize,
34
35    /// Filter events by type (e.g., http.request, runtime.init)
36    #[arg(short = 't', long)]
37    pub event_type: Option<String>,
38
39    /// Show events from this timestamp onward (Unix timestamp or relative time like "1h", "2d")
40    #[arg(long)]
41    pub from: Option<String>,
42
43    /// Show events until this timestamp (Unix timestamp or relative time like "1h", "2d")
44    #[arg(long)]
45    pub to: Option<String>,
46
47    /// Search events for this text (in description and data)
48    #[arg(long)]
49    pub search: Option<String>,
50
51    #[arg(long, default_value = None)]
52    pub format: Option<String>,
53}
54
55/// Execute the events explore command asynchronously
56pub async fn execute_async(args: &ExploreArgs, ctx: &CommandContext) -> CliResult<()> {
57    debug!("Starting event explorer for actor: {}", args.actor_id);
58    debug!("Server address: {}", args.address);
59    debug!("Live mode: {}", args.live);
60
61    // Parse the actor ID
62    let actor_id = TheaterId::from_str(&args.actor_id).map_err(|_| CliError::InvalidInput {
63        field: "actor_id".to_string(),
64        value: args.actor_id.clone(),
65        suggestion: "Provide a valid actor ID in the correct format".to_string(),
66    })?;
67
68    // Load initial events
69    let events = load_events(args, ctx, &actor_id).await?;
70
71    // Create and configure the explorer app
72    let mut app =
73        event_explorer::EventExplorerApp::new(args.actor_id.clone(), args.live, args.follow);
74
75    // Apply initial filters if specified
76    if let Some(event_type) = &args.event_type {
77        app.set_event_type_filter(event_type.clone());
78    }
79
80    if let Some(search) = &args.search {
81        app.set_search_query(search.clone());
82    }
83
84    // Load events into the app
85    app.load_events(events);
86
87    // Launch the TUI
88    event_explorer::run_explorer(app, ctx, args.address).await?;
89
90    Ok(())
91}
92
93/// Load events from either live actor or stored data
94async fn load_events(
95    args: &ExploreArgs,
96    ctx: &CommandContext,
97    actor_id: &TheaterId,
98) -> CliResult<Vec<ChainEvent>> {
99    debug!("Loading events for actor: {}", actor_id);
100
101    // Create client and connect
102    let client = ctx.create_client();
103
104    if args.live {
105        // Try to connect to live actor first
106        match client.connect().await {
107            Ok(_) => {
108                debug!("Connected to live actor");
109                // Get events from running actor
110                client
111                    .get_actor_events(&actor_id.to_string())
112                    .await
113                    .map_err(|e| CliError::ServerError {
114                        message: format!("Failed to get live actor events: {}", e),
115                    })
116            }
117            Err(e) => {
118                debug!(
119                    "Failed to connect to live actor: {}, falling back to stored events",
120                    e
121                );
122                // Fall back to stored events
123                load_stored_events(args, ctx, actor_id).await
124            }
125        }
126    } else {
127        // Load from stored events
128        load_stored_events(args, ctx, actor_id).await
129    }
130}
131
132/// Load events from filesystem/stored data
133async fn load_stored_events(
134    args: &ExploreArgs,
135    ctx: &CommandContext,
136    actor_id: &TheaterId,
137) -> CliResult<Vec<ChainEvent>> {
138    debug!("Loading stored events for actor: {}", actor_id);
139
140    // Create a temporary EventsArgs to reuse existing filtering logic
141    let events_args = crate::commands::events::EventsArgs {
142        actor_id: actor_id.to_string(),
143        address: args.address,
144        limit: args.limit,
145        event_type: args.event_type.clone(),
146        from: args.from.clone(),
147        to: args.to.clone(),
148        search: args.search.clone(),
149        sort: "chain".to_string(),
150        reverse: false,
151        detailed: false,
152        format: args.format.clone(),
153    };
154
155    // Use existing events command logic to load and filter events
156    let client = ctx.create_client();
157    client
158        .connect()
159        .await
160        .map_err(|e| CliError::connection_failed(args.address, e))?;
161
162    let mut events = client
163        .get_actor_events(&actor_id.to_string())
164        .await
165        .map_err(|e| CliError::ServerError {
166            message: format!("Failed to get stored actor events: {}", e),
167        })?;
168
169    // Apply the same filtering logic as the events command
170    apply_filters(&mut events, &events_args)?;
171
172    Ok(events)
173}
174
175/// Apply filters to events (reused from events command)
176fn apply_filters(
177    events: &mut Vec<ChainEvent>,
178    args: &crate::commands::events::EventsArgs,
179) -> CliResult<()> {
180    // Filter by event type
181    if let Some(event_type) = &args.event_type {
182        events.retain(|e| e.event_type.contains(event_type));
183    }
184
185    // Apply text search
186    if let Some(search_text) = &args.search {
187        events.retain(|e| {
188            // Search in event type
189            if e.event_type.contains(search_text) {
190                return true;
191            }
192
193            // Search in description
194            if let Some(desc) = &e.description {
195                if desc.contains(search_text) {
196                    return true;
197                }
198            }
199
200            // Search in data if it's UTF-8 text
201            if let Ok(data_str) = std::str::from_utf8(&e.data) {
202                if data_str.contains(search_text) {
203                    return true;
204                }
205            }
206
207            false
208        });
209    }
210
211    // Note: Time filtering would go here but requires time parsing logic
212    // For now, we'll implement basic filters and add time filtering later
213
214    Ok(())
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use crate::config::Config;
221    use crate::output::OutputManager;
222
223    #[tokio::test]
224    async fn test_explore_command_invalid_actor_id() {
225        let args = ExploreArgs {
226            actor_id: "invalid-id".to_string(),
227            address: "127.0.0.1:9000".parse().unwrap(),
228            live: false,
229            follow: false,
230            limit: 100,
231            event_type: None,
232            from: None,
233            to: None,
234            search: None,
235            format: None,
236        };
237
238        let config = Config::default();
239        let output = OutputManager::new(config.output.clone());
240        let ctx = CommandContext {
241            config,
242            output,
243            verbose: false,
244            json: false,
245        };
246
247        let result = execute_async(&args, &ctx).await;
248        assert!(result.is_err());
249        if let Err(CliError::InvalidInput { field, .. }) = result {
250            assert_eq!(field, "actor_id");
251        } else {
252            panic!("Expected InvalidInput error");
253        }
254    }
255}