#![allow(dead_code)]
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use nu_parser::FlatShape;
use nu_protocol::Signals;
use nu_protocol::ast::{Block, Expr};
use nu_protocol::engine::{EngineState, Redirection, Stack, StateWorkingSet};
use nu_protocol::{ByteStreamSource, OutDest, PipelineData, Span, Value};
use nu_protocol::debugger::WithoutDebug;
use ratatui::style::{Color, Modifier, Style};
use rusqlite::Connection;
pub(super) struct Engine {
state: EngineState,
stack: Stack,
blocked_externals: Vec<String>,
interrupt: Arc<AtomicBool>,
}
pub(super) struct ShellOutput {
pub stdout: String,
pub stderr: String,
pub success: bool,
}
#[derive(Debug, Clone)]
pub(crate) struct ShellTurn {
pub command: String,
pub stdout: String,
pub stderr: String,
pub success: bool,
}
pub(crate) struct History {
conn: Option<Connection>,
path: PathBuf,
}
impl History {
pub(crate) fn open(project_root: &Path) -> Self {
let mut path = project_root.to_path_buf();
path.push(".inkhaven");
let _ = std::fs::create_dir_all(&path);
path.push("shell_history.db");
let conn = Connection::open(&path).ok();
if let Some(c) = conn.as_ref() {
let _ = c.execute_batch(
r#"CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
command TEXT NOT NULL,
ts TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS history_ts_idx
ON history(ts DESC);"#,
);
}
Self { conn, path }
}
pub(crate) fn load(&self, cap: usize) -> Vec<String> {
let Some(conn) = self.conn.as_ref() else {
return Vec::new();
};
let Ok(mut stmt) = conn.prepare(
"SELECT command FROM (
SELECT command, id FROM history ORDER BY id DESC LIMIT ?
) sub ORDER BY id ASC",
) else {
return Vec::new();
};
let cap_i = cap as i64;
match stmt.query_map([cap_i], |row| row.get::<_, String>(0)) {
Ok(rows) => rows.filter_map(|r| r.ok()).collect(),
Err(_) => Vec::new(),
}
}
pub(crate) fn push(&self, command: &str) {
let Some(conn) = self.conn.as_ref() else { return };
let _ = conn.execute(
"INSERT INTO history (command) VALUES (?1)",
[command],
);
}
pub(crate) fn path(&self) -> &Path {
&self.path
}
}
impl Engine {
pub(super) fn new(project_root: &Path) -> Self {
let engine_state = EngineState::new();
let mut state = nu_command::add_shell_command_context(engine_state);
state.is_mcp = true;
let interrupt = Arc::new(AtomicBool::new(false));
state.set_signals(Signals::new(interrupt.clone()));
let mut stack = Stack::new();
let root_str = {
let raw = project_root.to_string_lossy().to_string();
let trimmed = raw.trim_end_matches('/');
if trimmed.is_empty() { "/".to_string() } else { trimmed.to_string() }
};
stack.add_env_var(
"PWD".to_string(),
Value::string(root_str.clone(), Span::unknown()),
);
stack.add_env_var(
"CWD".to_string(),
Value::string(root_str, Span::unknown()),
);
Self {
state,
stack,
blocked_externals: Vec::new(),
interrupt,
}
}
pub(super) fn with_blocked_externals(
mut self,
list: Vec<String>,
) -> Self {
self.blocked_externals = list
.into_iter()
.map(|s| basename_lower(&s))
.filter(|s| !s.is_empty())
.collect();
self
}
pub(super) fn trigger_interrupt(&self) {
self.interrupt.store(true, Ordering::Relaxed);
}
pub(super) fn reset_interrupt(&self) {
self.interrupt.store(false, Ordering::Relaxed);
}
pub(super) fn interrupt_handle(&self) -> Arc<AtomicBool> {
self.interrupt.clone()
}
pub(super) fn cwd(&self) -> PathBuf {
let v = self.stack.get_env_var(&self.state, "PWD");
if let Some(val) = v {
if let Ok(s) = val.coerce_str() {
return PathBuf::from(s.into_owned());
}
}
PathBuf::from("/")
}
pub(super) fn complete(
&self,
input: &str,
cursor_chars: usize,
) -> Completion {
let (token_start, token, is_command_pos) =
extract_token_for_completion(input, cursor_chars);
let mut matches: Vec<String> = Vec::new();
if is_command_pos && !token.contains('/') {
for (bytes, _id) in self.state.get_decls_sorted(false) {
if let Ok(name) = std::str::from_utf8(&bytes) {
if name.starts_with(&token) {
matches.push(name.to_string());
}
}
}
matches.extend(complete_external_on_path(&token));
} else {
matches.extend(complete_path(&self.cwd(), &token));
}
matches.sort();
matches.dedup();
Completion { matches, token_start, token }
}
pub(super) fn eval(&mut self, line: &str) -> ShellOutput {
let (block, parse_errors) = {
let mut working_set = StateWorkingSet::new(&self.state);
let block = nu_parser::parse(
&mut working_set,
None,
line.as_bytes(),
false,
);
let errs: Vec<String> = working_set
.parse_errors
.iter()
.map(|e| format!("{e:?}"))
.collect();
let delta = working_set.render();
if let Err(e) = self.state.merge_delta(delta) {
return ShellOutput {
stdout: String::new(),
stderr: format!("merge_delta: {e:?}"),
success: false,
};
}
(block, errs)
};
if !parse_errors.is_empty() {
return ShellOutput {
stdout: String::new(),
stderr: parse_errors.join("\n"),
success: false,
};
}
if !self.blocked_externals.is_empty() {
if let Some(name) =
find_blocked_external(&block, line, &self.blocked_externals)
{
return ShellOutput {
stdout: String::new(),
stderr: format!(
"blocked: `{name}` is a full-screen TUI app and \
would corrupt the editor's alt-screen surface. \
Use a separate terminal for interactive workflows. \
Customise `shell.blocked_externals` in HJSON to \
allow."
),
success: false,
};
}
}
let mut guard = self.stack.push_redirection(
Some(Redirection::Pipe(OutDest::Pipe)),
Some(Redirection::Pipe(OutDest::Pipe)),
);
let exec_result = nu_engine::eval_block::<WithoutDebug>(
&self.state,
&mut *guard,
&block,
PipelineData::empty(),
);
drop(guard);
match exec_result {
Ok(mut exec) => {
if let PipelineData::ByteStream(stream, _) = &mut exec.body {
if let ByteStreamSource::Child(child) = stream.source_mut() {
child.ignore_error(true);
}
}
let cfg = nu_protocol::Config::default();
let raw = format_via_table(
&self.state,
&mut self.stack,
exec.body,
&cfg,
);
ShellOutput {
stdout: strip_ansi(&raw),
stderr: String::new(),
success: true,
}
}
Err(e) => ShellOutput {
stdout: String::new(),
stderr: strip_ansi(&format!("{e:?}")),
success: false,
},
}
}
pub(super) fn highlight(&self, line: &str) -> Vec<(String, Style)> {
if line.is_empty() {
return vec![(String::new(), Style::default())];
}
let mut working_set = StateWorkingSet::new(&self.state);
let block = nu_parser::parse(
&mut working_set,
None,
line.as_bytes(),
false,
);
let flat = nu_parser::flatten_block(&working_set, &block);
let block_start = block
.span
.map(|s| s.start)
.or_else(|| flat.first().map(|(span, _)| span.start))
.unwrap_or(0);
let bytes = line.as_bytes();
let mut out: Vec<(String, Style)> = Vec::new();
let mut cursor = 0usize;
for (span, shape) in &flat {
let local_start = span
.start
.saturating_sub(block_start)
.min(bytes.len());
let local_end = span
.end
.saturating_sub(block_start)
.min(bytes.len());
if local_start > cursor {
let gap = String::from_utf8_lossy(
&bytes[cursor..local_start],
)
.into_owned();
out.push((gap, Style::default()));
}
if local_end > local_start {
let text = String::from_utf8_lossy(
&bytes[local_start..local_end],
)
.into_owned();
out.push((text, style_for_shape(shape)));
cursor = local_end;
}
}
if cursor < bytes.len() {
let tail =
String::from_utf8_lossy(&bytes[cursor..]).into_owned();
out.push((tail, Style::default()));
}
out
}
}
fn style_for_shape(shape: &FlatShape) -> Style {
match shape {
FlatShape::Keyword => Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
FlatShape::InternalCall(_)
| FlatShape::External(_)
| FlatShape::ExternalResolved => Style::default().fg(Color::Cyan),
FlatShape::Variable(_) | FlatShape::VarDecl(_) => {
Style::default().fg(Color::Magenta)
}
FlatShape::String
| FlatShape::RawString
| FlatShape::StringInterpolation
| FlatShape::GlobInterpolation => Style::default().fg(Color::Green),
FlatShape::Int
| FlatShape::Float
| FlatShape::Bool
| FlatShape::DateTime => Style::default().fg(Color::Yellow),
FlatShape::Flag => Style::default().fg(Color::Yellow),
FlatShape::Operator
| FlatShape::Pipe
| FlatShape::Redirection => Style::default().fg(Color::Gray),
FlatShape::Filepath
| FlatShape::Directory
| FlatShape::GlobPattern => Style::default().fg(Color::Blue),
FlatShape::Garbage => Style::default().fg(Color::Red),
FlatShape::Custom(_) => Style::default().fg(Color::Cyan),
_ => Style::default(),
}
}
pub(super) struct Completion {
pub matches: Vec<String>,
pub token_start: usize,
pub token: String,
}
fn extract_token_for_completion(
input: &str,
cursor_chars: usize,
) -> (usize, String, bool) {
let chars: Vec<char> = input.chars().collect();
let cursor = cursor_chars.min(chars.len());
let mut start = cursor;
while start > 0 {
let c = chars[start - 1];
if c.is_whitespace() || c == '|' || c == ';' || c == '(' {
break;
}
start -= 1;
}
let token: String = chars[start..cursor].iter().collect();
let mut p = start;
while p > 0 && chars[p - 1].is_whitespace() {
p -= 1;
}
let is_command_pos = p == 0
|| matches!(chars[p - 1], '|' | ';' | '(');
(start, token, is_command_pos)
}
fn complete_external_on_path(prefix: &str) -> Vec<String> {
use std::collections::BTreeSet;
let mut out: BTreeSet<String> = BTreeSet::new();
let path = match std::env::var_os("PATH") {
Some(p) => p,
None => return Vec::new(),
};
for dir in std::env::split_paths(&path) {
let read_dir = match std::fs::read_dir(&dir) {
Ok(r) => r,
Err(_) => continue,
};
for entry in read_dir.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if name.starts_with(prefix) {
out.insert(name);
}
}
}
out.into_iter().collect()
}
fn complete_path(cwd: &Path, prefix: &str) -> Vec<String> {
let (dir_part, name_prefix): (String, &str) = if let Some(idx) = prefix.rfind('/') {
let head = &prefix[..idx + 1];
let name = &prefix[idx + 1..];
(head.to_string(), name)
} else {
(String::new(), prefix)
};
let search_dir = if dir_part.is_empty() {
cwd.to_path_buf()
} else if dir_part.starts_with('/') {
PathBuf::from(&dir_part)
} else if dir_part.starts_with("~/") {
match std::env::var_os("HOME") {
Some(h) => PathBuf::from(h).join(&dir_part[2..]),
None => cwd.join(&dir_part),
}
} else {
cwd.join(&dir_part)
};
let mut matches: Vec<String> = Vec::new();
let read_dir = match std::fs::read_dir(&search_dir) {
Ok(r) => r,
Err(_) => return matches,
};
for entry in read_dir.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if !name.starts_with(name_prefix) {
continue;
}
let is_dir = entry
.file_type()
.map(|t| t.is_dir())
.unwrap_or(false);
let mut full = format!("{dir_part}{name}");
if is_dir {
full.push('/');
}
matches.push(full);
}
matches.sort();
matches
}
pub(super) fn longest_common_prefix(items: &[String]) -> String {
if items.is_empty() {
return String::new();
}
let first: Vec<char> = items[0].chars().collect();
let mut max_len = first.len();
for s in &items[1..] {
let mut i = 0;
for (a, b) in first.iter().zip(s.chars()) {
if *a != b {
break;
}
i += 1;
}
max_len = max_len.min(i);
if max_len == 0 {
break;
}
}
first[..max_len].iter().collect()
}
fn basename_lower(token: &str) -> String {
let trimmed = token.trim_start_matches('^');
let last = trimmed
.rsplit(|c: char| c == '/' || c == '\\')
.next()
.unwrap_or("");
last.to_lowercase()
}
fn find_blocked_external(
block: &Block,
line: &str,
blocked: &[String],
) -> Option<String> {
let block_start = block.span.map(|s| s.start)?;
let bytes = line.as_bytes();
for pipeline in block.pipelines.iter() {
for element in pipeline.elements.iter() {
if let Expr::ExternalCall(head, _args) = &element.expr.expr {
let span = head.span;
let rel_start = span.start.saturating_sub(block_start);
let rel_end = span.end.saturating_sub(block_start);
if rel_end > bytes.len() || rel_start >= rel_end {
continue;
}
let raw = match std::str::from_utf8(&bytes[rel_start..rel_end]) {
Ok(s) => s,
Err(_) => continue,
};
let bn = basename_lower(raw);
if blocked.iter().any(|b| b == &bn) {
return Some(raw.to_string());
}
}
}
}
None
}
pub(super) fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
match chars.next() {
Some('[') => {
while let Some(d) = chars.next() {
if matches!(d, '@'..='~') {
break;
}
}
}
Some(']') => {
while let Some(d) = chars.next() {
if d == '\x07' {
break;
}
if d == '\x1b' {
let _ = chars.next();
break;
}
}
}
Some('(') | Some(')') => {
let _ = chars.next();
}
Some(_) | None => {}
}
continue;
}
out.push(c);
}
out
}
fn format_via_table(
engine_state: &EngineState,
stack: &mut Stack,
pipeline: PipelineData,
cfg: &nu_protocol::Config,
) -> String {
let value = match pipeline.into_value(Span::unknown()) {
Ok(v) => v,
Err(_) => return String::new(),
};
if matches!(value, Value::Nothing { .. }) {
return String::new();
}
let is_tabular =
matches!(value, Value::List { .. } | Value::Record { .. });
if is_tabular {
if let Some(table_id) = engine_state.table_decl_id {
let cmd = engine_state.get_decl(table_id);
let ast_call = nu_protocol::ast::Call::new(Span::unknown());
let call_ref: nu_protocol::engine::Call<'_> =
(&ast_call).into();
let pd = PipelineData::Value(value.clone(), None);
if let Ok(formatted) =
cmd.run(engine_state, stack, &call_ref, pd)
{
if let Ok(s) = formatted.collect_string("\n", cfg) {
return s;
}
}
}
}
value.to_expanded_string("\n", cfg)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn engine() -> Engine {
let dir = std::env::temp_dir();
Engine::new(&dir)
}
#[test]
fn one_plus_one_equals_two() {
let mut e = engine();
let out = e.eval("1 + 1");
assert!(out.success, "stderr was: {}", out.stderr);
assert_eq!(out.stdout.trim(), "2");
}
#[test]
fn string_concatenation() {
let mut e = engine();
let out = e.eval(r#""foo" + "bar""#);
assert!(out.success, "stderr was: {}", out.stderr);
assert_eq!(out.stdout.trim(), "foobar");
}
#[test]
fn let_binding_does_not_persist_across_evals_yet() {
let mut e = engine();
let out1 = e.eval("let answer = 42");
let _ = out1;
let out2 = e.eval("$answer * 2");
assert!(
!out2.success,
"if this passes, let now persists across evals — \
celebrate, update the test name, and remove this docstring",
);
}
#[test]
fn parse_error_lands_in_stderr_not_panic() {
let mut e = engine();
let out = e.eval("let x = "); assert!(!out.success);
assert!(!out.stderr.is_empty());
}
#[test]
fn history_roundtrips_commands() {
let tmp = tempfile::tempdir().expect("tempdir");
let h = History::open(tmp.path());
h.push("ls");
h.push("pwd");
h.push("date");
let loaded = h.load(10);
assert_eq!(loaded, vec!["ls", "pwd", "date"]);
let loaded2 = h.load(2);
assert_eq!(loaded2, vec!["pwd", "date"]);
}
#[test]
fn history_survives_reopen() {
let tmp = tempfile::tempdir().expect("tempdir");
{
let h = History::open(tmp.path());
h.push("first");
h.push("second");
}
let h = History::open(tmp.path());
let loaded = h.load(10);
assert_eq!(loaded, vec!["first", "second"]);
}
#[test]
fn external_command_path_without_caret_is_captured() {
let mut e = engine();
let out = e.eval(r#"/bin/sh -c "echo nocaret-stderr 1>&2; echo nocaret-stdout""#);
let combined = format!("{}\n{}", out.stdout, out.stderr);
assert!(
combined.contains("nocaret-stderr"),
"expected stderr captured for path-shaped external, got stdout={:?} stderr={:?}",
out.stdout, out.stderr,
);
assert!(
combined.contains("nocaret-stdout"),
"expected stdout captured for path-shaped external, got stdout={:?} stderr={:?}",
out.stdout, out.stderr,
);
}
#[test]
fn external_command_failed_exit_stderr_captured() {
let mut e = engine();
let out = e.eval(r#"^/bin/ls /this/path/should/not/exist/13579"#);
let combined = format!("{}\n{}", out.stdout, out.stderr);
assert!(
combined.to_lowercase().contains("no such")
|| combined.to_lowercase().contains("not found")
|| combined.to_lowercase().contains("cannot access")
|| combined.contains("13579"),
"expected /bin/ls failure stderr captured, got stdout={:?} stderr={:?}",
out.stdout, out.stderr,
);
}
#[test]
fn blocked_externals_refused_before_spawn() {
let mut e = engine().with_blocked_externals(vec![
"vim".to_string(),
"less".to_string(),
]);
let out = e.eval("^vim file.txt");
assert!(!out.success, "expected refusal, got stderr={:?}", out.stderr);
assert!(
out.stderr.contains("blocked")
&& out.stderr.to_lowercase().contains("vim"),
"expected friendly block message, got {:?}",
out.stderr,
);
assert!(out.stdout.is_empty(), "unexpected stdout: {:?}", out.stdout);
}
#[test]
fn blocked_externals_match_by_basename_full_path() {
let mut e = engine().with_blocked_externals(vec![
"vim".to_string(),
]);
let out = e.eval("^/usr/bin/vim /tmp/x");
assert!(!out.success, "full-path vim should be blocked");
assert!(
out.stderr.contains("blocked"),
"expected blocked message, got {:?}",
out.stderr,
);
}
#[test]
fn blocked_externals_case_insensitive() {
let mut e = engine().with_blocked_externals(vec![
"vim".to_string(),
]);
let out = e.eval("^VIM");
assert!(!out.success, "case-insensitive match expected");
}
#[test]
fn blocked_externals_dont_affect_allowed_commands() {
let mut e = engine().with_blocked_externals(vec![
"vim".to_string(),
"less".to_string(),
]);
let a = e.eval("1 + 1");
assert!(a.success && a.stdout.trim() == "2");
let b = e.eval(r#""hello" | str length"#);
assert!(b.success && b.stdout.trim() == "5");
}
#[test]
fn interrupt_handle_clones_share_state() {
let e = engine();
let h1 = e.interrupt_handle();
let h2 = e.interrupt_handle();
assert!(!h1.load(std::sync::atomic::Ordering::Relaxed));
h1.store(true, std::sync::atomic::Ordering::Relaxed);
assert!(h2.load(std::sync::atomic::Ordering::Relaxed));
e.reset_interrupt();
assert!(!h1.load(std::sync::atomic::Ordering::Relaxed));
assert!(!h2.load(std::sync::atomic::Ordering::Relaxed));
}
#[test]
fn watchdog_signal_short_circuits_nu_loop() {
let mut e = engine();
e.trigger_interrupt();
let out = e.eval("1..1_000_000 | each {|x| $x}");
e.reset_interrupt();
assert!(
out.stderr.to_lowercase().contains("interrupt")
|| out.success,
"engine should either honour interrupt or complete cleanly; got success={} stderr={:?}",
out.success, out.stderr
);
}
#[test]
fn extract_token_command_position() {
let (s, t, c) = extract_token_for_completion("", 0);
assert_eq!((s, t.as_str(), c), (0, "", true));
let (s, t, c) = extract_token_for_completion("ls", 2);
assert_eq!((s, t.as_str(), c), (0, "ls", true));
let (s, t, c) = extract_token_for_completion(" ls", 4);
assert_eq!((s, t.as_str(), c), (2, "ls", true));
let (s, t, c) = extract_token_for_completion("ls | wh", 7);
assert_eq!((s, t.as_str(), c), (5, "wh", true));
let (s, t, c) = extract_token_for_completion("a; b", 4);
assert_eq!((s, t.as_str(), c), (3, "b", true));
}
#[test]
fn extract_token_argument_position() {
let (s, t, c) = extract_token_for_completion("ls Doc", 6);
assert_eq!((s, t.as_str(), c), (3, "Doc", false));
let (s, t, c) = extract_token_for_completion("cd src/li", 9);
assert_eq!((s, t.as_str(), c), (3, "src/li", false));
}
#[test]
fn longest_common_prefix_works() {
assert_eq!(
longest_common_prefix(&[
"stream".into(),
"string".into(),
"str".into(),
]),
"str"
);
assert_eq!(
longest_common_prefix(&["abc".into(), "abc".into()]),
"abc"
);
assert_eq!(
longest_common_prefix(&["abc".into(), "xyz".into()]),
""
);
assert_eq!(longest_common_prefix(&[]), "");
}
#[test]
fn complete_path_returns_local_entries() {
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::create_dir(tmp.path().join("subdir")).unwrap();
std::fs::write(tmp.path().join("file.txt"), b"x").unwrap();
std::fs::write(tmp.path().join("file2.txt"), b"y").unwrap();
std::fs::write(tmp.path().join("other"), b"z").unwrap();
let m = complete_path(tmp.path(), "");
let names: Vec<&str> = m.iter().map(String::as_str).collect();
assert!(names.contains(&"file.txt"));
assert!(names.contains(&"file2.txt"));
assert!(names.contains(&"other"));
assert!(names.contains(&"subdir/"), "dir entry must trail with /");
let m = complete_path(tmp.path(), "file");
assert_eq!(m, vec!["file.txt".to_string(), "file2.txt".to_string()]);
let m = complete_path(tmp.path(), "subdir/");
assert!(m.is_empty());
}
#[test]
fn complete_command_finds_nu_builtins() {
let e = engine();
let c = e.complete("wh", 2);
assert!(c.token_start == 0);
assert_eq!(c.token, "wh");
assert!(
c.matches.iter().any(|m| m == "where"),
"expected `where` in matches, got {:?}",
c.matches
);
}
#[test]
fn complete_argument_uses_engine_cwd() {
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::write(tmp.path().join("alpha.txt"), b"a").unwrap();
std::fs::write(tmp.path().join("alphax.txt"), b"b").unwrap();
std::fs::write(tmp.path().join("beta.txt"), b"c").unwrap();
let e = Engine::new(tmp.path());
let c = e.complete("ls al", 5);
assert_eq!(c.token, "al");
let names: Vec<&str> = c.matches.iter().map(String::as_str).collect();
assert!(names.contains(&"alpha.txt"));
assert!(names.contains(&"alphax.txt"));
assert!(!names.contains(&"beta.txt"));
}
#[test]
fn basename_lower_strips_caret_and_path() {
assert_eq!(basename_lower("vim"), "vim");
assert_eq!(basename_lower("^vim"), "vim");
assert_eq!(basename_lower("/usr/bin/vim"), "vim");
assert_eq!(basename_lower("^/usr/local/bin/Vim"), "vim");
assert_eq!(basename_lower("VIM"), "vim");
assert_eq!(basename_lower("/"), "");
}
#[test]
fn engine_state_isolated_across_evals_after_huge_output() {
let mut e = engine();
let h = e.eval("help commands");
assert!(h.success);
assert!(
h.stdout.len() > 10_000,
"help commands should produce a lot of output, got {} bytes",
h.stdout.len()
);
let a = e.eval("1 + 1");
let b = e.eval(r#""xyz" | str length"#);
let c = e.eval("ls");
assert_eq!(a.stdout.trim(), "2", "1+1 broken after help");
assert_eq!(b.stdout.trim(), "3", "str length broken after help");
assert!(c.success, "ls broken after help: {c:?}", c=c.stderr);
assert!(
!c.stdout.contains("zip-build"),
"ls leaked help-commands content"
);
}
#[test]
fn consecutive_evals_dont_replay_each_other() {
let mut e = engine();
let first = e.eval("help commands");
assert!(first.success, "help commands failed: {:?}", first.stderr);
let first_marker = "Commands"; assert!(
first.stdout.contains(first_marker) || !first.stdout.is_empty(),
"expected SOME help output, got {:?}",
first.stdout
);
let second = e.eval("1 + 1");
assert_eq!(
second.stdout.trim(),
"2",
"second eval (1+1) should return 2, got {:?} — likely replay bug",
second.stdout,
);
assert!(
!second.stdout.contains(first_marker),
"second eval leaked help-commands output: {:?}",
second.stdout
);
}
#[test]
fn external_command_stderr_is_captured_not_inherited() {
let mut e = engine();
let out = e.eval(r#"^/bin/sh -c "echo stderr-probe-13579 1>&2""#);
let combined = format!("{}\n{}", out.stdout, out.stderr);
assert!(
combined.contains("stderr-probe-13579"),
"expected stderr bytes captured (stdout or stderr), got stdout={:?} stderr={:?}",
out.stdout, out.stderr,
);
}
#[test]
fn external_command_output_is_captured_not_inherited() {
let mut e = engine();
let out = e.eval("^/bin/echo inkhaven-shell-capture-probe");
assert!(
out.success,
"echo exit !=0: stdout={:?} stderr={:?}",
out.stdout, out.stderr,
);
assert!(
out.stdout.contains("inkhaven-shell-capture-probe"),
"expected captured stdout to contain probe, got {:?}",
out.stdout,
);
}
#[test]
fn strip_ansi_clears_csi_sequences() {
assert_eq!(
strip_ansi("\x1b[31mred\x1b[0m"),
"red",
);
assert_eq!(
strip_ansi("a\x1b[1;1Hb\x1b[2Kc"),
"abc",
);
assert_eq!(
strip_ansi("\x1b[33;1mwarn:\x1b[0m message"),
"warn: message",
);
assert_eq!(
strip_ansi("plain ascii\nand a newline"),
"plain ascii\nand a newline",
);
}
#[test]
fn highlight_reconstructs_input_byte_for_byte() {
let e = engine();
let cases = [
"1 + 1",
"ls",
r#"echo "hello world""#,
"let x = 42",
"",
" ",
];
for input in cases {
let spans = e.highlight(input);
let rebuilt: String = spans.iter().map(|(s, _)| s.as_str()).collect();
assert_eq!(rebuilt, input, "round-trip failed for {:?}", input);
}
}
#[test]
fn highlight_produces_some_styling() {
let e = engine();
let spans = e.highlight("ls --long");
let any_styled = spans
.iter()
.any(|(_, style)| style.fg.is_some() || !style.add_modifier.is_empty());
assert!(
any_styled,
"expected at least one styled token, got {spans:?}",
);
}
#[test]
fn pwd_env_var_set_to_project_root() {
let dir: PathBuf = std::env::temp_dir();
let mut e = Engine::new(&dir);
let out = e.eval("$env.PWD");
assert!(out.success, "stderr was: {}", out.stderr);
let raw = dir.to_string_lossy().to_string();
let expected = raw.trim_end_matches('/').to_string();
let expected = if expected.is_empty() { "/" } else { &expected };
assert_eq!(out.stdout.trim(), expected);
}
}