1use crate::config::{paths, EditMode, HistoryConfig, TerminalConfig};
7use anyhow::{Context, Result};
8use crossterm::{execute, terminal};
9use reedline::{
10 default_emacs_keybindings, default_vi_insert_keybindings, default_vi_normal_keybindings,
11 ColumnarMenu, Emacs, FileBackedHistory, KeyCode, KeyModifiers, Keybindings, MenuBuilder,
12 Reedline, ReedlineEvent, ReedlineMenu, Signal, Vi,
13};
14use std::io;
15use std::path::PathBuf;
16
17use crate::repl::completer::OxurCompleter;
18use crate::repl::oxur_prompt::OxurPrompt;
19use crate::repl::pager;
20use crate::repl::sexp_highlighter::SExpHighlighter;
21use crate::repl::sexp_validator::SExpValidator;
22
23fn format_version(version_str: &str) -> String {
28 let parts: Vec<&str> = version_str.split_whitespace().collect();
31 if parts.len() > 1 {
32 parts[1..].join(" ")
33 } else {
34 version_str.to_string()
35 }
36}
37
38fn substitute_banner_versions(
45 banner: &str,
46 metadata: &oxur_repl::metadata::SystemMetadata,
47) -> String {
48 banner
49 .replace("N.N.N", &metadata.oxur_version)
50 .replace("M.M.M", &format_version(&metadata.rust_version))
51 .replace("L.L.L", &format_version(&metadata.cargo_version))
52}
53
54fn add_completion_keybinding(keybindings: &mut Keybindings) {
56 keybindings.add_binding(
57 KeyModifiers::NONE,
58 KeyCode::Tab,
59 ReedlineEvent::UntilFound(vec![
60 ReedlineEvent::Menu("completion_menu".to_string()),
61 ReedlineEvent::MenuNext,
62 ]),
63 );
64}
65
66pub struct ReplTerminal {
68 editor: Reedline,
69 #[allow(dead_code)] history_path: PathBuf,
71 terminal_config: TerminalConfig,
72}
73
74impl ReplTerminal {
75 pub fn with_config(
86 terminal_config: TerminalConfig,
87 history_config: HistoryConfig,
88 ) -> Result<Self> {
89 let edit_mode: Box<dyn reedline::EditMode> = match terminal_config.edit_mode {
91 EditMode::Emacs => {
92 let mut keybindings = default_emacs_keybindings();
93 add_completion_keybinding(&mut keybindings);
94 Box::new(Emacs::new(keybindings))
95 }
96 EditMode::Vi => {
97 let mut insert_keybindings = default_vi_insert_keybindings();
98 let mut normal_keybindings = default_vi_normal_keybindings();
99 add_completion_keybinding(&mut insert_keybindings);
100 add_completion_keybinding(&mut normal_keybindings);
101 Box::new(Vi::new(insert_keybindings, normal_keybindings))
102 }
103 };
104
105 let history_path = history_config.path.unwrap_or_else(paths::default_history_path);
107
108 let history_path_for_backend = if history_config.enabled {
112 history_path.clone()
113 } else {
114 std::env::temp_dir().join("oxur-repl-temp-history")
116 };
117
118 let history = Box::new(
119 FileBackedHistory::with_file(
120 history_config.max_size.unwrap_or(10000),
121 history_path_for_backend,
122 )
123 .context("Failed to create history backend")?,
124 );
125
126 let completion_menu = ColumnarMenu::default()
128 .with_name("completion_menu")
129 .with_columns(4)
130 .with_column_width(Some(20))
131 .with_column_padding(2);
132
133 let editor = Reedline::create()
135 .with_history(history)
136 .with_edit_mode(edit_mode)
137 .with_highlighter(Box::new(SExpHighlighter::new(terminal_config.color_enabled)))
138 .with_validator(Box::new(SExpValidator::new()))
139 .with_completer(Box::new(OxurCompleter::new()))
140 .with_menu(ReedlineMenu::EngineCompleter(Box::new(completion_menu)));
141
142 Ok(Self { editor, history_path, terminal_config })
143 }
144
145 pub fn read_line(&mut self, prompt: &str) -> Result<Option<String>> {
152 let oxur_prompt = OxurPrompt::new(
153 prompt.to_string(),
154 self.terminal_config.formatted_continuation_prompt(),
155 );
156
157 match self.editor.read_line(&oxur_prompt) {
158 Ok(Signal::Success(line)) => Ok(Some(line)),
159 Ok(Signal::CtrlC) => Ok(None),
160 Ok(Signal::CtrlD) => Err(anyhow::anyhow!("EOF")),
161 Err(e) => Err(anyhow::anyhow!("Input error: {}", e)),
162 }
163 }
164
165 pub fn read_line_default(&mut self) -> Result<Option<String>> {
167 let prompt = self.prompt();
168 self.read_line(&prompt)
169 }
170
171 pub fn prompt(&self) -> String {
173 self.terminal_config.formatted_prompt()
174 }
175
176 #[allow(dead_code)]
178 pub fn continuation_prompt(&self) -> String {
179 self.terminal_config.formatted_continuation_prompt()
180 }
181
182 pub fn save_history(&mut self) -> Result<()> {
187 Ok(())
189 }
190
191 #[allow(dead_code)]
193 pub fn color_enabled(&self) -> bool {
194 self.terminal_config.color_enabled
195 }
196
197 pub fn print_error(&self, msg: &str) {
199 if self.terminal_config.color_enabled {
200 eprintln!("\x1b[31mError:\x1b[0m {}", msg);
201 } else {
202 eprintln!("Error: {}", msg);
203 }
204 }
205
206 pub fn print_result(&self, value: &str) {
208 if self.terminal_config.color_enabled {
209 println!("\x1b[36m{}\x1b[0m", value);
210 } else {
211 println!("{}", value);
212 }
213 }
214
215 pub fn print_output(&self, output: &str) {
217 print!("{}", output);
218 }
219
220 pub fn print_help(&self, content: &str) {
224 if let Err(e) = pager::page_text(content) {
225 eprintln!("Warning: Pager failed ({}), printing directly", e);
227 println!("{}", content);
228 }
229 }
230
231 pub fn print_banner(&self, metadata: &oxur_repl::metadata::SystemMetadata) {
233 if let Some(ref banner) = self.terminal_config.banner {
234 let banner_with_versions = substitute_banner_versions(banner, metadata);
236 println!("{}", banner_with_versions);
237 } else {
238 if self.terminal_config.color_enabled {
240 println!(
241 "\x1b[1mOxur REPL\x1b[0m v{} | \x1b[90mRust: {} | Cargo: {}\x1b[0m",
242 metadata.oxur_version,
243 format_version(&metadata.rust_version),
244 format_version(&metadata.cargo_version)
245 );
246 } else {
247 println!(
248 "Oxur REPL v{} | Rust: {} | Cargo: {}",
249 metadata.oxur_version,
250 format_version(&metadata.rust_version),
251 format_version(&metadata.cargo_version)
252 );
253 }
254 println!("Type (help) for assistance, Ctrl-D to exit.");
255 }
256 println!();
257 }
258
259 pub fn print_goodbye(&self) {
261 println!();
262 if self.terminal_config.color_enabled {
263 println!("\x1b[33mGoodbye!\x1b[0m");
264 } else {
265 println!("Goodbye!");
266 }
267 }
268
269 pub fn clear_screen(&self) -> Result<()> {
271 execute!(io::stdout(), terminal::Clear(terminal::ClearType::All))?;
272 execute!(io::stdout(), crossterm::cursor::MoveTo(0, 0))?;
273 Ok(())
274 }
275
276 pub fn config(&self) -> &TerminalConfig {
278 &self.terminal_config
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285
286 #[test]
287 fn test_default_history_path() {
288 let path = paths::default_history_path();
289 assert!(path.ends_with("repl_history"));
290 }
291
292 #[test]
293 fn test_terminal_config_prompt() {
294 let config = TerminalConfig::builder().prompt("test> ").color(false).build();
295 assert_eq!(config.formatted_prompt(), "test> ");
296 }
297
298 #[test]
299 #[serial_test::serial]
300 fn test_terminal_config_colored_prompt() {
301 colored::control::set_override(true);
303
304 let config = TerminalConfig::builder().prompt("test> ").color(true).build();
306 let test_prompt = config.formatted_prompt();
307 assert_ne!(test_prompt, "test> ");
308 assert!(test_prompt.contains("\x1b["));
309 assert!(test_prompt.contains("test> "));
310
311 let oxur_config = TerminalConfig::builder().prompt("oxur> ").color(true).build();
313 let oxur_prompt = oxur_config.formatted_prompt();
314 assert_ne!(oxur_prompt, "oxur> ");
316 assert!(oxur_prompt.contains("\x1b["));
318 assert!(oxur_prompt.contains("o"));
320 assert!(oxur_prompt.contains("x"));
321 assert!(oxur_prompt.contains("u"));
322 assert!(oxur_prompt.contains("r"));
323
324 colored::control::unset_override();
326 }
327
328 #[test]
329 fn test_continuation_prompt() {
330 let config = TerminalConfig::builder().continuation_prompt("... ").color(false).build();
331 assert_eq!(config.formatted_continuation_prompt(), "... ");
332 }
333
334 #[test]
335 fn test_custom_banner() {
336 let config = TerminalConfig::builder().banner("Custom Welcome!").build();
337 assert_eq!(config.banner, Some("Custom Welcome!".to_string()));
338 }
339
340 #[test]
341 fn test_format_version_rustc() {
342 let version = "rustc 1.75.0 (82e1608df 2023-12-21)";
343 assert_eq!(format_version(version), "1.75.0 (82e1608df 2023-12-21)");
344 }
345
346 #[test]
347 fn test_format_version_cargo() {
348 let version = "cargo 1.75.0 (1d8b05cdd 2023-11-20)";
349 assert_eq!(format_version(version), "1.75.0 (1d8b05cdd 2023-11-20)");
350 }
351
352 #[test]
353 fn test_format_version_unknown() {
354 let version = "unknown";
355 assert_eq!(format_version(version), "unknown");
356 }
357
358 #[test]
359 fn test_substitute_banner_versions() {
360 let banner = "oxur: N.N.N\nrustc: M.M.M\ncargo: L.L.L";
361 let metadata = oxur_repl::metadata::SystemMetadata {
362 oxur_version: "0.1.0".to_string(),
363 rust_version: "rustc 1.75.0 (82e1608df 2023-12-21)".to_string(),
364 cargo_version: "cargo 1.75.0 (1d8b05cdd 2023-11-20)".to_string(),
365 os_name: "Test".to_string(),
366 os_version: "1.0".to_string(),
367 arch: "x86_64".to_string(),
368 hostname: "test".to_string(),
369 pid: 1234,
370 cwd: std::path::PathBuf::from("/test"),
371 started_at: std::time::SystemTime::now(),
372 };
373
374 let result = substitute_banner_versions(banner, &metadata);
375 assert!(result.contains("oxur: 0.1.0"));
376 assert!(result.contains("rustc: 1.75.0 (82e1608df 2023-12-21)"));
377 assert!(result.contains("cargo: 1.75.0 (1d8b05cdd 2023-11-20)"));
378 assert!(!result.contains("N.N.N"));
379 assert!(!result.contains("M.M.M"));
380 assert!(!result.contains("L.L.L"));
381 }
382
383 #[test]
386 fn test_format_version_empty() {
387 let version = "";
388 assert_eq!(format_version(version), "");
389 }
390
391 #[test]
392 fn test_format_version_single_word() {
393 let version = "1.75.0";
394 assert_eq!(format_version(version), "1.75.0");
395 }
396
397 #[test]
398 fn test_format_version_many_parts() {
399 let version = "tool 1.0.0 extra info here";
400 assert_eq!(format_version(version), "1.0.0 extra info here");
401 }
402
403 #[test]
404 fn test_substitute_banner_no_placeholders() {
405 let banner = "Welcome to the REPL!";
406 let metadata = oxur_repl::metadata::SystemMetadata {
407 oxur_version: "0.1.0".to_string(),
408 rust_version: "rustc 1.75.0".to_string(),
409 cargo_version: "cargo 1.75.0".to_string(),
410 os_name: "Test".to_string(),
411 os_version: "1.0".to_string(),
412 arch: "x86_64".to_string(),
413 hostname: "test".to_string(),
414 pid: 1234,
415 cwd: std::path::PathBuf::from("/test"),
416 started_at: std::time::SystemTime::now(),
417 };
418
419 let result = substitute_banner_versions(banner, &metadata);
420 assert_eq!(result, "Welcome to the REPL!");
421 }
422
423 #[test]
424 fn test_substitute_banner_partial_placeholders() {
425 let banner = "Oxur N.N.N only";
426 let metadata = oxur_repl::metadata::SystemMetadata {
427 oxur_version: "0.2.0".to_string(),
428 rust_version: "rustc 1.76.0".to_string(),
429 cargo_version: "cargo 1.76.0".to_string(),
430 os_name: "Test".to_string(),
431 os_version: "1.0".to_string(),
432 arch: "x86_64".to_string(),
433 hostname: "test".to_string(),
434 pid: 1234,
435 cwd: std::path::PathBuf::from("/test"),
436 started_at: std::time::SystemTime::now(),
437 };
438
439 let result = substitute_banner_versions(banner, &metadata);
440 assert_eq!(result, "Oxur 0.2.0 only");
441 }
442
443 #[test]
444 fn test_add_completion_keybinding() {
445 let mut keybindings = default_emacs_keybindings();
446 add_completion_keybinding(&mut keybindings);
448 }
449
450 #[test]
451 fn test_add_completion_keybinding_vi_insert() {
452 let mut keybindings = default_vi_insert_keybindings();
453 add_completion_keybinding(&mut keybindings);
454 }
455
456 #[test]
457 fn test_add_completion_keybinding_vi_normal() {
458 let mut keybindings = default_vi_normal_keybindings();
459 add_completion_keybinding(&mut keybindings);
460 }
461
462 #[test]
463 fn test_terminal_config_default_banner() {
464 let config = TerminalConfig::default();
465 assert!(config.banner.is_some());
467 }
468
469 #[test]
470 fn test_terminal_config_color_disabled() {
471 let config = TerminalConfig::builder().color(false).build();
472 assert!(!config.color_enabled);
473 }
474
475 #[test]
476 fn test_terminal_config_color_enabled() {
477 let config = TerminalConfig::builder().color(true).build();
478 assert!(config.color_enabled);
479 }
480
481 #[test]
482 fn test_terminal_config_edit_mode_emacs() {
483 let config = TerminalConfig::builder().edit_mode(EditMode::Emacs).build();
484 assert!(matches!(config.edit_mode, EditMode::Emacs));
485 }
486
487 #[test]
488 fn test_terminal_config_edit_mode_vi() {
489 let config = TerminalConfig::builder().edit_mode(EditMode::Vi).build();
490 assert!(matches!(config.edit_mode, EditMode::Vi));
491 }
492
493 #[test]
494 fn test_history_config_default() {
495 let config = HistoryConfig::default();
496 assert!(config.enabled);
497 assert!(config.path.is_none());
498 assert_eq!(config.max_size, Some(10000));
500 }
501
502 #[test]
503 fn test_history_config_disabled() {
504 let config = HistoryConfig { enabled: false, path: None, max_size: None };
505 assert!(!config.enabled);
506 }
507
508 #[test]
509 fn test_history_config_custom_path() {
510 let path = PathBuf::from("/custom/history");
511 let config = HistoryConfig { enabled: true, path: Some(path.clone()), max_size: None };
512 assert_eq!(config.path, Some(path));
513 }
514
515 #[test]
516 fn test_history_config_custom_max_size() {
517 let config = HistoryConfig { enabled: true, path: None, max_size: Some(5000) };
518 assert_eq!(config.max_size, Some(5000));
519 }
520
521 #[test]
525 #[serial_test::serial]
526 fn test_repl_terminal_with_config_emacs() {
527 let terminal_config =
529 TerminalConfig::builder().edit_mode(EditMode::Emacs).color(false).build();
530 let history_config = HistoryConfig { enabled: false, path: None, max_size: Some(100) };
531
532 let result = ReplTerminal::with_config(terminal_config, history_config);
533 assert!(result.is_ok());
534 let terminal = result.unwrap();
535 assert!(!terminal.config().color_enabled);
536 }
537
538 #[test]
539 #[serial_test::serial]
540 fn test_repl_terminal_with_config_vi() {
541 let terminal_config =
543 TerminalConfig::builder().edit_mode(EditMode::Vi).color(false).build();
544 let history_config = HistoryConfig { enabled: false, path: None, max_size: Some(100) };
545
546 let result = ReplTerminal::with_config(terminal_config, history_config);
547 assert!(result.is_ok());
548 }
549
550 #[test]
551 #[serial_test::serial]
552 fn test_repl_terminal_with_history_enabled() {
553 let terminal_config = TerminalConfig::builder().color(false).build();
555 let temp_dir = std::env::temp_dir();
556 let history_path = temp_dir.join("test-oxur-history");
557 let history_config =
558 HistoryConfig { enabled: true, path: Some(history_path.clone()), max_size: Some(500) };
559
560 let result = ReplTerminal::with_config(terminal_config, history_config);
561 assert!(result.is_ok());
562
563 let _ = std::fs::remove_file(history_path);
565 }
566
567 #[test]
568 #[serial_test::serial]
569 fn test_repl_terminal_with_history_disabled() {
570 let terminal_config = TerminalConfig::builder().color(false).build();
572 let history_config = HistoryConfig { enabled: false, path: None, max_size: None };
573
574 let result = ReplTerminal::with_config(terminal_config, history_config);
575 assert!(result.is_ok());
576 }
577
578 #[test]
579 #[serial_test::serial]
580 fn test_repl_terminal_config_accessor() {
581 let terminal_config = TerminalConfig::builder()
582 .prompt("test> ")
583 .continuation_prompt("..> ")
584 .color(false)
585 .build();
586 let history_config = HistoryConfig::default();
587
588 let terminal = ReplTerminal::with_config(terminal_config.clone(), history_config).unwrap();
589
590 let config = terminal.config();
592 assert_eq!(config.prompt, "test> ");
593 assert_eq!(config.continuation_prompt, "..> ");
594 assert!(!config.color_enabled);
595 }
596
597 #[test]
598 #[serial_test::serial]
599 fn test_repl_terminal_prompt() {
600 let terminal_config = TerminalConfig::builder().prompt("custom> ").color(false).build();
601 let history_config = HistoryConfig::default();
602
603 let terminal = ReplTerminal::with_config(terminal_config, history_config).unwrap();
604
605 let prompt = terminal.prompt();
607 assert_eq!(prompt, "custom> ");
608 }
609
610 #[test]
611 #[serial_test::serial]
612 fn test_repl_terminal_continuation_prompt() {
613 let terminal_config =
614 TerminalConfig::builder().continuation_prompt(">>> ").color(false).build();
615 let history_config = HistoryConfig::default();
616
617 let terminal = ReplTerminal::with_config(terminal_config, history_config).unwrap();
618
619 let cont_prompt = terminal.continuation_prompt();
621 assert_eq!(cont_prompt, ">>> ");
622 }
623
624 #[test]
625 #[serial_test::serial]
626 fn test_repl_terminal_color_enabled() {
627 let terminal_config = TerminalConfig::builder().color(true).build();
628 let history_config = HistoryConfig::default();
629
630 let terminal = ReplTerminal::with_config(terminal_config, history_config).unwrap();
631
632 assert!(terminal.color_enabled());
634 }
635
636 #[test]
637 #[serial_test::serial]
638 fn test_repl_terminal_color_disabled() {
639 let terminal_config = TerminalConfig::builder().color(false).build();
640 let history_config = HistoryConfig::default();
641
642 let terminal = ReplTerminal::with_config(terminal_config, history_config).unwrap();
643
644 assert!(!terminal.color_enabled());
645 }
646
647 #[test]
648 #[serial_test::serial]
649 fn test_repl_terminal_save_history() {
650 let terminal_config = TerminalConfig::builder().color(false).build();
651 let history_config = HistoryConfig::default();
652
653 let mut terminal = ReplTerminal::with_config(terminal_config, history_config).unwrap();
654
655 let result = terminal.save_history();
657 assert!(result.is_ok());
658 }
659}