entrenar_shell/
repl.rs

1//! REPL (Read-Eval-Print Loop) implementation.
2
3use crate::commands::{execute, parse, Command};
4use crate::state::SessionState;
5use entrenar_common::{cli::styles, EntrenarError, Result};
6use rustyline::error::ReadlineError;
7use rustyline::DefaultEditor;
8use std::path::PathBuf;
9
10/// Interactive REPL for entrenar-shell.
11pub struct Repl {
12    editor: DefaultEditor,
13    state: SessionState,
14    history_path: Option<PathBuf>,
15}
16
17impl Repl {
18    /// Create a new REPL instance.
19    pub fn new() -> Result<Self> {
20        let editor = DefaultEditor::new().map_err(|e| EntrenarError::Internal {
21            message: format!("Failed to create editor: {e}"),
22        })?;
23
24        let history_path = dirs::data_dir().map(|p| p.join("entrenar").join("shell_history"));
25
26        let mut repl = Self {
27            editor,
28            state: SessionState::new(),
29            history_path,
30        };
31
32        // Load history if available
33        if let Some(ref path) = repl.history_path {
34            let _ = repl.editor.load_history(path);
35        }
36
37        Ok(repl)
38    }
39
40    /// Create a REPL with pre-configured state.
41    pub fn with_state(state: SessionState) -> Result<Self> {
42        let mut repl = Self::new()?;
43        repl.state = state;
44        Ok(repl)
45    }
46
47    /// Run the REPL main loop.
48    pub fn run(&mut self) -> Result<()> {
49        self.print_banner();
50
51        loop {
52            let prompt = self.format_prompt();
53
54            match self.editor.readline(&prompt) {
55                Ok(line) => {
56                    let line = line.trim();
57                    if line.is_empty() {
58                        continue;
59                    }
60
61                    // Add to readline history
62                    let _ = self.editor.add_history_entry(line);
63
64                    // Parse and execute
65                    match parse(line) {
66                        Ok(cmd) => {
67                            if matches!(cmd, Command::Quit) {
68                                self.save_state();
69                                println!("{}", styles::info("Session saved. Goodbye!"));
70                                break;
71                            }
72
73                            if matches!(cmd, Command::Clear) {
74                                print!("\x1B[2J\x1B[1;1H");
75                                continue;
76                            }
77
78                            match execute(&cmd, &mut self.state) {
79                                Ok(output) => {
80                                    if !output.is_empty() {
81                                        println!("{output}");
82                                    }
83                                }
84                                Err(e) => {
85                                    println!("{}", styles::error(&e.to_string()));
86                                }
87                            }
88                        }
89                        Err(e) => {
90                            println!("{}", styles::error(&e.to_string()));
91                        }
92                    }
93                }
94                Err(ReadlineError::Interrupted) => {
95                    println!("{}", styles::warning("Use 'quit' or Ctrl-D to exit"));
96                }
97                Err(ReadlineError::Eof) => {
98                    self.save_state();
99                    println!("\n{}", styles::info("Session saved. Goodbye!"));
100                    break;
101                }
102                Err(e) => {
103                    println!("{}", styles::error(&format!("Error: {e}")));
104                }
105            }
106        }
107
108        Ok(())
109    }
110
111    fn print_banner(&self) {
112        println!("{}", styles::header("Entrenar Shell v0.1.0"));
113        println!("Interactive Distillation Environment");
114        println!("Type 'help' for commands, 'quit' to exit.\n");
115    }
116
117    fn format_prompt(&self) -> String {
118        let model_count = self.state.loaded_models().len();
119        if model_count > 0 {
120            format!("entrenar ({model_count} models)> ")
121        } else {
122            "entrenar> ".to_string()
123        }
124    }
125
126    fn save_state(&mut self) {
127        // Save readline history
128        if let Some(ref path) = self.history_path {
129            if let Some(parent) = path.parent() {
130                let _ = std::fs::create_dir_all(parent);
131            }
132            let _ = self.editor.save_history(path);
133        }
134
135        // Save session state
136        if self.state.preferences().auto_save_history {
137            if let Some(data_dir) = dirs::data_dir() {
138                let state_path = data_dir.join("entrenar").join("session.json");
139                if let Some(parent) = state_path.parent() {
140                    let _ = std::fs::create_dir_all(parent);
141                }
142                let _ = self.state.save(&state_path);
143            }
144        }
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn test_repl_creation() {
154        // REPL creation should succeed
155        let repl = Repl::new();
156        assert!(repl.is_ok());
157    }
158
159    #[test]
160    fn test_repl_with_state() {
161        let mut state = SessionState::new();
162        state.preferences_mut().default_batch_size = 64;
163
164        let repl = Repl::with_state(state).unwrap();
165        assert_eq!(repl.state.preferences().default_batch_size, 64);
166    }
167}