Skip to main content

kaish_repl/
lib.rs

1//! kaish REPL — Interactive shell for 会sh.
2//!
3//! This REPL provides an interactive interface to the kaish kernel.
4//! It handles:
5//! - Multi-line input via keyword/quote balancing (if/for/while → fi/done)
6//! - Tab completion for commands, variables, and paths
7//! - Command execution via the Kernel
8//! - Result formatting with OutputData
9//! - Command history via rustyline
10
11pub mod format;
12
13use std::borrow::Cow;
14use std::io::IsTerminal;
15use std::path::PathBuf;
16
17use anyhow::{Context, Result};
18use rustyline::completion::{Completer, FilenameCompleter, Pair};
19use rustyline::error::ReadlineError;
20use rustyline::highlight::Highlighter;
21use rustyline::hint::{Hint, Hinter};
22use rustyline::history::DefaultHistory;
23use rustyline::validate::{ValidationContext, ValidationResult, Validator};
24use rustyline::{Editor, Helper};
25use tokio::runtime::Runtime;
26
27use kaish_client::{EmbeddedClient, KernelClient};
28use kaish_kernel::ast::Value;
29use kaish_kernel::interpreter::ExecResult;
30use kaish_kernel::{ExecuteOptions, Kernel, KernelConfig};
31
32/// Snapshot the OS environment as a map of `String` → `Value::String`.
33///
34/// The kernel itself is hermetic — it never reads `std::env::vars()`. The REPL
35/// (and other shell-like frontends) call this and pass the result via
36/// `KernelConfig::with_initial_vars` so users get their normal `PATH`, `HOME`,
37/// `EDITOR`, etc. propagated to subprocesses.
38pub fn os_env_vars() -> std::collections::HashMap<String, Value> {
39    std::env::vars()
40        .map(|(k, v)| (k, Value::String(v)))
41        .collect()
42}
43
44/// Build per-call trace context from the W3C environment variables an upstream
45/// tracer sets before invoking kaish: `TRACEPARENT`, `TRACESTATE`, and
46/// `BAGGAGE` (the W3C `baggage` header format, `key1=value1,key2=value2`).
47///
48/// This is the REPL frontend's analogue of the MCP server lifting trace context
49/// off the request `_meta`. The kernel is hermetic and never reads env, so a
50/// frontend must hand it the context explicitly via [`ExecuteOptions`]. Used by
51/// the non-interactive entry points (`kaish script.kai`, `kaish -c '…'`) so a
52/// wrapper like `otel-cli exec -- kaish …` traces across the kaish boundary.
53pub fn trace_options_from_env() -> ExecuteOptions {
54    parse_trace_env(|key| std::env::var(key).ok())
55}
56
57/// Pure core of [`trace_options_from_env`], parameterised on the env lookup so
58/// it is testable without mutating process-global environment.
59fn parse_trace_env(get: impl Fn(&str) -> Option<String>) -> ExecuteOptions {
60    let mut opts = ExecuteOptions::new();
61
62    // `tracestate` is meaningless without a `traceparent` (W3C), and the kernel
63    // drops it in that case anyway, so only read it alongside a traceparent.
64    if let Some(traceparent) = get("TRACEPARENT").filter(|s| !s.is_empty()) {
65        opts = opts.with_traceparent(traceparent);
66        if let Some(tracestate) = get("TRACESTATE").filter(|s| !s.is_empty()) {
67            opts = opts.with_tracestate(tracestate);
68        }
69    }
70
71    if let Some(raw) = get("BAGGAGE").filter(|s| !s.is_empty()) {
72        let baggage = parse_w3c_baggage(&raw);
73        if !baggage.is_empty() {
74            opts = opts.with_baggage(baggage);
75        }
76    }
77
78    opts
79}
80
81/// Parse a W3C `baggage` header value (`k1=v1,k2=v2`) into a map.
82///
83/// Entries without a `=` are skipped (malformed — not silently turned into a
84/// key with an empty value). Surrounding whitespace is trimmed. W3C allows
85/// `;`-delimited properties after a value (`k=v;meta`); those are dropped,
86/// keeping just the value. Percent-encoded values are taken verbatim —
87/// identifiers in practice don't need decoding.
88fn parse_w3c_baggage(raw: &str) -> std::collections::BTreeMap<String, String> {
89    let mut map = std::collections::BTreeMap::new();
90    for entry in raw.split(',') {
91        let entry = entry.trim();
92        if entry.is_empty() {
93            continue;
94        }
95        let Some((key, value)) = entry.split_once('=') else {
96            tracing::debug!(entry, "skipping malformed BAGGAGE entry (no '=')");
97            continue;
98        };
99        let value = value.split(';').next().unwrap_or(value);
100        map.insert(key.trim().to_string(), value.trim().to_string());
101    }
102    map
103}
104
105// ── Process result ──────────────────────────────────────────────────
106
107/// Result from processing a line of input.
108#[derive(Debug)]
109pub enum ProcessResult {
110    /// Output to display to the user.
111    Output(String),
112    /// No output (empty line, etc.).
113    Empty,
114    /// Exit the REPL.
115    Exit,
116}
117
118// ── KaishHelper ─────────────────────────────────────────────────────
119
120/// Rustyline helper providing validation, completion, highlighting, and hints.
121///
122/// Holds the kernel as a `KernelClient` trait object so completion is driven
123/// purely through the client abstraction — the same surface any embedder
124/// (including a future remote client) would use.
125struct KaishHelper {
126    client: Box<dyn KernelClient>,
127    handle: tokio::runtime::Handle,
128    path_completer: FilenameCompleter,
129}
130
131impl KaishHelper {
132    fn new(client: Box<dyn KernelClient>, handle: tokio::runtime::Handle) -> Self {
133        Self {
134            client,
135            handle,
136            path_completer: FilenameCompleter::new(),
137        }
138    }
139
140    /// Determine if the input is incomplete (needs more lines).
141    ///
142    /// Uses a heuristic approach: count keyword depth (if/for/while increment,
143    /// fi/done/esac decrement), check for unclosed quotes, and trailing backslash.
144    fn is_incomplete(&self, input: &str) -> bool {
145        // Trailing backslash = line continuation
146        if input.trim_end().ends_with('\\') {
147            return true;
148        }
149
150        let mut depth: i32 = 0;
151        let mut in_single_quote = false;
152        let mut in_double_quote = false;
153
154        for line in input.lines() {
155            let mut chars = line.chars().peekable();
156
157            while let Some(ch) = chars.next() {
158                match ch {
159                    '\\' if !in_single_quote => {
160                        // Skip escaped character
161                        chars.next();
162                    }
163                    '\'' if !in_double_quote => {
164                        in_single_quote = !in_single_quote;
165                    }
166                    '"' if !in_single_quote => {
167                        in_double_quote = !in_double_quote;
168                    }
169                    _ => {}
170                }
171            }
172        }
173
174        // Unclosed quotes
175        if in_single_quote || in_double_quote {
176            return true;
177        }
178
179        // Count keyword depth from words (outside quotes)
180        for word in shell_words(input) {
181            match word.as_str() {
182                "if" | "for" | "while" | "case" => depth += 1,
183                "fi" | "done" | "esac" => depth -= 1,
184                "then" | "else" | "elif" => {
185                    // These don't change depth, they're part of an if block
186                }
187                _ => {}
188            }
189        }
190
191        if depth > 0 {
192            return true;
193        }
194
195        // Heredoc continuation: ask the lexer whether any heredoc started
196        // without seeing its closing delimiter line. Single source of truth
197        // with the parser — no parallel hand-rolled heredoc scanner.
198        // Other lexer errors (invalid token, etc.) aren't continuation
199        // signals; let the kernel surface them on submit.
200        if let Err(errs) = kaish_kernel::lexer::tokenize(input)
201            && errs
202                .iter()
203                .any(|e| matches!(e.token, kaish_kernel::lexer::LexerError::UnterminatedHeredoc { .. }))
204        {
205            return true;
206        }
207
208        false
209    }
210}
211
212/// Extract "words" from shell input, skipping quoted content.
213/// Only used for keyword counting — doesn't need to be a full tokenizer.
214fn shell_words(input: &str) -> Vec<String> {
215    let mut words = Vec::new();
216    let mut current = String::new();
217    let mut in_single_quote = false;
218    let mut in_double_quote = false;
219    let mut in_comment = false;
220    let mut prev_was_backslash = false;
221
222    for ch in input.chars() {
223        // Comments run to end of line
224        if in_comment {
225            if ch == '\n' {
226                in_comment = false;
227            }
228            continue;
229        }
230
231        if prev_was_backslash {
232            prev_was_backslash = false;
233            if !in_single_quote {
234                current.push(ch);
235                continue;
236            }
237        }
238
239        match ch {
240            '\\' if !in_single_quote => {
241                prev_was_backslash = true;
242            }
243            '\'' if !in_double_quote => {
244                in_single_quote = !in_single_quote;
245            }
246            '"' if !in_single_quote => {
247                in_double_quote = !in_double_quote;
248            }
249            '#' if !in_single_quote && !in_double_quote => {
250                if !current.is_empty() {
251                    words.push(std::mem::take(&mut current));
252                }
253                in_comment = true;
254            }
255            _ if ch.is_whitespace() && !in_single_quote && !in_double_quote => {
256                if !current.is_empty() {
257                    words.push(std::mem::take(&mut current));
258                }
259            }
260            ';' if !in_single_quote && !in_double_quote => {
261                // Semicolons split words too (e.g. "if true; then")
262                if !current.is_empty() {
263                    words.push(std::mem::take(&mut current));
264                }
265            }
266            _ => {
267                current.push(ch);
268            }
269        }
270    }
271
272    if !current.is_empty() {
273        words.push(current);
274    }
275
276    words
277}
278
279// ── Completion context ──────────────────────────────────────────────
280
281/// What kind of completion to offer based on cursor context.
282enum CompletionContext {
283    /// Start of line, after |, ;, &&, || → complete command names
284    Command,
285    /// After $ or within ${ → complete variable names
286    Variable,
287    /// Everything else → complete file paths
288    Path,
289}
290
291/// Characters that delimit words for completion purposes.
292fn is_word_delimiter(c: char) -> bool {
293    c.is_whitespace() || matches!(c, '|' | ';' | '(' | ')')
294}
295
296/// Detect the completion context by scanning backwards from cursor position.
297fn detect_completion_context(line: &str, pos: usize) -> CompletionContext {
298    let before = &line[..pos];
299
300    // Check for variable completion: look for $ before cursor
301    // Walk backwards to find if we're in a $VAR or ${VAR context
302    // But NOT $( which is command substitution
303    let bytes = before.as_bytes();
304    let mut i = pos;
305    while i > 0 {
306        i -= 1;
307        let b = bytes[i];
308        if b == b'$' {
309            // $( is command substitution, not variable
310            if i + 1 < pos && bytes[i + 1] == b'(' {
311                break;
312            }
313            return CompletionContext::Variable;
314        }
315        if b == b'{' && i > 0 && bytes[i - 1] == b'$' {
316            return CompletionContext::Variable;
317        }
318        // Stop scanning if we hit a non-identifier character
319        if !b.is_ascii_alphanumeric() && b != b'_' && b != b'{' {
320            break;
321        }
322    }
323
324    // Check for command position: start of line, or after pipe/semicolon/logical operators/$(
325    let trimmed = before.trim();
326    if trimmed.is_empty()
327        || trimmed.ends_with('|')
328        || trimmed.ends_with(';')
329        || trimmed.ends_with("&&")
330        || trimmed.ends_with("||")
331        || trimmed.ends_with("$(")
332    {
333        return CompletionContext::Command;
334    }
335
336    // Find start of current "word" (using delimiters that include parentheses)
337    let word_start = before.rfind(is_word_delimiter);
338    match word_start {
339        None => CompletionContext::Command, // First word on the line
340        Some(idx) => {
341            // Check what's before the word
342            let prefix = before[..=idx].trim();
343            if prefix.is_empty()
344                || prefix.ends_with('|')
345                || prefix.ends_with(';')
346                || prefix.ends_with("&&")
347                || prefix.ends_with("||")
348                || prefix.ends_with("$(")
349                || prefix.ends_with("then")
350                || prefix.ends_with("else")
351                || prefix.ends_with("do")
352            {
353                CompletionContext::Command
354            } else {
355                CompletionContext::Path
356            }
357        }
358    }
359}
360
361// ── Rustyline trait impls ───────────────────────────────────────────
362
363impl Completer for KaishHelper {
364    type Candidate = Pair;
365
366    fn complete(
367        &self,
368        line: &str,
369        pos: usize,
370        ctx: &rustyline::Context<'_>,
371    ) -> rustyline::Result<(usize, Vec<Pair>)> {
372        match detect_completion_context(line, pos) {
373            CompletionContext::Command => {
374                // Find the prefix being typed
375                let before = &line[..pos];
376                let word_start = before
377                    .rfind(is_word_delimiter)
378                    .map(|i| i + 1)
379                    .unwrap_or(0);
380                let prefix = &line[word_start..pos];
381
382                let mut candidates = Vec::new();
383
384                // Tool/builtin names, via the client. Completion is a
385                // best-effort affordance: if the client errors, warn and
386                // offer nothing rather than failing the keystroke.
387                let schemas = match self.handle.block_on(self.client.tool_schemas()) {
388                    Ok(schemas) => schemas,
389                    Err(e) => {
390                        tracing::warn!("completion: tool_schemas failed: {e}");
391                        Vec::new()
392                    }
393                };
394                for schema in schemas {
395                    if schema.name.starts_with(prefix) {
396                        candidates.push(Pair {
397                            display: schema.name.clone(),
398                            replacement: schema.name.clone(),
399                        });
400                    }
401                }
402
403                candidates.sort_by(|a, b| a.display.cmp(&b.display));
404
405                Ok((word_start, candidates))
406            }
407
408            CompletionContext::Variable => {
409                // Find where the variable name starts (after $ or ${)
410                let before = &line[..pos];
411                let (var_start, prefix) = if let Some(brace_pos) = before.rfind("${") {
412                    let name_start = brace_pos + 2;
413                    (brace_pos, &line[name_start..pos])
414                } else if let Some(dollar_pos) = before.rfind('$') {
415                    let name_start = dollar_pos + 1;
416                    (dollar_pos, &line[name_start..pos])
417                } else {
418                    return Ok((pos, vec![]));
419                };
420
421                // list_vars is async, use block_on. Best-effort: on error,
422                // warn and offer no variable candidates.
423                let vars = match self.handle.block_on(self.client.list_vars()) {
424                    Ok(vars) => vars,
425                    Err(e) => {
426                        tracing::warn!("completion: list_vars failed: {e}");
427                        Vec::new()
428                    }
429                };
430
431                let mut candidates: Vec<Pair> = vars
432                    .into_iter()
433                    .filter(|(name, _)| name.starts_with(prefix))
434                    .map(|(name, _)| {
435                        // Reconstruct the full $VAR or ${VAR} replacement
436                        let (display, replacement) = if before.contains("${") {
437                            (name.clone(), format!("${{{name}}}"))
438                        } else {
439                            (name.clone(), format!("${name}"))
440                        };
441                        Pair {
442                            display,
443                            replacement,
444                        }
445                    })
446                    .collect();
447
448                candidates.sort_by(|a, b| a.display.cmp(&b.display));
449
450                Ok((var_start, candidates))
451            }
452
453            CompletionContext::Path => self.path_completer.complete(line, pos, ctx),
454        }
455    }
456}
457
458impl Validator for KaishHelper {
459    fn validate(&self, ctx: &mut ValidationContext) -> rustyline::Result<ValidationResult> {
460        let input = ctx.input();
461        if input.trim().is_empty() {
462            return Ok(ValidationResult::Valid(None));
463        }
464        if self.is_incomplete(input) {
465            Ok(ValidationResult::Incomplete)
466        } else {
467            Ok(ValidationResult::Valid(None))
468        }
469    }
470}
471
472impl Highlighter for KaishHelper {
473    fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> {
474        Cow::Borrowed(hint)
475    }
476}
477
478/// No-op hint type — we don't provide inline hints yet.
479struct NoHint;
480impl Hint for NoHint {
481    fn display(&self) -> &str {
482        ""
483    }
484    fn completion(&self) -> Option<&str> {
485        None
486    }
487}
488
489impl Hinter for KaishHelper {
490    type Hint = NoHint;
491
492    fn hint(&self, _line: &str, _pos: usize, _ctx: &rustyline::Context<'_>) -> Option<NoHint> {
493        None
494    }
495}
496
497impl Helper for KaishHelper {}
498
499// ── REPL core ───────────────────────────────────────────────────────
500
501/// REPL configuration and state.
502pub struct Repl {
503    client: EmbeddedClient,
504    runtime: Runtime,
505}
506
507impl Repl {
508    /// Create a new REPL instance with passthrough filesystem access.
509    pub fn new() -> Result<Self> {
510        let config = KernelConfig::repl()
511            .with_interactive(true)
512            .with_initial_vars(os_env_vars());
513        let mut kernel = Kernel::new(config).context("Failed to create kernel")?;
514        let runtime = Runtime::new().context("Failed to create tokio runtime")?;
515
516        // Initialize terminal job control if stdin is a TTY. See `new()`.
517        #[cfg(unix)]
518        if std::io::stdin().is_terminal() {
519            kernel.init_terminal();
520        }
521
522        Ok(Self {
523            client: EmbeddedClient::new(kernel),
524            runtime,
525        })
526    }
527
528    /// Create a new REPL with a custom kernel configuration.
529    pub fn with_config(config: KernelConfig) -> Result<Self> {
530        let mut kernel = Kernel::new(config).context("Failed to create kernel")?;
531        let runtime = Runtime::new().context("Failed to create tokio runtime")?;
532
533        // Initialize terminal job control if stdin is a TTY. This needs the
534        // owned kernel (&mut, local-TTY setup), so it stays as pre-wrap
535        // kernel setup rather than a client-trait call.
536        #[cfg(unix)]
537        if std::io::stdin().is_terminal() {
538            kernel.init_terminal();
539        }
540
541        Ok(Self {
542            client: EmbeddedClient::new(kernel),
543            runtime,
544        })
545    }
546
547    /// Create a new REPL rooted at the given path.
548    pub fn with_root(root: PathBuf) -> Result<Self> {
549        let config = KernelConfig::repl()
550            .with_cwd(root)
551            .with_initial_vars(os_env_vars());
552        Self::with_config(config)
553    }
554
555    /// Process a single line of input.
556    pub fn process_line(&mut self, line: &str) -> ProcessResult {
557        let trimmed = line.trim();
558
559        // Skip empty lines
560        if trimmed.is_empty() {
561            return ProcessResult::Empty;
562        }
563
564        // Intercept exit/quit before kernel dispatch
565        if matches!(trimmed, "exit" | "quit") {
566            return ProcessResult::Exit;
567        }
568
569        // Execute via the client with SIGINT handling.
570        // A per-execute signal listener catches Ctrl-C during execution,
571        // cancels the kernel, and returns exit code 130.
572        let client = self.client.clone();
573        let input = trimmed.to_string();
574        let result = self.runtime.block_on(async {
575            let mut sigint = tokio::signal::unix::signal(
576                tokio::signal::unix::SignalKind::interrupt(),
577            )?;
578            tokio::select! {
579                result = client.execute(&input) => result,
580                _ = sigint.recv() => {
581                    client.cancel().await?;
582                    Ok(ExecResult::failure(130, ""))
583                }
584            }
585        });
586
587        match result {
588            Ok(exec_result) => {
589                if exec_result.ok() && !exec_result.has_output() && exec_result.text_out().is_empty() {
590                    ProcessResult::Empty
591                } else {
592                    ProcessResult::Output(format_result(&exec_result))
593                }
594            }
595            Err(e) => ProcessResult::Output(format!("Error: {}", e)),
596        }
597    }
598}
599
600impl Default for Repl {
601    #[allow(clippy::expect_used)]
602    fn default() -> Self {
603        Self::new().expect("Failed to create REPL")
604    }
605}
606
607// ── Formatting ──────────────────────────────────────────────────────
608
609/// Format an ExecResult for display.
610///
611/// Uses OutputData when available, otherwise falls back to status+output format.
612fn format_result(result: &ExecResult) -> String {
613    // If there's structured output, use the formatter
614    if result.has_output() {
615        let context = format::detect_context();
616        let formatted = format::format_output(result, context);
617
618        // For failures, append error info
619        if !result.ok() && !result.err.is_empty() {
620            return format!("{}\n✗ code={} err=\"{}\"", formatted, result.code, result.err);
621        }
622        return formatted;
623    }
624
625    // No structured output — just pass through the raw text.
626    // Success: show output directly (no status prefix).
627    // Failure: show stderr or exit code so the user notices.
628    if result.ok() {
629        result.text_out().into_owned()
630    } else {
631        let mut output = String::new();
632        let text = result.text_out();
633        if !text.is_empty() {
634            output.push_str(&text);
635            if !output.ends_with('\n') {
636                output.push('\n');
637            }
638        }
639        if !result.err.is_empty() {
640            output.push_str(&format!("✗ {}", result.err));
641        } else {
642            output.push_str(&format!("✗ [exit {}]", result.code));
643        }
644        output
645    }
646}
647
648// ── History ─────────────────────────────────────────────────────────
649
650/// Save REPL history to disk.
651fn save_history(rl: &mut Editor<KaishHelper, DefaultHistory>, history_path: &Option<PathBuf>) {
652    if let Some(path) = history_path {
653        if let Some(parent) = path.parent()
654            && let Err(e) = std::fs::create_dir_all(parent) {
655                tracing::warn!("Failed to create history directory: {}", e);
656            }
657        if let Err(e) = rl.save_history(path) {
658            tracing::warn!("Failed to save history: {}", e);
659        }
660    }
661}
662
663/// Load REPL history from disk.
664fn load_history(rl: &mut Editor<KaishHelper, DefaultHistory>) -> Option<PathBuf> {
665    let history_path = directories::BaseDirs::new()
666        .map(|b| b.data_dir().join("kaish").join("history.txt"));
667    if let Some(ref path) = history_path
668        && let Err(e) = rl.load_history(path) {
669            let is_not_found = matches!(&e, ReadlineError::Io(io_err) if io_err.kind() == std::io::ErrorKind::NotFound);
670            if !is_not_found {
671                tracing::warn!("Failed to load history: {}", e);
672            }
673        }
674    history_path
675}
676
677// ── RC file and prompt ──────────────────────────────────────────────
678
679/// Load the RC file for interactive sessions.
680///
681/// Search order: `$KAISH_INIT` → `~/.config/kaish/init.kai` → `~/.kaishrc`
682fn load_rc_file(repl: &Repl) {
683    let candidates: Vec<PathBuf> = if let Ok(path) = std::env::var("KAISH_INIT") {
684        vec![PathBuf::from(path)]
685    } else {
686        vec![
687            kaish_kernel::paths::config_dir().join("init.kai"),
688            directories::BaseDirs::new()
689                .map(|b| b.home_dir().join(".kaishrc"))
690                .unwrap_or_else(|| PathBuf::from("/.kaishrc")),
691        ]
692    };
693
694    for path in &candidates {
695        if path.is_file() {
696            let cmd = format!(r#"source "{}""#, path.display());
697            if let Err(e) = repl.runtime.block_on(repl.client.execute(&cmd)) {
698                eprintln!("kaish: warning: error sourcing {}: {}", path.display(), e);
699            }
700            return;
701        }
702    }
703}
704
705/// Resolve the prompt string: call `kaish_prompt()` if defined, else default.
706fn resolve_prompt(repl: &Repl) -> String {
707    let has_fn = repl
708        .runtime
709        .block_on(repl.client.has_function("kaish_prompt"))
710        .unwrap_or(false);
711    if has_fn {
712        if let Ok(result) = repl.runtime.block_on(repl.client.execute("kaish_prompt")) {
713            if result.ok() {
714                let text = result.text_out().trim_end().to_string();
715                if !text.is_empty() {
716                    return text;
717                }
718            }
719        }
720    }
721    "会sh> ".to_string()
722}
723
724// ── Entry points ────────────────────────────────────────────────────
725
726/// Run the REPL with optional overlay mode.
727///
728/// When `overlay` is `true`, writes are virtual (copy-on-write overlay).
729/// Use `kaish-vfs commit` to apply changes to real files.
730pub fn run_with_overlay(overlay: bool) -> Result<()> {
731    println!("会sh — kaish v{}", env!("CARGO_PKG_VERSION"));
732    use kaish_kernel::help::{compose, Recipe, SchemaContent};
733
734    if overlay {
735        println!("[overlay mode: writes are virtual — use 'kaish-vfs commit' to apply]");
736    }
737    println!("{}", compose(&Recipe::repl_welcome(), &SchemaContent::new(&[])));
738
739    let config = KernelConfig::repl()
740        .with_interactive(true)
741        .with_initial_vars(os_env_vars())
742        .with_overlay(overlay);
743    let mut repl = Repl::with_config(config)?;
744
745    // Source RC file (interactive only)
746    load_rc_file(&repl);
747
748    let helper = KaishHelper::new(
749        Box::new(repl.client.clone()),
750        repl.runtime.handle().clone(),
751    );
752
753    let mut rl: Editor<KaishHelper, DefaultHistory> =
754        Editor::new().context("Failed to create editor")?;
755    rl.set_helper(Some(helper));
756
757    let history_path = load_history(&mut rl);
758
759    loop {
760        let prompt_string = resolve_prompt(&repl);
761        let prompt: &str = &prompt_string;
762
763        match rl.readline(prompt) {
764            Ok(line) => {
765                if let Err(e) = rl.add_history_entry(line.as_str()) {
766                    tracing::warn!("Failed to add history entry: {}", e);
767                }
768
769                match repl.process_line(&line) {
770                    ProcessResult::Output(output) => {
771                        if output.ends_with('\n') {
772                            print!("{}", output);
773                        } else {
774                            println!("{}", output);
775                        }
776                    }
777                    ProcessResult::Empty => {}
778                    ProcessResult::Exit => {
779                        save_history(&mut rl, &history_path);
780                        return Ok(());
781                    }
782                }
783            }
784            Err(ReadlineError::Interrupted) => {
785                println!("^C");
786                continue;
787            }
788            Err(ReadlineError::Eof) => {
789                println!("^D");
790                break;
791            }
792            Err(err) => {
793                eprintln!("Error: {}", err);
794                break;
795            }
796        }
797    }
798
799    save_history(&mut rl, &history_path);
800
801    Ok(())
802}
803
804/// Run the REPL.
805pub fn run() -> Result<()> {
806    println!("会sh — kaish v{}", env!("CARGO_PKG_VERSION"));
807    // Welcome text comes from the canonical kaish-help corpus (via the kernel
808    // re-export), so it stays in sync with the MCP/embedder instructions.
809    use kaish_kernel::help::{compose, Recipe, SchemaContent};
810    println!("{}", compose(&Recipe::repl_welcome(), &SchemaContent::new(&[])));
811
812    let mut repl = Repl::new()?;
813
814    // Source RC file (interactive only)
815    load_rc_file(&repl);
816
817    // Build the helper with a client handle (sharing the REPL's kernel) and
818    // a runtime handle. Boxed as a trait object so completion runs purely
819    // through the KernelClient abstraction.
820    let helper = KaishHelper::new(
821        Box::new(repl.client.clone()),
822        repl.runtime.handle().clone(),
823    );
824
825    let mut rl: Editor<KaishHelper, DefaultHistory> =
826        Editor::new().context("Failed to create editor")?;
827    rl.set_helper(Some(helper));
828
829    let history_path = load_history(&mut rl);
830
831    loop {
832        // Dynamic prompt: call kaish_prompt() if defined, else default
833        let prompt_string = resolve_prompt(&repl);
834        let prompt: &str = &prompt_string;
835
836        match rl.readline(prompt) {
837            Ok(line) => {
838                if let Err(e) = rl.add_history_entry(line.as_str()) {
839                    tracing::warn!("Failed to add history entry: {}", e);
840                }
841
842                match repl.process_line(&line) {
843                    ProcessResult::Output(output) => {
844                        if output.ends_with('\n') {
845                            print!("{}", output);
846                        } else {
847                            println!("{}", output);
848                        }
849                    }
850                    ProcessResult::Empty => {}
851                    ProcessResult::Exit => {
852                        save_history(&mut rl, &history_path);
853                        return Ok(());
854                    }
855                }
856            }
857            Err(ReadlineError::Interrupted) => {
858                println!("^C");
859                continue;
860            }
861            Err(ReadlineError::Eof) => {
862                println!("^D");
863                break;
864            }
865            Err(err) => {
866                eprintln!("Error: {}", err);
867                break;
868            }
869        }
870    }
871
872    save_history(&mut rl, &history_path);
873
874    Ok(())
875}
876
877// ── Tests ───────────────────────────────────────────────────────────
878
879#[cfg(test)]
880mod tests {
881    use super::*;
882
883    /// Build an env getter over a fixed set of pairs — keeps trace-env tests
884    /// off the process-global environment (which races under `cargo test`).
885    fn env_of<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
886        move |key| {
887            pairs
888                .iter()
889                .find(|(k, _)| *k == key)
890                .map(|(_, v)| v.to_string())
891        }
892    }
893
894    #[test]
895    fn trace_env_empty_yields_default_options() {
896        let opts = parse_trace_env(env_of(&[]));
897        assert!(opts.traceparent.is_none());
898        assert!(opts.tracestate.is_none());
899        assert!(opts.baggage.is_empty());
900    }
901
902    #[test]
903    fn trace_env_reads_traceparent_and_tracestate() {
904        let opts = parse_trace_env(env_of(&[
905            ("TRACEPARENT", "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"),
906            ("TRACESTATE", "vendor=opaque"),
907        ]));
908        assert_eq!(
909            opts.traceparent.as_deref(),
910            Some("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"),
911        );
912        assert_eq!(opts.tracestate.as_deref(), Some("vendor=opaque"));
913    }
914
915    #[test]
916    fn trace_env_drops_tracestate_without_traceparent() {
917        let opts = parse_trace_env(env_of(&[("TRACESTATE", "vendor=opaque")]));
918        assert!(opts.traceparent.is_none());
919        assert!(opts.tracestate.is_none(), "tracestate alone is meaningless");
920    }
921
922    #[test]
923    fn trace_env_treats_empty_string_as_unset() {
924        let opts = parse_trace_env(env_of(&[("TRACEPARENT", ""), ("BAGGAGE", "")]));
925        assert!(opts.traceparent.is_none());
926        assert!(opts.baggage.is_empty());
927    }
928
929    #[test]
930    fn trace_env_parses_baggage() {
931        let opts = parse_trace_env(env_of(&[("BAGGAGE", "owner=atobey,tenant=acme")]));
932        assert_eq!(opts.baggage.get("owner").map(String::as_str), Some("atobey"));
933        assert_eq!(opts.baggage.get("tenant").map(String::as_str), Some("acme"));
934    }
935
936    #[test]
937    fn baggage_drops_properties_and_skips_malformed() {
938        // `k=v;prop` keeps only `v`; a member with no `=` is skipped; whitespace
939        // around members and around `=` is trimmed.
940        let map = parse_w3c_baggage("owner=atobey;ttl=60 , broken , tenant = acme ");
941        assert_eq!(map.get("owner").map(String::as_str), Some("atobey"));
942        assert_eq!(map.get("tenant").map(String::as_str), Some("acme"));
943        assert!(!map.contains_key("broken"), "member without '=' is skipped");
944        assert_eq!(map.len(), 2);
945    }
946
947    #[test]
948    fn test_shell_words_simple() {
949        assert_eq!(shell_words("echo hello world"), vec!["echo", "hello", "world"]);
950    }
951
952    #[test]
953    fn test_shell_words_semicolons() {
954        assert_eq!(shell_words("if true; then"), vec!["if", "true", "then"]);
955    }
956
957    #[test]
958    fn test_shell_words_quoted() {
959        // Quoted content is a single word (spaces preserved inside)
960        assert_eq!(shell_words("echo \"hello world\""), vec!["echo", "hello world"]);
961    }
962
963    #[test]
964    fn test_shell_words_single_quoted() {
965        // Keywords inside quotes are not counted
966        assert_eq!(shell_words("echo 'if then fi'"), vec!["echo", "if then fi"]);
967    }
968
969    #[test]
970    fn test_is_incomplete_if_block() {
971        let helper = make_test_helper();
972        assert!(helper.is_incomplete("if true; then"));
973        assert!(helper.is_incomplete("if true; then\n  echo hello"));
974        assert!(!helper.is_incomplete("if true; then\n  echo hello\nfi"));
975    }
976
977    #[test]
978    fn test_is_incomplete_for_loop() {
979        let helper = make_test_helper();
980        assert!(helper.is_incomplete("for x in 1 2 3; do"));
981        assert!(!helper.is_incomplete("for x in 1 2 3; do\n  echo $x\ndone"));
982    }
983
984    #[test]
985    fn test_is_incomplete_unclosed_single_quote() {
986        let helper = make_test_helper();
987        assert!(helper.is_incomplete("echo 'hello"));
988        assert!(!helper.is_incomplete("echo 'hello'"));
989    }
990
991    #[test]
992    fn test_is_incomplete_unclosed_double_quote() {
993        let helper = make_test_helper();
994        assert!(helper.is_incomplete("echo \"hello"));
995        assert!(!helper.is_incomplete("echo \"hello\""));
996    }
997
998    #[test]
999    fn test_is_incomplete_backslash_continuation() {
1000        let helper = make_test_helper();
1001        assert!(helper.is_incomplete("echo hello \\"));
1002        assert!(!helper.is_incomplete("echo hello"));
1003    }
1004
1005    #[test]
1006    fn test_is_incomplete_while_loop() {
1007        let helper = make_test_helper();
1008        assert!(helper.is_incomplete("while true; do"));
1009        assert!(!helper.is_incomplete("while true; do\n  echo loop\ndone"));
1010    }
1011
1012    #[test]
1013    fn test_is_incomplete_nested() {
1014        let helper = make_test_helper();
1015        assert!(helper.is_incomplete("if true; then\n  for x in 1 2; do"));
1016        assert!(helper.is_incomplete("if true; then\n  for x in 1 2; do\n    echo $x\n  done"));
1017        assert!(!helper.is_incomplete("if true; then\n  for x in 1 2; do\n    echo $x\n  done\nfi"));
1018    }
1019
1020    #[test]
1021    fn test_is_incomplete_empty() {
1022        let helper = make_test_helper();
1023        assert!(!helper.is_incomplete(""));
1024        assert!(!helper.is_incomplete("echo hello"));
1025    }
1026
1027    #[test]
1028    fn test_is_incomplete_unterminated_heredoc() {
1029        let helper = make_test_helper();
1030        // No closing EOF — REPL should prompt for more input.
1031        assert!(helper.is_incomplete("cat <<EOF"));
1032        assert!(helper.is_incomplete("cat <<EOF\nhello"));
1033        // <<-form (tab-strip) and quoted delimiters too.
1034        assert!(helper.is_incomplete("cat <<-DONE\n\thi"));
1035        assert!(helper.is_incomplete("cat <<'EOF'\n$VAR"));
1036        // Closing delimiter on its own line — complete.
1037        assert!(!helper.is_incomplete("cat <<EOF\nhello\nEOF"));
1038        assert!(!helper.is_incomplete("cat <<-DONE\n\thi\n\tDONE"));
1039    }
1040
1041    #[test]
1042    fn test_detect_context_command_start() {
1043        assert!(matches!(
1044            detect_completion_context("", 0),
1045            CompletionContext::Command
1046        ));
1047        assert!(matches!(
1048            detect_completion_context("ec", 2),
1049            CompletionContext::Command
1050        ));
1051    }
1052
1053    #[test]
1054    fn test_detect_context_after_pipe() {
1055        assert!(matches!(
1056            detect_completion_context("echo hello | gr", 15),
1057            CompletionContext::Command
1058        ));
1059    }
1060
1061    #[test]
1062    fn test_detect_context_variable() {
1063        assert!(matches!(
1064            detect_completion_context("echo $HO", 8),
1065            CompletionContext::Variable
1066        ));
1067        assert!(matches!(
1068            detect_completion_context("echo ${HO", 9),
1069            CompletionContext::Variable
1070        ));
1071    }
1072
1073    #[test]
1074    fn test_detect_context_path() {
1075        assert!(matches!(
1076            detect_completion_context("cat /etc/hos", 12),
1077            CompletionContext::Path
1078        ));
1079    }
1080
1081    #[test]
1082    fn test_detect_context_command_substitution() {
1083        // $(cmd should complete commands, not variables
1084        assert!(matches!(
1085            detect_completion_context("echo $(ca", 9),
1086            CompletionContext::Command
1087        ));
1088        assert!(matches!(
1089            detect_completion_context("X=$(ec", 6),
1090            CompletionContext::Command
1091        ));
1092    }
1093
1094    #[test]
1095    fn test_shell_words_comments() {
1096        // Keywords in comments should be ignored
1097        assert_eq!(shell_words("# if this happens"), Vec::<String>::new());
1098        assert_eq!(shell_words("echo hello # if comment"), vec!["echo", "hello"]);
1099    }
1100
1101    #[test]
1102    fn test_is_incomplete_comment_with_keyword() {
1103        let helper = make_test_helper();
1104        // Comments containing keywords should NOT make input incomplete
1105        assert!(!helper.is_incomplete("# if this happens"));
1106        assert!(!helper.is_incomplete("echo hello # if we do this"));
1107    }
1108
1109    /// Create a test helper (the client is not used for is_incomplete).
1110    fn make_test_helper() -> KaishHelper {
1111        let config = KernelConfig::transient();
1112        let kernel = Kernel::new(config).expect("test kernel");
1113        let client = EmbeddedClient::new(kernel);
1114        let rt = Runtime::new().expect("test runtime");
1115        KaishHelper::new(Box::new(client), rt.handle().clone())
1116    }
1117
1118    // Completion runs through the `Box<dyn KernelClient>` trait object. These
1119    // tests prove that path end-to-end: command names come from
1120    // `tool_schemas()` and variables from `list_vars()`, both via the client.
1121    #[test]
1122    fn test_completion_through_client() {
1123        let config = KernelConfig::transient();
1124        let kernel = Kernel::new(config).expect("test kernel");
1125        let client = EmbeddedClient::new(kernel);
1126        let rt = Runtime::new().expect("test runtime");
1127
1128        // Seed a variable on a clone; it shares the kernel with the helper's
1129        // client, so the completer sees it.
1130        rt.block_on(client.set_var("MYVAR", Value::String("hi".into())))
1131            .expect("set_var failed");
1132
1133        let helper = KaishHelper::new(Box::new(client), rt.handle().clone());
1134        let history = DefaultHistory::new();
1135        let ctx = rustyline::Context::new(&history);
1136
1137        // Command completion: "ec" → echo (sourced from tool_schemas()).
1138        let (start, candidates) = helper.complete("ec", 2, &ctx).expect("command completion");
1139        assert_eq!(start, 0);
1140        assert!(
1141            candidates.iter().any(|p| p.replacement == "echo"),
1142            "expected `echo` among command candidates, got {:?}",
1143            candidates.iter().map(|p| &p.replacement).collect::<Vec<_>>()
1144        );
1145
1146        // Variable completion: "$MY" → $MYVAR (sourced from list_vars()).
1147        let (start, candidates) = helper.complete("$MY", 3, &ctx).expect("variable completion");
1148        assert_eq!(start, 0);
1149        assert!(
1150            candidates.iter().any(|p| p.replacement == "$MYVAR"),
1151            "expected `$MYVAR` among variable candidates, got {:?}",
1152            candidates.iter().map(|p| &p.replacement).collect::<Vec<_>>()
1153        );
1154    }
1155}