#![cfg(feature = "repl")]
use crate::runtime::completion::RuchyCompleter;
use crate::runtime::repl::Repl;
use crate::runtime::replay::{InputMode, SessionMetadata, SessionRecorder};
use crate::runtime::Value;
use anyhow::Result;
use colored::Colorize;
use rustyline::history::DefaultHistory;
use rustyline::{CompletionType, Config, EditMode};
use std::path::Path;
use std::time::SystemTime;
impl Repl {
fn create_session_metadata() -> Result<SessionMetadata> {
Ok(SessionMetadata {
session_id: format!(
"ruchy-session-{}",
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)?
.as_secs()
),
created_at: chrono::Utc::now().to_rfc3339(),
ruchy_version: env!("CARGO_PKG_VERSION").to_string(),
student_id: None,
assignment_id: None,
tags: vec!["interactive".to_string()],
})
}
fn setup_recording_editor(&self) -> Result<rustyline::Editor<RuchyCompleter, DefaultHistory>> {
let config = Config::builder()
.history_ignore_space(true)
.history_ignore_dups(true)?
.completion_type(CompletionType::List)
.edit_mode(EditMode::Emacs)
.build();
let mut rl = rustyline::Editor::<RuchyCompleter, DefaultHistory>::with_config(config)?;
let completer = RuchyCompleter::new();
rl.set_helper(Some(completer));
let temp_dir = std::env::temp_dir().join(format!("ruchy-{}", std::process::id()));
std::fs::create_dir_all(&temp_dir)?;
let history_path = temp_dir.join("history.txt");
let _ = rl.load_history(&history_path);
Ok(rl)
}
fn process_recorded_input(
&mut self,
line: String,
recorder: &mut SessionRecorder,
rl: &mut rustyline::Editor<RuchyCompleter, DefaultHistory>,
) -> Result<bool> {
let input = line.trim();
let _input_id = recorder.record_input(line.clone(), InputMode::Interactive);
if input == ":quit" || input == ":exit" {
return Ok(true); }
if !input.is_empty() {
rl.add_history_entry(input)?;
let result = self.eval(input);
let result_for_recording = match &result {
Ok(s) => Ok(Value::from_string(s.clone())),
Err(e) => Err(anyhow::anyhow!("{e}")),
};
recorder.record_output(result_for_recording);
match result {
Ok(output) if !output.is_empty() => {
println!("{output}");
}
Err(e) => {
eprintln!("{}: {}", "Error".bright_red(), e);
}
_ => {}
}
}
Ok(false) }
fn process_multiline_recorded_input(
&mut self,
line: String,
multiline_buffer: &mut String,
in_multiline: &mut bool,
recorder: &mut SessionRecorder,
rl: &mut rustyline::Editor<RuchyCompleter, DefaultHistory>,
) -> Result<()> {
let input = line.trim();
if input.is_empty() {
let full_input = multiline_buffer.trim().to_string();
if !full_input.is_empty() {
rl.add_history_entry(&full_input)?;
let result = self.eval(&full_input);
let result_for_recording = match &result {
Ok(s) => Ok(Value::from_string(s.clone())),
Err(e) => Err(anyhow::anyhow!("{e}")),
};
recorder.record_output(result_for_recording);
match result {
Ok(output) if !output.is_empty() => {
println!("{output}");
}
Err(e) => {
eprintln!("{}: {}", "Error".bright_red(), e);
}
_ => {}
}
}
multiline_buffer.clear();
*in_multiline = false;
} else {
multiline_buffer.push_str(&line);
multiline_buffer.push('\n');
}
Ok(())
}
pub fn run_with_recording_refactored(&mut self, record_file: &Path) -> Result<()> {
let metadata = Self::create_session_metadata()?;
let mut recorder = SessionRecorder::new(metadata);
println!(
"{}",
format!("🎬 Recording session to: {}", record_file.display()).bright_yellow()
);
let mut rl = self.setup_recording_editor()?;
let mut multiline_buffer = String::new();
let mut in_multiline = false;
loop {
let prompt = if in_multiline {
format!("{} ", " ...".bright_black())
} else {
format!("{} ", self.get_prompt().bright_green())
};
match rl.readline(&prompt) {
Ok(line) => {
if in_multiline {
let _input_id = recorder.record_input(line.clone(), InputMode::Paste);
self.process_multiline_recorded_input(
line,
&mut multiline_buffer,
&mut in_multiline,
&mut recorder,
&mut rl,
)?;
} else {
let input = line.trim();
if Self::needs_continuation(input) {
multiline_buffer = format!("{line}\n");
in_multiline = true;
let _input_id = recorder.record_input(line.clone(), InputMode::Paste);
} else {
let should_exit =
self.process_recorded_input(line, &mut recorder, &mut rl)?;
if should_exit {
break;
}
}
}
}
Err(rustyline::error::ReadlineError::Interrupted) => {
println!("{}", "Use :quit to exit".bright_yellow());
}
Err(rustyline::error::ReadlineError::Eof) => break,
Err(err) => {
eprintln!("{}: {:?}", "Error".bright_red(), err);
break;
}
}
}
let session = recorder.into_session();
let session_json = serde_json::to_string_pretty(&session)?;
std::fs::write(record_file, session_json)?;
println!(
"{}",
format!("📼 Session saved to: {}", record_file.display()).bright_green()
);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::NamedTempFile;
#[test]
fn test_create_session_metadata() {
let metadata = Repl::create_session_metadata().expect("operation should succeed in test");
assert!(metadata.session_id.starts_with("ruchy-session-"));
assert_eq!(metadata.ruchy_version, env!("CARGO_PKG_VERSION"));
assert_eq!(metadata.tags, vec!["interactive"]);
assert!(metadata.student_id.is_none());
assert!(metadata.assignment_id.is_none());
assert!(metadata.session_id.len() > "ruchy-session-".len());
}
#[test]
fn test_create_session_metadata_unique_ids() {
let metadata1 = Repl::create_session_metadata().expect("operation should succeed in test");
std::thread::sleep(std::time::Duration::from_millis(2)); let metadata2 = Repl::create_session_metadata().expect("operation should succeed in test");
assert!(metadata1.session_id.starts_with("ruchy-session-"));
assert!(metadata2.session_id.starts_with("ruchy-session-"));
assert_eq!(metadata1.ruchy_version, metadata2.ruchy_version);
let same_timestamp = metadata1.session_id == metadata2.session_id;
if !same_timestamp {
assert_ne!(metadata1.session_id, metadata2.session_id);
}
}
#[test]
fn test_setup_recording_editor() -> Result<()> {
let repl = Repl::new(std::env::temp_dir())?;
let editor = repl.setup_recording_editor()?;
assert!(editor.helper().is_some());
Ok(())
}
#[test]
fn test_setup_recording_editor_creates_temp_dir() -> Result<()> {
let repl = Repl::new(std::env::temp_dir())?;
let _editor = repl.setup_recording_editor()?;
let temp_dir = std::env::temp_dir().join(format!("ruchy-{}", std::process::id()));
assert!(temp_dir.exists());
Ok(())
}
#[test]
fn test_process_recorded_input_quit_commands() -> Result<()> {
let mut repl = Repl::new(std::env::temp_dir())?;
let metadata = Repl::create_session_metadata()?;
let mut recorder = SessionRecorder::new(metadata);
let mut rl = repl.setup_recording_editor()?;
let should_exit =
repl.process_recorded_input(":quit".to_string(), &mut recorder, &mut rl)?;
assert!(should_exit);
let should_exit =
repl.process_recorded_input(":exit".to_string(), &mut recorder, &mut rl)?;
assert!(should_exit);
Ok(())
}
#[test]
fn test_process_recorded_input_empty_input() -> Result<()> {
let mut repl = Repl::new(std::env::temp_dir())?;
let metadata = Repl::create_session_metadata()?;
let mut recorder = SessionRecorder::new(metadata);
let mut rl = repl.setup_recording_editor()?;
let should_exit = repl.process_recorded_input(String::new(), &mut recorder, &mut rl)?;
assert!(!should_exit);
let should_exit =
repl.process_recorded_input(" \t ".to_string(), &mut recorder, &mut rl)?;
assert!(!should_exit);
Ok(())
}
#[test]
fn test_process_recorded_input_simple_expression() -> Result<()> {
let mut repl = Repl::new(std::env::temp_dir())?;
let metadata = Repl::create_session_metadata()?;
let mut recorder = SessionRecorder::new(metadata);
let mut rl = repl.setup_recording_editor()?;
let should_exit =
repl.process_recorded_input("2 + 3".to_string(), &mut recorder, &mut rl)?;
assert!(!should_exit);
Ok(())
}
#[test]
fn test_process_recorded_input_invalid_syntax() -> Result<()> {
let mut repl = Repl::new(std::env::temp_dir())?;
let metadata = Repl::create_session_metadata()?;
let mut recorder = SessionRecorder::new(metadata);
let mut rl = repl.setup_recording_editor()?;
let should_exit =
repl.process_recorded_input("invalid syntax here".to_string(), &mut recorder, &mut rl)?;
assert!(!should_exit);
Ok(())
}
#[test]
fn test_process_multiline_recorded_input_empty_line() -> Result<()> {
let mut repl = Repl::new(std::env::temp_dir())?;
let metadata = Repl::create_session_metadata()?;
let mut recorder = SessionRecorder::new(metadata);
let mut rl = repl.setup_recording_editor()?;
let mut multiline_buffer = String::from("2 + 3\n");
let mut in_multiline = true;
repl.process_multiline_recorded_input(
String::new(),
&mut multiline_buffer,
&mut in_multiline,
&mut recorder,
&mut rl,
)?;
assert!(!in_multiline);
assert!(multiline_buffer.is_empty());
Ok(())
}
#[test]
fn test_process_multiline_recorded_input_continuation() -> Result<()> {
let mut repl = Repl::new(std::env::temp_dir())?;
let metadata = Repl::create_session_metadata()?;
let mut recorder = SessionRecorder::new(metadata);
let mut rl = repl.setup_recording_editor()?;
let mut multiline_buffer = String::new();
let mut in_multiline = true;
repl.process_multiline_recorded_input(
"let x = 5".to_string(),
&mut multiline_buffer,
&mut in_multiline,
&mut recorder,
&mut rl,
)?;
assert!(in_multiline);
assert!(multiline_buffer.contains("let x = 5"));
Ok(())
}
#[test]
fn test_process_multiline_recorded_input_with_evaluation() -> Result<()> {
let mut repl = Repl::new(std::env::temp_dir())?;
let metadata = Repl::create_session_metadata()?;
let mut recorder = SessionRecorder::new(metadata);
let mut rl = repl.setup_recording_editor()?;
let mut multiline_buffer = String::from("2 + 3");
let mut in_multiline = true;
repl.process_multiline_recorded_input(
String::new(),
&mut multiline_buffer,
&mut in_multiline,
&mut recorder,
&mut rl,
)?;
assert!(!in_multiline);
assert!(multiline_buffer.is_empty());
Ok(())
}
#[test]
fn test_multiline_buffer_accumulation() -> Result<()> {
let mut repl = Repl::new(std::env::temp_dir())?;
let metadata = Repl::create_session_metadata()?;
let mut recorder = SessionRecorder::new(metadata);
let mut rl = repl.setup_recording_editor()?;
let mut multiline_buffer = String::new();
let mut in_multiline = true;
repl.process_multiline_recorded_input(
"line1".to_string(),
&mut multiline_buffer,
&mut in_multiline,
&mut recorder,
&mut rl,
)?;
repl.process_multiline_recorded_input(
"line2".to_string(),
&mut multiline_buffer,
&mut in_multiline,
&mut recorder,
&mut rl,
)?;
assert!(multiline_buffer.contains("line1"));
assert!(multiline_buffer.contains("line2"));
assert!(in_multiline);
Ok(())
}
#[test]
fn test_run_with_recording_refactored_setup() -> Result<()> {
let _repl = Repl::new(std::env::temp_dir())?;
let _temp_file = NamedTempFile::new()?;
let metadata = Repl::create_session_metadata()?;
assert!(!metadata.session_id.is_empty());
assert!(!metadata.created_at.is_empty());
Ok(())
}
#[test]
fn test_recording_session_metadata_components() -> Result<()> {
let metadata = Repl::create_session_metadata()?;
assert!(metadata.session_id.starts_with("ruchy-session-"));
let timestamp_part = metadata
.session_id
.strip_prefix("ruchy-session-")
.expect("operation should succeed in test");
assert!(timestamp_part.parse::<u64>().is_ok());
assert!(chrono::DateTime::parse_from_rfc3339(&metadata.created_at).is_ok());
assert_eq!(metadata.ruchy_version, env!("CARGO_PKG_VERSION"));
Ok(())
}
#[test]
fn test_setup_recording_editor_configuration() -> Result<()> {
let repl = Repl::new(std::env::temp_dir())?;
let editor = repl.setup_recording_editor()?;
assert!(editor.helper().is_some());
let temp_dir = std::env::temp_dir().join(format!("ruchy-{}", std::process::id()));
assert!(temp_dir.exists());
Ok(())
}
#[test]
fn test_process_recorded_input_records_properly() -> Result<()> {
let mut repl = Repl::new(std::env::temp_dir())?;
let metadata = Repl::create_session_metadata()?;
let mut recorder = SessionRecorder::new(metadata);
let mut rl = repl.setup_recording_editor()?;
let initial_timeline_len = recorder.get_session().timeline.len();
repl.process_recorded_input("1 + 1".to_string(), &mut recorder, &mut rl)?;
let final_timeline_len = recorder.get_session().timeline.len();
assert!(final_timeline_len > initial_timeline_len);
Ok(())
}
#[test]
fn test_whitespace_handling_in_inputs() -> Result<()> {
let mut repl = Repl::new(std::env::temp_dir())?;
let metadata = Repl::create_session_metadata()?;
let mut recorder = SessionRecorder::new(metadata);
let mut rl = repl.setup_recording_editor()?;
let should_exit =
repl.process_recorded_input(" 2 + 3 ".to_string(), &mut recorder, &mut rl)?;
assert!(!should_exit);
Ok(())
}
#[test]
fn test_session_metadata_timestamp_progression() -> Result<()> {
let metadata1 = Repl::create_session_metadata()?;
std::thread::sleep(std::time::Duration::from_millis(10));
let metadata2 = Repl::create_session_metadata()?;
let ts1_str = metadata1
.session_id
.strip_prefix("ruchy-session-")
.expect("operation should succeed in test");
let ts2_str = metadata2
.session_id
.strip_prefix("ruchy-session-")
.expect("operation should succeed in test");
let ts1: u64 = ts1_str.parse()?;
let ts2: u64 = ts2_str.parse()?;
assert!(ts2 >= ts1); Ok(())
}
#[test]
fn test_multiline_input_error_handling() -> Result<()> {
let mut repl = Repl::new(std::env::temp_dir())?;
let metadata = Repl::create_session_metadata()?;
let mut recorder = SessionRecorder::new(metadata);
let mut rl = repl.setup_recording_editor()?;
let mut multiline_buffer = String::from("invalid syntax");
let mut in_multiline = true;
repl.process_multiline_recorded_input(
String::new(),
&mut multiline_buffer,
&mut in_multiline,
&mut recorder,
&mut rl,
)?;
assert!(!in_multiline);
assert!(multiline_buffer.is_empty());
Ok(())
}
}