use std::fs::File;
use std::io::{self, Read};
use std::path::{Path, PathBuf};
use rustyline::completion::{Completer, Pair};
use rustyline::error::ReadlineError;
use rustyline::hint::{Hinter, HistoryHinter};
use rustyline::history::DefaultHistory;
use rustyline::{
CompletionType, Config, Context, EditMode, Editor, Helper, Highlighter, Validator,
};
const SLASH_COMMANDS: &[&str] = &[
"/help",
"/clear",
"/info",
"/context",
"/servers",
"/tools",
"/allowances",
"/budget",
"/audit",
"/compact",
"/save",
"/sessions",
];
const MAX_HISTORY_BYTES: u64 = 8 * 1024 * 1024;
pub(crate) enum ReadlineEvent {
Line(String),
Interrupted,
Eof,
}
#[derive(Helper, Validator, Highlighter)]
struct ReplHelper {
hinter: HistoryHinter,
}
impl Completer for ReplHelper {
type Candidate = Pair;
fn complete(
&self,
line: &str,
pos: usize,
_ctx: &Context<'_>,
) -> rustyline::Result<(usize, Vec<Pair>)> {
let prefix = &line[..pos];
let word_start = prefix
.rfind(char::is_whitespace)
.map_or(0, |i| i.saturating_add(1));
let word = &prefix[word_start..];
if !word.starts_with('/') {
return Ok((pos, Vec::new()));
}
let matches: Vec<Pair> = SLASH_COMMANDS
.iter()
.filter(|cmd| cmd.starts_with(word))
.map(|cmd| Pair {
display: cmd.to_string(),
replacement: cmd.to_string(),
})
.collect();
Ok((word_start, matches))
}
}
impl Hinter for ReplHelper {
type Hint = String;
fn hint(&self, line: &str, pos: usize, ctx: &Context<'_>) -> Option<String> {
self.hinter.hint(line, pos, ctx)
}
}
pub(crate) struct ReplEditor {
editor: Editor<ReplHelper, DefaultHistory>,
history_path: PathBuf,
}
impl ReplEditor {
pub(crate) fn new() -> anyhow::Result<Self> {
let home = astrid_core::dirs::AstridHome::resolve()?;
home.ensure()?;
let history_dir = home.log_dir().join("cli");
astrid_core::platform_fs::ensure_private_directory(&history_dir)?;
let history_path = history_dir.join("history");
migrate_legacy_history(&history_path, &home.root().join("history"))?;
match std::fs::symlink_metadata(&history_path) {
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
anyhow::bail!(
"REPL history path is not a regular file: {}",
history_path.display()
);
},
Ok(_) => {
astrid_core::platform_fs::verify_no_redirects(&history_path)?;
astrid_core::platform_fs::restrict_private_file(&history_path)?;
},
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
astrid_core::platform_fs::atomic_write_private_file(&history_path, b"")?;
},
Err(error) => return Err(error.into()),
}
let config = Config::builder()
.history_ignore_dups(true)?
.completion_type(CompletionType::List)
.edit_mode(EditMode::Emacs)
.auto_add_history(true)
.build();
let helper = ReplHelper {
hinter: HistoryHinter::new(),
};
let mut editor = Editor::with_config(config)?;
editor.set_helper(Some(helper));
let _ = editor.load_history(&history_path);
Ok(Self {
editor,
history_path,
})
}
pub(crate) fn readline(&mut self) -> ReadlineEvent {
let continuation = " ";
let mut accumulated = String::new();
let mut is_continuation = false;
loop {
let line = if is_continuation {
self.editor.readline(continuation)
} else {
self.editor.readline(&("> ", "\x1b[1;32m> \x1b[0m"))
};
match line {
Ok(line) => {
if line.ends_with('\\') {
accumulated.push_str(&line[..line.len().saturating_sub(1)]);
accumulated.push('\n');
is_continuation = true;
continue;
}
accumulated.push_str(&line);
let _ = self.editor.save_history(&self.history_path);
let _ = astrid_core::platform_fs::restrict_private_file(&self.history_path);
return ReadlineEvent::Line(accumulated);
},
Err(ReadlineError::Interrupted) => {
return ReadlineEvent::Interrupted;
},
Err(ReadlineError::Eof | _) => {
return ReadlineEvent::Eof;
},
}
}
}
}
fn migrate_legacy_history(new_path: &Path, legacy_path: &Path) -> io::Result<()> {
let legacy_exists = match std::fs::symlink_metadata(legacy_path) {
Ok(metadata) => {
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"legacy REPL history is not a regular file: {}",
legacy_path.display()
),
));
}
astrid_core::platform_fs::verify_no_redirects(legacy_path)?;
true
},
Err(error) if error.kind() == io::ErrorKind::NotFound => false,
Err(error) => return Err(error),
};
if !legacy_exists {
return Ok(());
}
let legacy = read_history_bounded(legacy_path)?;
match std::fs::symlink_metadata(new_path) {
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"REPL history path is not a regular file: {}",
new_path.display()
),
));
},
Ok(_) => {
astrid_core::platform_fs::verify_no_redirects(new_path)?;
let current = read_history_bounded(new_path)?;
if current != legacy {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"legacy and operator REPL histories differ; refusing to merge",
));
}
},
Err(error) if error.kind() == io::ErrorKind::NotFound => {
astrid_core::platform_fs::atomic_write_private_file(new_path, &legacy)?;
},
Err(error) => return Err(error),
}
std::fs::remove_file(legacy_path)?;
Ok(())
}
fn read_history_bounded(path: &Path) -> io::Result<Vec<u8>> {
astrid_core::platform_fs::verify_no_redirects(path)?;
let mut file = File::open(path)?;
let mut bytes = Vec::new();
file.by_ref()
.take(MAX_HISTORY_BYTES.saturating_add(1))
.read_to_end(&mut bytes)?;
if bytes.len() as u64 > MAX_HISTORY_BYTES {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("REPL history exceeds {MAX_HISTORY_BYTES} bytes"),
));
}
Ok(bytes)
}
#[cfg(test)]
mod tests {
use super::migrate_legacy_history;
#[test]
fn legacy_history_moves_to_operator_log() {
let root = tempfile::tempdir().expect("tempdir");
let legacy = root.path().join("history");
let current = root.path().join("log/cli/history");
std::fs::create_dir_all(current.parent().unwrap()).unwrap();
std::fs::write(&legacy, b"/help\nhello\n").unwrap();
migrate_legacy_history(¤t, &legacy).unwrap();
assert_eq!(std::fs::read(¤t).unwrap(), b"/help\nhello\n");
assert!(!legacy.exists());
}
#[cfg(unix)]
#[test]
fn redirected_legacy_history_is_rejected() {
use std::os::unix::fs::symlink;
let root = tempfile::tempdir().expect("tempdir");
let outside = root.path().join("outside");
let legacy = root.path().join("history");
let current = root.path().join("log/cli/history");
std::fs::write(&outside, b"secret").unwrap();
symlink(&outside, &legacy).unwrap();
assert!(migrate_legacy_history(¤t, &legacy).is_err());
assert!(!current.exists());
}
}