oxur_cli/repl/
runner.rs

1//! Shared REPL loop runner
2//!
3//! Extracts common REPL loop logic used by both interactive and connect modes.
4//! Provides `ReplRunner` struct and `ReplClientAdapter` trait for client abstraction.
5
6use crate::repl::help::HelpSystem;
7use crate::repl::terminal::ReplTerminal;
8use anyhow::Result;
9use async_trait::async_trait;
10use oxur_repl::protocol::{
11    MessageId, Operation, OperationResult, ReplMode, Request, Response, SessionId,
12};
13use std::sync::atomic::{AtomicU64, Ordering};
14
15/// Trait for REPL client adapters
16///
17/// Abstracts the transport layer for different REPL client modes:
18/// - `InProcessAdapter`: Channel-based for interactive mode
19/// - `TcpAdapter`: TCP transport for connect mode
20#[async_trait]
21pub trait ReplClientAdapter: Send {
22    /// Send an eval request to the server
23    async fn send_eval(&mut self, request: Request) -> Result<()>;
24
25    /// Receive a response from the server
26    async fn recv_response(&mut self) -> Result<Response>;
27
28    /// Close the client connection
29    async fn close(&mut self) -> Result<()>;
30
31    /// Handle special commands (e.g., stats in interactive mode)
32    ///
33    /// Returns `Some(output)` if the command was handled, `None` to continue normal eval.
34    /// Default implementation returns `None` (no special handling).
35    ///
36    /// Made async to support protocol-level stats requests in remote mode.
37    async fn handle_special_command(
38        &mut self,
39        _input: &str,
40        _color_enabled: bool,
41    ) -> Option<String> {
42        None
43    }
44
45    /// Record a usage metric for the given command type
46    ///
47    /// Default implementation does nothing (no-op).
48    /// Interactive mode will override this to track command frequency.
49    fn record_usage(&mut self, _command_type: oxur_repl::metrics::CommandType) {}
50
51    /// Create a new session
52    ///
53    /// Returns the new session ID on success.
54    /// Default implementation returns an error.
55    async fn create_session(&mut self, _name: Option<String>) -> Result<SessionId> {
56        Err(anyhow::anyhow!("Session creation not supported in this mode"))
57    }
58
59    /// Switch to a different session
60    ///
61    /// Returns Ok if the session exists and switch was successful.
62    /// Default implementation returns an error.
63    async fn switch_session(&mut self, _session_id: SessionId) -> Result<()> {
64        Err(anyhow::anyhow!("Session switching not supported in this mode"))
65    }
66
67    /// Get current session ID
68    ///
69    /// Returns the currently active session ID.
70    fn current_session(&self) -> &SessionId;
71
72    /// Close a session
73    ///
74    /// Closes the specified session. If None, closes current session.
75    /// Default implementation returns an error.
76    async fn close_session(&mut self, _session_id: Option<SessionId>) -> Result<()> {
77        Err(anyhow::anyhow!("Session closing not supported in this mode"))
78    }
79}
80
81/// Shared REPL loop runner
82///
83/// Encapsulates the main REPL loop logic shared between interactive and connect modes.
84/// Uses a `ReplClientAdapter` for transport-specific behavior.
85pub struct ReplRunner {
86    terminal: ReplTerminal,
87    session_id: SessionId,
88    msg_counter: AtomicU64,
89    metadata: Option<oxur_repl::metadata::SystemMetadata>,
90}
91
92impl ReplRunner {
93    /// Create a new REPL runner
94    pub fn new(terminal: ReplTerminal, session_id: SessionId) -> Self {
95        Self { terminal, session_id, msg_counter: AtomicU64::new(1), metadata: None }
96    }
97
98    /// Get the next message ID
99    fn next_message_id(&self) -> MessageId {
100        MessageId::new(self.msg_counter.fetch_add(1, Ordering::SeqCst))
101    }
102
103    /// Get a reference to the terminal
104    pub fn terminal(&self) -> &ReplTerminal {
105        &self.terminal
106    }
107
108    /// Print the welcome banner with system metadata
109    pub fn print_banner(&mut self, metadata: &oxur_repl::metadata::SystemMetadata) {
110        self.metadata = Some(metadata.clone());
111        self.terminal.print_banner(metadata);
112    }
113
114    /// Run the main REPL loop
115    ///
116    /// Processes user input, sends requests to the server via the adapter,
117    /// and displays results. Handles help commands, quit commands, and
118    /// special adapter-specific commands.
119    pub async fn run<C: ReplClientAdapter>(&mut self, client: &mut C) -> Result<()> {
120        loop {
121            // Read input from user
122            let line = match self.terminal.read_line_default() {
123                Ok(Some(line)) => line,
124                Ok(None) => {
125                    // Ctrl-C - just print newline and continue
126                    println!();
127                    continue;
128                }
129                Err(e) => {
130                    // Check if it's an EOF error (Ctrl-D)
131                    if e.to_string().contains("EOF") {
132                        break;
133                    }
134                    self.terminal.print_error(&format!("Input error: {}", e));
135                    break;
136                }
137            };
138
139            // Skip empty lines
140            let trimmed = line.trim();
141            if trimmed.is_empty() {
142                continue;
143            }
144
145            // Check for quit commands
146            if Self::is_quit_command(trimmed) {
147                break;
148            }
149
150            // Check for help commands
151            let color_enabled = self.terminal.config().color_enabled;
152            if let Some(help_output) = parse_help_command(trimmed, color_enabled) {
153                client.record_usage(oxur_repl::metrics::CommandType::Help);
154                self.terminal.print_help(&help_output);
155                continue;
156            }
157
158            // Check for clear command
159            if trimmed == "(clear)" {
160                client.record_usage(oxur_repl::metrics::CommandType::Clear);
161                if let Err(e) = self.terminal.clear_screen() {
162                    self.terminal.print_error(&format!("Failed to clear screen: {}", e));
163                }
164                continue;
165            }
166
167            // Check for banner command
168            if trimmed == "(banner)" {
169                client.record_usage(oxur_repl::metrics::CommandType::Banner);
170                if let Some(ref metadata) = self.metadata {
171                    self.terminal.print_banner(metadata);
172                } else {
173                    self.terminal.print_error("No metadata available");
174                }
175                continue;
176            }
177
178            // Check for session management commands
179            if let Some(output) = self.handle_session_command(trimmed, client).await {
180                self.terminal.print_help(&output);
181                continue;
182            }
183
184            // Check for adapter-specific special commands (e.g., stats)
185            if let Some(output) = client.handle_special_command(trimmed, color_enabled).await {
186                self.terminal.print_help(&output);
187                continue;
188            }
189
190            // Track eval command
191            client.record_usage(oxur_repl::metrics::CommandType::Eval);
192
193            // Create eval request
194            let eval_req = Request {
195                id: self.next_message_id(),
196                session_id: self.session_id.clone(),
197                operation: Operation::Eval { code: trimmed.to_string(), mode: ReplMode::Lisp },
198            };
199
200            // Send request to server
201            if let Err(e) = client.send_eval(eval_req).await {
202                self.terminal.print_error(&format!("Failed to send request: {}", e));
203                continue;
204            }
205
206            // Receive response
207            let response = match client.recv_response().await {
208                Ok(r) => r,
209                Err(e) => {
210                    self.terminal.print_error(&format!("Failed to receive response: {}", e));
211                    continue;
212                }
213            };
214
215            // Display result
216            self.display_result(&response.result);
217        }
218
219        Ok(())
220    }
221
222    /// Finish the REPL session
223    ///
224    /// Saves history, prints goodbye, and closes the session on the server.
225    pub async fn finish<C: ReplClientAdapter>(&mut self, client: &mut C) -> Result<()> {
226        // Save history before exit
227        if let Err(e) = self.terminal.save_history() {
228            eprintln!("Warning: Failed to save command history: {}", e);
229        }
230
231        self.terminal.print_goodbye();
232
233        // Close session
234        let close_req = Request {
235            id: self.next_message_id(),
236            session_id: self.session_id.clone(),
237            operation: Operation::Close,
238        };
239
240        let _ = client.send_eval(close_req).await;
241        let _ = client.recv_response().await;
242        let _ = client.close().await;
243
244        Ok(())
245    }
246
247    /// Handle session management commands
248    ///
249    /// Returns Some(output) if the command was handled, None otherwise.
250    async fn handle_session_command<C: ReplClientAdapter>(
251        &mut self,
252        input: &str,
253        client: &mut C,
254    ) -> Option<String> {
255        // (current-session)
256        if input == "(current-session)" {
257            let session_id = client.current_session();
258            return Some(format!("Current session: {}", session_id));
259        }
260
261        // (new-session)
262        if input == "(new-session)" {
263            match client.create_session(None).await {
264                Ok(new_id) => {
265                    self.session_id = new_id.clone();
266                    return Some(format!("Created and switched to new session: {}", new_id));
267                }
268                Err(e) => return Some(format!("Failed to create session: {}", e)),
269            }
270        }
271
272        // (new-session "name")
273        if input.starts_with("(new-session ") && input.ends_with(')') {
274            let name_part = &input[13..input.len() - 1].trim();
275            // Remove quotes if present
276            let name = if name_part.starts_with('"') && name_part.ends_with('"') {
277                &name_part[1..name_part.len() - 1]
278            } else {
279                name_part
280            };
281
282            match client.create_session(Some(name.to_string())).await {
283                Ok(new_id) => {
284                    self.session_id = new_id.clone();
285                    return Some(format!(
286                        "Created and switched to new session: {} ({})",
287                        name, new_id
288                    ));
289                }
290                Err(e) => return Some(format!("Failed to create session: {}", e)),
291            }
292        }
293
294        // (switch-session <id>)
295        if input.starts_with("(switch-session ") && input.ends_with(')') {
296            let id_part = &input[16..input.len() - 1].trim();
297            let session_id = SessionId::new(*id_part);
298
299            match client.switch_session(session_id.clone()).await {
300                Ok(()) => {
301                    self.session_id = session_id.clone();
302                    return Some(format!("Switched to session: {}", session_id));
303                }
304                Err(e) => return Some(format!("Failed to switch session: {}", e)),
305            }
306        }
307
308        // (close-session)
309        if input == "(close-session)" {
310            match client.close_session(None).await {
311                Ok(()) => return Some("Closed current session".to_string()),
312                Err(e) => return Some(format!("Failed to close session: {}", e)),
313            }
314        }
315
316        // (close-session <id>)
317        if input.starts_with("(close-session ") && input.ends_with(')') {
318            let id_part = &input[15..input.len() - 1].trim();
319            let session_id = SessionId::new(*id_part);
320
321            match client.close_session(Some(session_id.clone())).await {
322                Ok(()) => return Some(format!("Closed session: {}", session_id)),
323                Err(e) => return Some(format!("Failed to close session: {}", e)),
324            }
325        }
326
327        None
328    }
329
330    /// Check if input is a quit command
331    fn is_quit_command(input: &str) -> bool {
332        matches!(input, "(quit)" | "(q)" | "(exit)")
333    }
334
335    /// Display an operation result
336    fn display_result(&self, result: &OperationResult) {
337        match result {
338            OperationResult::Success { value, stdout, stderr, .. } => {
339                // Print stdout if any
340                if let Some(out) = stdout {
341                    if !out.is_empty() {
342                        self.terminal.print_output(out);
343                    }
344                }
345
346                // Print return value if any
347                if let Some(val) = value {
348                    if !val.is_empty() {
349                        self.terminal.print_result(val);
350                    }
351                }
352
353                // Print stderr if any
354                if let Some(err) = stderr {
355                    if !err.is_empty() {
356                        eprintln!("{}", err);
357                    }
358                }
359            }
360            OperationResult::Error { error, stdout, stderr } => {
361                // Print any stdout before the error
362                if let Some(out) = stdout {
363                    if !out.is_empty() {
364                        self.terminal.print_output(out);
365                    }
366                }
367
368                // Print the error message
369                self.terminal.print_error(&error.message);
370
371                // Print stderr if any
372                if let Some(err) = stderr {
373                    if !err.is_empty() {
374                        eprintln!("{}", err);
375                    }
376                }
377            }
378            OperationResult::Sessions { .. } | OperationResult::HistoryEntries { .. } => {
379                // These don't produce output in interactive eval mode
380            }
381            _ => {
382                // Handle any future OperationResult variants
383            }
384        }
385    }
386}
387
388/// Parse help commands and return formatted help output
389///
390/// Recognizes:
391/// - `(help)` - Returns overview help
392/// - `(help <topic>)` - Returns topic-specific help or error message
393///
394/// Returns `None` if input is not a help command.
395pub fn parse_help_command(input: &str, color_enabled: bool) -> Option<String> {
396    let help_system = HelpSystem::new(color_enabled);
397
398    if input == "(help)" {
399        return Some(help_system.show_overview());
400    }
401
402    // Parse (help <topic>)
403    if input.starts_with("(help ") && input.ends_with(')') {
404        let topic = &input[6..input.len() - 1].trim();
405        return help_system.show_topic(topic).or_else(|| {
406            Some(format!("Unknown help topic: {}. Try (help) for available topics.", topic))
407        });
408    }
409
410    None
411}