Skip to main content

osp_cli/repl/
engine.rs

1use std::borrow::Cow;
2use std::collections::{BTreeMap, BTreeSet};
3use std::io::{self, IsTerminal, Write};
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6#[cfg(unix)]
7use std::time::{Duration, Instant};
8
9use super::highlight::ReplHighlighter;
10pub use super::highlight::{HighlightDebugSpan, debug_highlight};
11pub use super::history_store::{
12    HistoryConfig, HistoryEntry, HistoryShellContext, OspHistoryStore, SharedHistory,
13    expand_history,
14};
15use super::menu::{MenuDebug, MenuStyleDebug, OspCompletionMenu, debug_snapshot, display_text};
16use crate::completion::{
17    ArgNode, CompletionEngine, CompletionNode, CompletionTree, SuggestionEntry, SuggestionOutput,
18};
19use anyhow::Result;
20use nu_ansi_term::{Color, Style};
21use reedline::{
22    Completer, EditCommand, EditMode, Editor, Emacs, KeyCode, KeyModifiers, Menu, MenuEvent,
23    Prompt, PromptEditMode, PromptHistorySearch, PromptHistorySearchStatus, Reedline,
24    ReedlineEvent, ReedlineMenu, ReedlineRawEvent, Signal, Span, Suggestion, UndoBehavior,
25    default_emacs_keybindings,
26};
27use serde::Serialize;
28
29#[derive(Debug, Clone)]
30pub struct ReplPrompt {
31    pub left: String,
32    pub indicator: String,
33}
34
35pub type PromptRightRenderer = Arc<dyn Fn() -> String + Send + Sync>;
36
37#[derive(Debug, Clone, Default, PartialEq, Eq)]
38pub struct LineProjection {
39    pub line: String,
40    pub hidden_suggestions: BTreeSet<String>,
41}
42
43impl LineProjection {
44    pub fn passthrough(line: impl Into<String>) -> Self {
45        Self {
46            line: line.into(),
47            hidden_suggestions: BTreeSet::new(),
48        }
49    }
50
51    pub fn with_hidden_suggestions(mut self, hidden_suggestions: BTreeSet<String>) -> Self {
52        self.hidden_suggestions = hidden_suggestions;
53        self
54    }
55}
56
57pub type LineProjector = Arc<dyn Fn(&str) -> LineProjection + Send + Sync>;
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum ReplInputMode {
61    Auto,
62    Interactive,
63    Basic,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum ReplReloadKind {
68    Default,
69    WithIntro,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum ReplLineResult {
74    Continue(String),
75    ReplaceInput(String),
76    Exit(i32),
77    Restart {
78        output: String,
79        reload: ReplReloadKind,
80    },
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub enum ReplRunResult {
85    Exit(i32),
86    Restart {
87        output: String,
88        reload: ReplReloadKind,
89    },
90}
91
92pub struct ReplRunConfig {
93    pub prompt: ReplPrompt,
94    pub completion_words: Vec<String>,
95    pub completion_tree: Option<CompletionTree>,
96    pub appearance: ReplAppearance,
97    pub history_config: HistoryConfig,
98    pub input_mode: ReplInputMode,
99    pub prompt_right: Option<PromptRightRenderer>,
100    pub line_projector: Option<LineProjector>,
101}
102
103#[derive(Debug, Clone, Serialize)]
104pub struct CompletionDebugMatch {
105    pub id: String,
106    pub label: String,
107    pub description: Option<String>,
108    pub kind: String,
109}
110
111#[derive(Debug, Clone, Serialize)]
112pub struct CompletionDebug {
113    pub line: String,
114    pub cursor: usize,
115    pub replace_range: [usize; 2],
116    pub stub: String,
117    pub matches: Vec<CompletionDebugMatch>,
118    pub selected: i64,
119    pub selected_row: u16,
120    pub selected_col: u16,
121    pub columns: u16,
122    pub rows: u16,
123    pub visible_rows: u16,
124    pub menu_indent: u16,
125    pub menu_styles: MenuStyleDebug,
126    pub menu_description: Option<String>,
127    pub menu_description_rendered: Option<String>,
128    pub width: u16,
129    pub height: u16,
130    pub unicode: bool,
131    pub color: bool,
132    pub rendered: Vec<String>,
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub enum DebugStep {
137    Tab,
138    BackTab,
139    Up,
140    Down,
141    Left,
142    Right,
143    Accept,
144    Close,
145}
146
147impl DebugStep {
148    pub fn parse(raw: &str) -> Option<Self> {
149        match raw.trim().to_ascii_lowercase().as_str() {
150            "tab" => Some(Self::Tab),
151            "backtab" | "shift-tab" | "shift_tab" => Some(Self::BackTab),
152            "up" => Some(Self::Up),
153            "down" => Some(Self::Down),
154            "left" => Some(Self::Left),
155            "right" => Some(Self::Right),
156            "accept" | "enter" => Some(Self::Accept),
157            "close" | "esc" | "escape" => Some(Self::Close),
158            _ => None,
159        }
160    }
161
162    pub fn as_str(&self) -> &'static str {
163        match self {
164            Self::Tab => "tab",
165            Self::BackTab => "backtab",
166            Self::Up => "up",
167            Self::Down => "down",
168            Self::Left => "left",
169            Self::Right => "right",
170            Self::Accept => "accept",
171            Self::Close => "close",
172        }
173    }
174}
175
176#[derive(Debug, Clone, Serialize)]
177pub struct CompletionDebugFrame {
178    pub step: String,
179    pub state: CompletionDebug,
180}
181
182#[derive(Debug, Clone, Copy)]
183pub struct CompletionDebugOptions<'a> {
184    pub width: u16,
185    pub height: u16,
186    pub ansi: bool,
187    pub unicode: bool,
188    pub appearance: Option<&'a ReplAppearance>,
189}
190
191impl<'a> CompletionDebugOptions<'a> {
192    pub fn new(width: u16, height: u16) -> Self {
193        Self {
194            width,
195            height,
196            ansi: false,
197            unicode: false,
198            appearance: None,
199        }
200    }
201
202    pub fn ansi(mut self, ansi: bool) -> Self {
203        self.ansi = ansi;
204        self
205    }
206
207    pub fn unicode(mut self, unicode: bool) -> Self {
208        self.unicode = unicode;
209        self
210    }
211
212    pub fn appearance(mut self, appearance: Option<&'a ReplAppearance>) -> Self {
213        self.appearance = appearance;
214        self
215    }
216}
217
218pub fn debug_completion(
219    tree: &CompletionTree,
220    line: &str,
221    cursor: usize,
222    options: CompletionDebugOptions<'_>,
223) -> CompletionDebug {
224    let (editor, mut completer, mut menu) =
225        build_debug_completion_session(tree, line, cursor, options.appearance);
226    let mut editor = editor;
227
228    menu.menu_event(MenuEvent::Activate(false));
229    menu.apply_event(&mut editor, &mut completer);
230
231    snapshot_completion_debug(
232        tree,
233        &mut menu,
234        &editor,
235        options.width,
236        options.height,
237        options.ansi,
238        options.unicode,
239    )
240}
241
242pub fn debug_completion_steps(
243    tree: &CompletionTree,
244    line: &str,
245    cursor: usize,
246    options: CompletionDebugOptions<'_>,
247    steps: &[DebugStep],
248) -> Vec<CompletionDebugFrame> {
249    let (mut editor, mut completer, mut menu) =
250        build_debug_completion_session(tree, line, cursor, options.appearance);
251
252    let steps = steps.to_vec();
253    if steps.is_empty() {
254        return Vec::new();
255    }
256
257    let mut frames = Vec::with_capacity(steps.len());
258    for step in steps {
259        apply_debug_step(step, &mut menu, &mut editor, &mut completer);
260        let state = snapshot_completion_debug(
261            tree,
262            &mut menu,
263            &editor,
264            options.width,
265            options.height,
266            options.ansi,
267            options.unicode,
268        );
269        frames.push(CompletionDebugFrame {
270            step: step.as_str().to_string(),
271            state,
272        });
273    }
274
275    frames
276}
277
278fn build_debug_completion_session(
279    tree: &CompletionTree,
280    line: &str,
281    cursor: usize,
282    appearance: Option<&ReplAppearance>,
283) -> (Editor, ReplCompleter, OspCompletionMenu) {
284    let mut editor = Editor::default();
285    editor.edit_buffer(
286        |buf| {
287            buf.set_buffer(line.to_string());
288            buf.set_insertion_point(cursor.min(buf.get_buffer().len()));
289        },
290        UndoBehavior::CreateUndoPoint,
291    );
292
293    let completer = ReplCompleter::new(Vec::new(), Some(tree.clone()), None);
294    let menu = if let Some(appearance) = appearance {
295        build_completion_menu(appearance)
296    } else {
297        OspCompletionMenu::default()
298    };
299
300    (editor, completer, menu)
301}
302
303fn apply_debug_step(
304    step: DebugStep,
305    menu: &mut OspCompletionMenu,
306    editor: &mut Editor,
307    completer: &mut ReplCompleter,
308) {
309    match step {
310        DebugStep::Tab => {
311            if menu.is_active() {
312                dispatch_menu_event(menu, editor, completer, MenuEvent::NextElement);
313            } else {
314                dispatch_menu_event(menu, editor, completer, MenuEvent::Activate(false));
315            }
316        }
317        DebugStep::BackTab => {
318            if menu.is_active() {
319                dispatch_menu_event(menu, editor, completer, MenuEvent::PreviousElement);
320            } else {
321                dispatch_menu_event(menu, editor, completer, MenuEvent::Activate(false));
322            }
323        }
324        DebugStep::Up => {
325            if menu.is_active() {
326                dispatch_menu_event(menu, editor, completer, MenuEvent::MoveUp);
327            }
328        }
329        DebugStep::Down => {
330            if menu.is_active() {
331                dispatch_menu_event(menu, editor, completer, MenuEvent::MoveDown);
332            }
333        }
334        DebugStep::Left => {
335            if menu.is_active() {
336                dispatch_menu_event(menu, editor, completer, MenuEvent::MoveLeft);
337            }
338        }
339        DebugStep::Right => {
340            if menu.is_active() {
341                dispatch_menu_event(menu, editor, completer, MenuEvent::MoveRight);
342            }
343        }
344        DebugStep::Accept => {
345            if menu.is_active() {
346                menu.accept_selection_in_buffer(editor);
347                dispatch_menu_event(menu, editor, completer, MenuEvent::Deactivate);
348            }
349        }
350        DebugStep::Close => {
351            dispatch_menu_event(menu, editor, completer, MenuEvent::Deactivate);
352        }
353    }
354}
355
356fn dispatch_menu_event(
357    menu: &mut OspCompletionMenu,
358    editor: &mut Editor,
359    completer: &mut ReplCompleter,
360    event: MenuEvent,
361) {
362    menu.menu_event(event);
363    menu.apply_event(editor, completer);
364}
365
366fn snapshot_completion_debug(
367    tree: &CompletionTree,
368    menu: &mut OspCompletionMenu,
369    editor: &Editor,
370    width: u16,
371    height: u16,
372    ansi: bool,
373    unicode: bool,
374) -> CompletionDebug {
375    let line = editor.get_buffer().to_string();
376    let cursor = editor.line_buffer().insertion_point();
377    let values = menu.get_values();
378    let engine = CompletionEngine::new(tree.clone());
379    let analysis = engine.analyze(&line, cursor);
380
381    let (stub, replace_range) = if let Some(first) = values.first() {
382        let start = first.span.start;
383        let end = first.span.end;
384        let stub = line.get(start..end).unwrap_or("").to_string();
385        (stub, [start, end])
386    } else {
387        (
388            analysis.cursor.raw_stub.clone(),
389            [
390                analysis.cursor.replace_range.start,
391                analysis.cursor.replace_range.end,
392            ],
393        )
394    };
395
396    let matches = values
397        .iter()
398        .map(|item| CompletionDebugMatch {
399            id: item.value.clone(),
400            label: display_text(item).to_string(),
401            description: item.description.clone(),
402            kind: engine
403                .classify_match(&analysis, &item.value)
404                .as_str()
405                .to_string(),
406        })
407        .collect::<Vec<_>>();
408
409    let MenuDebug {
410        columns,
411        rows,
412        visible_rows,
413        indent,
414        selected_index,
415        selected_row,
416        selected_col,
417        description,
418        description_rendered,
419        styles,
420        rendered,
421    } = debug_snapshot(menu, editor, width, height, ansi);
422
423    let selected = if matches.is_empty() {
424        -1
425    } else {
426        selected_index
427    };
428
429    CompletionDebug {
430        line,
431        cursor,
432        replace_range,
433        stub,
434        matches,
435        selected,
436        selected_row,
437        selected_col,
438        columns,
439        rows,
440        visible_rows,
441        menu_indent: indent,
442        menu_styles: styles,
443        menu_description: description,
444        menu_description_rendered: description_rendered,
445        width,
446        height,
447        unicode,
448        color: ansi,
449        rendered,
450    }
451}
452
453impl ReplPrompt {
454    pub fn simple(left: impl Into<String>) -> Self {
455        Self {
456            left: left.into(),
457            indicator: String::new(),
458        }
459    }
460}
461
462#[derive(Debug, Clone, Default)]
463pub struct ReplAppearance {
464    pub completion_text_style: Option<String>,
465    pub completion_background_style: Option<String>,
466    pub completion_highlight_style: Option<String>,
467    pub command_highlight_style: Option<String>,
468}
469
470struct AutoCompleteEmacs {
471    inner: Emacs,
472    menu_name: String,
473}
474
475impl AutoCompleteEmacs {
476    fn new(inner: Emacs, menu_name: impl Into<String>) -> Self {
477        Self {
478            inner,
479            menu_name: menu_name.into(),
480        }
481    }
482
483    fn should_reopen_menu(commands: &[EditCommand]) -> bool {
484        commands.iter().any(|cmd| {
485            matches!(
486                cmd,
487                EditCommand::InsertChar(_)
488                    | EditCommand::InsertString(_)
489                    | EditCommand::ReplaceChar(_)
490                    | EditCommand::ReplaceChars(_, _)
491                    | EditCommand::Backspace
492                    | EditCommand::Delete
493                    | EditCommand::CutChar
494                    | EditCommand::BackspaceWord
495                    | EditCommand::DeleteWord
496                    | EditCommand::Clear
497                    | EditCommand::ClearToLineEnd
498                    | EditCommand::CutCurrentLine
499                    | EditCommand::CutFromStart
500                    | EditCommand::CutFromLineStart
501                    | EditCommand::CutToEnd
502                    | EditCommand::CutToLineEnd
503                    | EditCommand::CutWordLeft
504                    | EditCommand::CutBigWordLeft
505                    | EditCommand::CutWordRight
506                    | EditCommand::CutBigWordRight
507                    | EditCommand::CutWordRightToNext
508                    | EditCommand::CutBigWordRightToNext
509                    | EditCommand::PasteCutBufferBefore
510                    | EditCommand::PasteCutBufferAfter
511                    | EditCommand::Undo
512                    | EditCommand::Redo
513            )
514        })
515    }
516}
517
518impl EditMode for AutoCompleteEmacs {
519    fn parse_event(&mut self, event: ReedlineRawEvent) -> ReedlineEvent {
520        let parsed = self.inner.parse_event(event);
521        match parsed {
522            ReedlineEvent::Edit(commands) if Self::should_reopen_menu(&commands) => {
523                ReedlineEvent::Multiple(vec![
524                    ReedlineEvent::Edit(commands),
525                    ReedlineEvent::Menu(self.menu_name.clone()),
526                ])
527            }
528            other => other,
529        }
530    }
531
532    fn edit_mode(&self) -> PromptEditMode {
533        self.inner.edit_mode()
534    }
535}
536
537enum SubmissionResult {
538    Noop,
539    Print(String),
540    ReplaceInput(String),
541    Exit(i32),
542    Restart {
543        output: String,
544        reload: ReplReloadKind,
545    },
546}
547
548struct SubmissionContext<'a, F> {
549    history_store: &'a SharedHistory,
550    execute: &'a mut F,
551}
552
553impl<'a, F> SubmissionContext<'a, F> where F: FnMut(&str, &SharedHistory) -> Result<ReplLineResult> {}
554
555fn process_submission<F>(raw: &str, ctx: &mut SubmissionContext<'_, F>) -> Result<SubmissionResult>
556where
557    F: FnMut(&str, &SharedHistory) -> Result<ReplLineResult>,
558{
559    let raw = raw.trim();
560    if raw.is_empty() {
561        return Ok(SubmissionResult::Noop);
562    }
563    let result = match (ctx.execute)(raw, ctx.history_store) {
564        Ok(ReplLineResult::Continue(output)) => SubmissionResult::Print(output),
565        Ok(ReplLineResult::ReplaceInput(buffer)) => SubmissionResult::ReplaceInput(buffer),
566        Ok(ReplLineResult::Exit(code)) => SubmissionResult::Exit(code),
567        Ok(ReplLineResult::Restart { output, reload }) => {
568            SubmissionResult::Restart { output, reload }
569        }
570        Err(err) => {
571            eprintln!("{err}");
572            SubmissionResult::Noop
573        }
574    };
575    Ok(result)
576}
577
578pub fn run_repl<F>(config: ReplRunConfig, mut execute: F) -> Result<ReplRunResult>
579where
580    F: FnMut(&str, &SharedHistory) -> Result<ReplLineResult>,
581{
582    let ReplRunConfig {
583        prompt,
584        completion_words,
585        completion_tree,
586        appearance,
587        history_config,
588        input_mode,
589        prompt_right,
590        line_projector,
591    } = config;
592    let history_store = SharedHistory::new(history_config)?;
593    let mut submission = SubmissionContext {
594        history_store: &history_store,
595        execute: &mut execute,
596    };
597    let prompt = OspPrompt::new(prompt.left, prompt.indicator, prompt_right);
598
599    if let Some(reason) = basic_input_reason(input_mode) {
600        match reason {
601            BasicInputReason::NotATerminal => {
602                eprintln!("Warning: Input is not a terminal (fd=0).");
603            }
604            BasicInputReason::CursorProbeUnsupported => {
605                eprintln!(
606                    "Warning: terminal does not support cursor position requests; using basic input mode."
607                );
608            }
609            BasicInputReason::Explicit => {}
610        }
611        run_repl_basic(&prompt, &mut submission)?;
612        return Ok(ReplRunResult::Exit(0));
613    }
614
615    let tree = completion_tree.unwrap_or_else(|| build_repl_tree(&completion_words));
616    let completer = Box::new(ReplCompleter::new(
617        completion_words,
618        Some(tree.clone()),
619        line_projector.clone(),
620    ));
621    let completion_menu = Box::new(build_completion_menu(&appearance));
622    let highlighter = build_repl_highlighter(&tree, &appearance, line_projector);
623    let mut keybindings = default_emacs_keybindings();
624    keybindings.add_binding(
625        KeyModifiers::NONE,
626        KeyCode::Enter,
627        ReedlineEvent::Multiple(vec![ReedlineEvent::Esc, ReedlineEvent::Submit]),
628    );
629    keybindings.add_binding(
630        KeyModifiers::NONE,
631        KeyCode::Tab,
632        ReedlineEvent::UntilFound(vec![
633            ReedlineEvent::Menu("completion_menu".to_string()),
634            ReedlineEvent::MenuNext,
635        ]),
636    );
637    keybindings.add_binding(
638        KeyModifiers::SHIFT,
639        KeyCode::BackTab,
640        ReedlineEvent::UntilFound(vec![
641            ReedlineEvent::Menu("completion_menu".to_string()),
642            ReedlineEvent::MenuPrevious,
643        ]),
644    );
645    keybindings.add_binding(
646        KeyModifiers::CONTROL,
647        KeyCode::Char(' '),
648        ReedlineEvent::Menu("completion_menu".to_string()),
649    );
650    let edit_mode = Box::new(AutoCompleteEmacs::new(
651        Emacs::new(keybindings),
652        "completion_menu",
653    ));
654
655    let mut editor = Reedline::create()
656        .with_completer(completer)
657        .with_menu(ReedlineMenu::EngineCompleter(completion_menu))
658        .with_edit_mode(edit_mode);
659    if let Some(highlighter) = highlighter {
660        editor = editor.with_highlighter(Box::new(highlighter));
661    }
662    editor = editor.with_history(Box::new(history_store.clone()));
663
664    loop {
665        let signal = match editor.read_line(&prompt) {
666            Ok(signal) => signal,
667            Err(err) => {
668                if is_cursor_position_error(&err) {
669                    eprintln!(
670                        "WARNING: terminal does not support cursor position requests; \
671falling back to basic input mode."
672                    );
673                    run_repl_basic(&prompt, &mut submission)?;
674                    return Ok(ReplRunResult::Exit(0));
675                }
676                return Err(err.into());
677            }
678        };
679
680        match signal {
681            Signal::Success(line) => match process_submission(&line, &mut submission)? {
682                SubmissionResult::Noop => continue,
683                SubmissionResult::Print(output) => print!("{output}"),
684                SubmissionResult::ReplaceInput(buffer) => {
685                    editor.run_edit_commands(&[
686                        EditCommand::Clear,
687                        EditCommand::InsertString(buffer),
688                    ]);
689                    continue;
690                }
691                SubmissionResult::Exit(code) => return Ok(ReplRunResult::Exit(code)),
692                SubmissionResult::Restart { output, reload } => {
693                    return Ok(ReplRunResult::Restart { output, reload });
694                }
695            },
696            Signal::CtrlD => return Ok(ReplRunResult::Exit(0)),
697            Signal::CtrlC => continue,
698        }
699    }
700}
701
702fn is_cursor_position_error(err: &io::Error) -> bool {
703    if matches!(err.raw_os_error(), Some(6 | 25)) {
704        return true;
705    }
706    let message = err.to_string().to_ascii_lowercase();
707    message.contains("cursor position could not be read")
708        || message.contains("no such device or address")
709        || message.contains("inappropriate ioctl")
710}
711
712#[derive(Debug, Clone, Copy, PartialEq, Eq)]
713enum BasicInputReason {
714    Explicit,
715    NotATerminal,
716    CursorProbeUnsupported,
717}
718
719fn basic_input_reason(input_mode: ReplInputMode) -> Option<BasicInputReason> {
720    if matches!(input_mode, ReplInputMode::Basic) {
721        return Some(BasicInputReason::Explicit);
722    }
723
724    if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
725        return Some(BasicInputReason::NotATerminal);
726    }
727
728    if matches!(input_mode, ReplInputMode::Auto) && !cursor_position_reports_supported() {
729        return Some(BasicInputReason::CursorProbeUnsupported);
730    }
731
732    None
733}
734
735#[cfg(not(unix))]
736fn cursor_position_reports_supported() -> bool {
737    true
738}
739
740#[cfg(unix)]
741fn cursor_position_reports_supported() -> bool {
742    use std::mem::MaybeUninit;
743    use std::os::fd::AsRawFd;
744
745    const CURSOR_PROBE_TIMEOUT: Duration = Duration::from_millis(75);
746
747    struct RawModeGuard {
748        fd: i32,
749        original: libc::termios,
750        active: bool,
751    }
752
753    impl Drop for RawModeGuard {
754        fn drop(&mut self) {
755            if self.active {
756                unsafe {
757                    libc::tcsetattr(self.fd, libc::TCSANOW, &self.original);
758                }
759            }
760        }
761    }
762
763    let stdin = io::stdin();
764    let fd = stdin.as_raw_fd();
765    let mut original = MaybeUninit::<libc::termios>::uninit();
766    if unsafe { libc::tcgetattr(fd, original.as_mut_ptr()) } != 0 {
767        return true;
768    }
769    let original = unsafe { original.assume_init() };
770    let mut raw = original;
771    unsafe {
772        libc::cfmakeraw(&mut raw);
773    }
774    if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &raw) } != 0 {
775        return true;
776    }
777    let _guard = RawModeGuard {
778        fd,
779        original,
780        active: true,
781    };
782
783    let mut stdout = io::stdout();
784    if stdout.write_all(b"\x1b[6n").is_err() || stdout.flush().is_err() {
785        return true;
786    }
787
788    let start = Instant::now();
789    let mut buffer = Vec::with_capacity(32);
790    while start.elapsed() < CURSOR_PROBE_TIMEOUT {
791        let remaining = CURSOR_PROBE_TIMEOUT
792            .saturating_sub(start.elapsed())
793            .as_millis()
794            .min(i32::MAX as u128) as i32;
795        let mut pollfd = libc::pollfd {
796            fd,
797            events: libc::POLLIN,
798            revents: 0,
799        };
800        let ready = unsafe { libc::poll(&mut pollfd, 1, remaining) };
801        if ready <= 0 {
802            break;
803        }
804        let mut chunk = [0u8; 64];
805        let read = unsafe { libc::read(fd, chunk.as_mut_ptr().cast(), chunk.len()) };
806        if read <= 0 {
807            break;
808        }
809        buffer.extend_from_slice(&chunk[..read as usize]);
810        if contains_cursor_position_report(&buffer) {
811            return true;
812        }
813        if buffer.len() >= 256 {
814            break;
815        }
816    }
817
818    false
819}
820
821fn contains_cursor_position_report(bytes: &[u8]) -> bool {
822    bytes.windows(2).enumerate().any(|(start, window)| {
823        window == b"\x1b[" && parse_cursor_position_report(&bytes[start..]).is_some()
824    })
825}
826
827fn parse_cursor_position_report(bytes: &[u8]) -> Option<(u16, u16)> {
828    let rest = bytes.strip_prefix(b"\x1b[")?;
829    let row_end = rest.iter().position(|byte| !byte.is_ascii_digit())?;
830    if row_end == 0 || *rest.get(row_end)? != b';' {
831        return None;
832    }
833    let row = std::str::from_utf8(&rest[..row_end])
834        .ok()?
835        .parse::<u16>()
836        .ok()?;
837    let col_rest = &rest[row_end + 1..];
838    let col_end = col_rest.iter().position(|byte| !byte.is_ascii_digit())?;
839    if col_end == 0 || *col_rest.get(col_end)? != b'R' {
840        return None;
841    }
842    let col = std::str::from_utf8(&col_rest[..col_end])
843        .ok()?
844        .parse::<u16>()
845        .ok()?;
846    Some((col, row))
847}
848
849fn run_repl_basic<F>(prompt: &OspPrompt, submission: &mut SubmissionContext<'_, F>) -> Result<()>
850where
851    F: FnMut(&str, &SharedHistory) -> Result<ReplLineResult>,
852{
853    let stdin = io::stdin();
854    loop {
855        print!("{}{}", prompt.left, prompt.indicator);
856        io::stdout().flush()?;
857
858        let mut line = String::new();
859        let read = stdin.read_line(&mut line)?;
860        if read == 0 {
861            break;
862        }
863
864        match process_submission(&line, submission)? {
865            SubmissionResult::Noop => continue,
866            SubmissionResult::Print(output) => print!("{output}"),
867            SubmissionResult::ReplaceInput(buffer) => {
868                println!("{buffer}");
869                continue;
870            }
871            SubmissionResult::Exit(_) => break,
872            SubmissionResult::Restart { output, .. } => {
873                print!("{output}");
874                break;
875            }
876        }
877    }
878    Ok(())
879}
880
881struct ReplCompleter {
882    engine: CompletionEngine,
883    line_projector: Option<LineProjector>,
884}
885
886impl ReplCompleter {
887    fn new(
888        mut words: Vec<String>,
889        completion_tree: Option<CompletionTree>,
890        line_projector: Option<LineProjector>,
891    ) -> Self {
892        words.sort();
893        words.dedup();
894        let tree = completion_tree.unwrap_or_else(|| build_repl_tree(&words));
895        Self {
896            engine: CompletionEngine::new(tree),
897            line_projector,
898        }
899    }
900}
901
902impl Completer for ReplCompleter {
903    fn complete(&mut self, line: &str, pos: usize) -> Vec<Suggestion> {
904        debug_assert!(
905            pos <= line.len(),
906            "completer received pos {pos} beyond line length {}",
907            line.len()
908        );
909        let projected = self
910            .line_projector
911            .as_ref()
912            .map(|project| project(line))
913            .unwrap_or_else(|| LineProjection::passthrough(line));
914        let (cursor_state, outputs) = self.engine.complete(&projected.line, pos);
915        let span = Span {
916            start: cursor_state.replace_range.start,
917            end: cursor_state.replace_range.end,
918        };
919
920        let mut ranked = Vec::new();
921        let mut has_path_sentinel = false;
922        for output in outputs {
923            match output {
924                SuggestionOutput::Item(item) => ranked.push(item),
925                SuggestionOutput::PathSentinel => has_path_sentinel = true,
926            }
927        }
928
929        let mut suggestions = ranked
930            .into_iter()
931            .filter(|item| !projected.hidden_suggestions.contains(&item.text))
932            .map(|item| Suggestion {
933                value: item.text,
934                description: item.meta,
935                extra: item.display.map(|display| vec![display]),
936                span,
937                append_whitespace: true,
938                ..Suggestion::default()
939            })
940            .collect::<Vec<_>>();
941
942        if has_path_sentinel {
943            suggestions.extend(path_suggestions(&cursor_state.raw_stub, span));
944        }
945
946        suggestions
947    }
948}
949
950pub fn default_pipe_verbs() -> BTreeMap<String, String> {
951    BTreeMap::from([
952        ("F".to_string(), "Filter rows".to_string()),
953        ("P".to_string(), "Project columns".to_string()),
954        ("S".to_string(), "Sort rows".to_string()),
955        ("G".to_string(), "Group rows".to_string()),
956        ("A".to_string(), "Aggregate rows/groups".to_string()),
957        ("L".to_string(), "Limit rows".to_string()),
958        ("Z".to_string(), "Collapse grouped output".to_string()),
959        ("C".to_string(), "Count rows".to_string()),
960        ("Y".to_string(), "Mark output for copy".to_string()),
961        ("H".to_string(), "Show DSL help".to_string()),
962        ("V".to_string(), "Value-only quick search".to_string()),
963        ("K".to_string(), "Key-only quick search".to_string()),
964        ("?".to_string(), "Clean rows / exists filter".to_string()),
965        ("U".to_string(), "Unroll list field".to_string()),
966        ("JQ".to_string(), "Run jq expression".to_string()),
967        ("VAL".to_string(), "Extract values".to_string()),
968        ("VALUE".to_string(), "Extract values".to_string()),
969    ])
970}
971
972fn build_repl_tree(words: &[String]) -> CompletionTree {
973    let suggestions = words
974        .iter()
975        .map(|word| SuggestionEntry::value(word.clone()))
976        .collect::<Vec<_>>();
977    let args = (0..12)
978        .map(|_| ArgNode {
979            suggestions: suggestions.clone(),
980            ..ArgNode::default()
981        })
982        .collect::<Vec<_>>();
983
984    CompletionTree {
985        root: CompletionNode {
986            args,
987            ..CompletionNode::default()
988        },
989        pipe_verbs: default_pipe_verbs(),
990    }
991}
992
993fn build_completion_menu(appearance: &ReplAppearance) -> OspCompletionMenu {
994    let text_color = appearance
995        .completion_text_style
996        .as_deref()
997        .and_then(color_from_style_spec);
998    let background_color = appearance
999        .completion_background_style
1000        .as_deref()
1001        .and_then(color_from_style_spec);
1002    let highlight_color = appearance
1003        .completion_highlight_style
1004        .as_deref()
1005        .and_then(color_from_style_spec);
1006
1007    OspCompletionMenu::default()
1008        .with_name("completion_menu")
1009        .with_only_buffer_difference(false)
1010        .with_marker("")
1011        .with_columns(u16::MAX)
1012        .with_max_rows(u16::MAX)
1013        .with_description_rows(1)
1014        .with_column_padding(2)
1015        .with_text_style(style_with_fg_bg(text_color, background_color))
1016        .with_description_text_style(style_with_fg_bg(text_color, highlight_color))
1017        .with_match_text_style(style_with_fg_bg(highlight_color, background_color))
1018        .with_selected_text_style(style_with_fg_bg(highlight_color, text_color))
1019        .with_selected_match_text_style(style_with_fg_bg(highlight_color, text_color))
1020}
1021
1022fn build_repl_highlighter(
1023    tree: &CompletionTree,
1024    appearance: &ReplAppearance,
1025    line_projector: Option<LineProjector>,
1026) -> Option<ReplHighlighter> {
1027    let command_color = appearance
1028        .command_highlight_style
1029        .as_deref()
1030        .and_then(color_from_style_spec);
1031    Some(ReplHighlighter::new(
1032        tree.clone(),
1033        command_color?,
1034        line_projector,
1035    ))
1036}
1037
1038fn style_with_fg_bg(fg: Option<Color>, bg: Option<Color>) -> Style {
1039    let mut style = Style::new();
1040    if let Some(fg) = fg {
1041        style = style.fg(fg);
1042    }
1043    if let Some(bg) = bg {
1044        style = style.on(bg);
1045    }
1046    style
1047}
1048
1049pub fn color_from_style_spec(spec: &str) -> Option<Color> {
1050    let token = extract_color_token(spec)?;
1051    parse_color_token(token)
1052}
1053
1054fn extract_color_token(spec: &str) -> Option<&str> {
1055    let attrs = [
1056        "bold",
1057        "dim",
1058        "dimmed",
1059        "italic",
1060        "underline",
1061        "blink",
1062        "reverse",
1063        "hidden",
1064        "strikethrough",
1065    ];
1066
1067    let mut last: Option<&str> = None;
1068    for part in spec.split_whitespace() {
1069        let token = part
1070            .trim()
1071            .strip_prefix("fg:")
1072            .or_else(|| part.trim().strip_prefix("bg:"))
1073            .unwrap_or(part.trim());
1074        if token.is_empty() {
1075            continue;
1076        }
1077        if attrs.iter().any(|attr| token.eq_ignore_ascii_case(attr)) {
1078            continue;
1079        }
1080        last = Some(token);
1081    }
1082    last
1083}
1084
1085fn parse_color_token(token: &str) -> Option<Color> {
1086    let normalized = token.trim().to_ascii_lowercase();
1087
1088    if let Some(value) = normalized.strip_prefix('#') {
1089        if value.len() == 6 {
1090            let r = u8::from_str_radix(&value[0..2], 16).ok()?;
1091            let g = u8::from_str_radix(&value[2..4], 16).ok()?;
1092            let b = u8::from_str_radix(&value[4..6], 16).ok()?;
1093            return Some(Color::Rgb(r, g, b));
1094        }
1095        if value.len() == 3 {
1096            let r = u8::from_str_radix(&value[0..1], 16).ok()?;
1097            let g = u8::from_str_radix(&value[1..2], 16).ok()?;
1098            let b = u8::from_str_radix(&value[2..3], 16).ok()?;
1099            return Some(Color::Rgb(
1100                r.saturating_mul(17),
1101                g.saturating_mul(17),
1102                b.saturating_mul(17),
1103            ));
1104        }
1105    }
1106
1107    if let Some(value) = normalized.strip_prefix("ansi")
1108        && let Ok(index) = value.parse::<u8>()
1109    {
1110        return Some(Color::Fixed(index));
1111    }
1112
1113    if let Some(value) = normalized
1114        .strip_prefix("rgb(")
1115        .and_then(|value| value.strip_suffix(')'))
1116    {
1117        let mut parts = value.split(',').map(|part| part.trim().parse::<u8>().ok());
1118        if let (Some(Some(r)), Some(Some(g)), Some(Some(b))) =
1119            (parts.next(), parts.next(), parts.next())
1120        {
1121            return Some(Color::Rgb(r, g, b));
1122        }
1123    }
1124
1125    match normalized.as_str() {
1126        "black" => Some(Color::Black),
1127        "red" => Some(Color::Red),
1128        "green" => Some(Color::Green),
1129        "yellow" => Some(Color::Yellow),
1130        "blue" => Some(Color::Blue),
1131        "magenta" | "purple" => Some(Color::Purple),
1132        "cyan" => Some(Color::Cyan),
1133        "white" => Some(Color::White),
1134        "darkgray" | "dark_gray" | "gray" | "grey" => Some(Color::DarkGray),
1135        "lightgray" | "light_gray" | "lightgrey" | "light_grey" => Some(Color::LightGray),
1136        "lightred" | "light_red" => Some(Color::LightRed),
1137        "lightgreen" | "light_green" => Some(Color::LightGreen),
1138        "lightyellow" | "light_yellow" => Some(Color::LightYellow),
1139        "lightblue" | "light_blue" => Some(Color::LightBlue),
1140        "lightmagenta" | "light_magenta" | "lightpurple" | "light_purple" => {
1141            Some(Color::LightPurple)
1142        }
1143        "lightcyan" | "light_cyan" => Some(Color::LightCyan),
1144        _ => None,
1145    }
1146}
1147
1148#[derive(Debug, Clone, Serialize)]
1149pub(crate) struct CompletionTraceMenuState {
1150    pub selected_index: i64,
1151    pub selected_row: u16,
1152    pub selected_col: u16,
1153    pub active: bool,
1154    pub just_activated: bool,
1155    pub columns: u16,
1156    pub visible_rows: u16,
1157    pub rows: u16,
1158    pub menu_indent: u16,
1159}
1160
1161#[derive(Debug, Clone, Serialize)]
1162struct CompletionTracePayload<'a> {
1163    event: &'a str,
1164    line: &'a str,
1165    cursor: usize,
1166    stub: &'a str,
1167    matches: Vec<String>,
1168    #[serde(skip_serializing_if = "Option::is_none")]
1169    buffer_before: Option<&'a str>,
1170    #[serde(skip_serializing_if = "Option::is_none")]
1171    buffer_after: Option<&'a str>,
1172    #[serde(skip_serializing_if = "Option::is_none")]
1173    cursor_before: Option<usize>,
1174    #[serde(skip_serializing_if = "Option::is_none")]
1175    cursor_after: Option<usize>,
1176    #[serde(skip_serializing_if = "Option::is_none")]
1177    accepted_value: Option<&'a str>,
1178    #[serde(skip_serializing_if = "Option::is_none")]
1179    replace_range: Option<[usize; 2]>,
1180    #[serde(skip_serializing_if = "Option::is_none")]
1181    selected_index: Option<i64>,
1182    #[serde(skip_serializing_if = "Option::is_none")]
1183    selected_row: Option<u16>,
1184    #[serde(skip_serializing_if = "Option::is_none")]
1185    selected_col: Option<u16>,
1186    #[serde(skip_serializing_if = "Option::is_none")]
1187    active: Option<bool>,
1188    #[serde(skip_serializing_if = "Option::is_none")]
1189    just_activated: Option<bool>,
1190    #[serde(skip_serializing_if = "Option::is_none")]
1191    columns: Option<u16>,
1192    #[serde(skip_serializing_if = "Option::is_none")]
1193    visible_rows: Option<u16>,
1194    #[serde(skip_serializing_if = "Option::is_none")]
1195    rows: Option<u16>,
1196    #[serde(skip_serializing_if = "Option::is_none")]
1197    menu_indent: Option<u16>,
1198}
1199
1200#[derive(Debug, Clone)]
1201pub(crate) struct CompletionTraceEvent<'a> {
1202    pub event: &'a str,
1203    pub line: &'a str,
1204    pub cursor: usize,
1205    pub stub: &'a str,
1206    pub matches: Vec<String>,
1207    pub replace_range: Option<[usize; 2]>,
1208    pub menu: Option<CompletionTraceMenuState>,
1209    pub buffer_before: Option<&'a str>,
1210    pub buffer_after: Option<&'a str>,
1211    pub cursor_before: Option<usize>,
1212    pub cursor_after: Option<usize>,
1213    pub accepted_value: Option<&'a str>,
1214}
1215
1216pub(crate) fn trace_completion(trace: CompletionTraceEvent<'_>) {
1217    if !trace_completion_enabled() {
1218        return;
1219    }
1220
1221    let (
1222        selected_index,
1223        selected_row,
1224        selected_col,
1225        active,
1226        just_activated,
1227        columns,
1228        visible_rows,
1229        rows,
1230        menu_indent,
1231    ) = if let Some(menu) = trace.menu {
1232        (
1233            Some(menu.selected_index),
1234            Some(menu.selected_row),
1235            Some(menu.selected_col),
1236            Some(menu.active),
1237            Some(menu.just_activated),
1238            Some(menu.columns),
1239            Some(menu.visible_rows),
1240            Some(menu.rows),
1241            Some(menu.menu_indent),
1242        )
1243    } else {
1244        (None, None, None, None, None, None, None, None, None)
1245    };
1246
1247    let payload = CompletionTracePayload {
1248        event: trace.event,
1249        line: trace.line,
1250        cursor: trace.cursor,
1251        stub: trace.stub,
1252        matches: trace.matches,
1253        buffer_before: trace.buffer_before,
1254        buffer_after: trace.buffer_after,
1255        cursor_before: trace.cursor_before,
1256        cursor_after: trace.cursor_after,
1257        accepted_value: trace.accepted_value,
1258        replace_range: trace.replace_range,
1259        selected_index,
1260        selected_row,
1261        selected_col,
1262        active,
1263        just_activated,
1264        columns,
1265        visible_rows,
1266        rows,
1267        menu_indent,
1268    };
1269
1270    let serialized = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string());
1271    if let Ok(path) = std::env::var("OSP_REPL_TRACE_PATH")
1272        && !path.trim().is_empty()
1273    {
1274        if let Ok(mut file) = std::fs::OpenOptions::new()
1275            .create(true)
1276            .append(true)
1277            .open(path)
1278        {
1279            let _ = writeln!(file, "{serialized}");
1280        }
1281    } else {
1282        eprintln!("{serialized}");
1283    }
1284}
1285
1286pub(crate) fn trace_completion_enabled() -> bool {
1287    let Ok(raw) = std::env::var("OSP_REPL_TRACE_COMPLETION") else {
1288        return false;
1289    };
1290    !matches!(
1291        raw.trim().to_ascii_lowercase().as_str(),
1292        "" | "0" | "false" | "off" | "no"
1293    )
1294}
1295
1296fn path_suggestions(stub: &str, span: Span) -> Vec<Suggestion> {
1297    let (lookup, insert_prefix, typed_prefix) = split_path_stub(stub);
1298    let read_dir = std::fs::read_dir(&lookup);
1299    let Ok(entries) = read_dir else {
1300        return Vec::new();
1301    };
1302
1303    let mut out = Vec::new();
1304    for entry in entries.flatten() {
1305        let file_name = entry.file_name().to_string_lossy().to_string();
1306        if !file_name.starts_with(&typed_prefix) {
1307            continue;
1308        }
1309
1310        let path = entry.path();
1311        let is_dir = path.is_dir();
1312        let suffix = if is_dir { "/" } else { "" };
1313
1314        out.push(Suggestion {
1315            value: format!("{insert_prefix}{file_name}{suffix}"),
1316            description: Some(if is_dir { "dir" } else { "file" }.to_string()),
1317            span,
1318            append_whitespace: !is_dir,
1319            ..Suggestion::default()
1320        });
1321    }
1322
1323    out
1324}
1325
1326fn split_path_stub(stub: &str) -> (PathBuf, String, String) {
1327    if stub.is_empty() {
1328        return (PathBuf::from("."), String::new(), String::new());
1329    }
1330
1331    let expanded = expand_home(stub);
1332    let mut lookup = PathBuf::from(&expanded);
1333    if stub.ends_with('/') {
1334        return (lookup, stub.to_string(), String::new());
1335    }
1336
1337    let typed_prefix = Path::new(stub)
1338        .file_name()
1339        .and_then(|value| value.to_str())
1340        .map(ToOwned::to_owned)
1341        .unwrap_or_default();
1342
1343    let insert_prefix = match stub.rfind('/') {
1344        Some(index) => stub[..=index].to_string(),
1345        None => String::new(),
1346    };
1347
1348    if let Some(parent) = lookup.parent() {
1349        if parent.as_os_str().is_empty() {
1350            lookup = PathBuf::from(".");
1351        } else {
1352            lookup = parent.to_path_buf();
1353        }
1354    } else {
1355        lookup = PathBuf::from(".");
1356    }
1357
1358    (lookup, insert_prefix, typed_prefix)
1359}
1360
1361fn expand_home(path: &str) -> String {
1362    if path == "~" {
1363        return std::env::var("HOME").unwrap_or_else(|_| "~".to_string());
1364    }
1365    if let Some(rest) = path.strip_prefix("~/")
1366        && let Ok(home) = std::env::var("HOME")
1367    {
1368        return format!("{home}/{rest}");
1369    }
1370    path.to_string()
1371}
1372
1373struct OspPrompt {
1374    left: String,
1375    indicator: String,
1376    right: Option<PromptRightRenderer>,
1377}
1378
1379impl OspPrompt {
1380    fn new(left: String, indicator: String, right: Option<PromptRightRenderer>) -> Self {
1381        Self {
1382            left,
1383            indicator,
1384            right,
1385        }
1386    }
1387}
1388
1389impl Prompt for OspPrompt {
1390    fn render_prompt_left(&self) -> Cow<'_, str> {
1391        Cow::Borrowed(self.left.as_str())
1392    }
1393
1394    fn render_prompt_right(&self) -> Cow<'_, str> {
1395        match &self.right {
1396            Some(render) => Cow::Owned(render()),
1397            None => Cow::Borrowed(""),
1398        }
1399    }
1400
1401    fn render_prompt_indicator(&self, _prompt_mode: PromptEditMode) -> Cow<'_, str> {
1402        Cow::Borrowed(self.indicator.as_str())
1403    }
1404
1405    fn render_prompt_multiline_indicator(&self) -> Cow<'_, str> {
1406        Cow::Borrowed("... ")
1407    }
1408
1409    fn render_prompt_history_search_indicator(
1410        &self,
1411        history_search: PromptHistorySearch,
1412    ) -> Cow<'_, str> {
1413        let prefix = match history_search.status {
1414            PromptHistorySearchStatus::Passing => "",
1415            PromptHistorySearchStatus::Failing => "failing ",
1416        };
1417        Cow::Owned(format!(
1418            "({prefix}reverse-search: {}) ",
1419            history_search.term
1420        ))
1421    }
1422
1423    fn get_prompt_color(&self) -> reedline::Color {
1424        reedline::Color::Reset
1425    }
1426
1427    fn get_indicator_color(&self) -> reedline::Color {
1428        reedline::Color::Reset
1429    }
1430}
1431
1432#[cfg(test)]
1433mod tests {
1434    use crate::completion::{CompletionNode, CompletionTree, FlagNode};
1435    use nu_ansi_term::Color;
1436    use reedline::{
1437        Completer, EditCommand, Prompt, PromptEditMode, PromptHistorySearch,
1438        PromptHistorySearchStatus,
1439    };
1440    use std::collections::BTreeSet;
1441    use std::io;
1442    use std::path::PathBuf;
1443    use std::sync::{Arc, Mutex, OnceLock};
1444
1445    use super::{
1446        AutoCompleteEmacs, BasicInputReason, CompletionDebugOptions, DebugStep, HistoryConfig,
1447        HistoryShellContext, OspPrompt, PromptRightRenderer, ReplAppearance, ReplCompleter,
1448        ReplInputMode, ReplLineResult, ReplPrompt, ReplReloadKind, ReplRunResult, SharedHistory,
1449        SubmissionContext, SubmissionResult, basic_input_reason, build_repl_highlighter,
1450        color_from_style_spec, contains_cursor_position_report, debug_completion,
1451        debug_completion_steps, default_pipe_verbs, expand_history, expand_home,
1452        is_cursor_position_error, parse_cursor_position_report, path_suggestions,
1453        process_submission, split_path_stub, trace_completion, trace_completion_enabled,
1454    };
1455    use crate::repl::LineProjection;
1456
1457    fn env_lock() -> &'static Mutex<()> {
1458        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1459        LOCK.get_or_init(|| Mutex::new(()))
1460    }
1461
1462    fn completion_tree_with_config_show() -> CompletionTree {
1463        let mut config = CompletionNode::default();
1464        config
1465            .children
1466            .insert("show".to_string(), CompletionNode::default());
1467
1468        let mut root = CompletionNode::default();
1469        root.children.insert("config".to_string(), config);
1470        CompletionTree {
1471            root,
1472            ..CompletionTree::default()
1473        }
1474    }
1475
1476    fn disabled_history() -> SharedHistory {
1477        SharedHistory::new(
1478            HistoryConfig {
1479                path: None,
1480                max_entries: 0,
1481                enabled: false,
1482                dedupe: false,
1483                profile_scoped: false,
1484                exclude_patterns: Vec::new(),
1485                profile: None,
1486                terminal: None,
1487                shell_context: HistoryShellContext::default(),
1488            }
1489            .normalized(),
1490        )
1491        .expect("history config should build")
1492    }
1493
1494    #[test]
1495    fn expands_double_bang() {
1496        let history = vec!["ldap user oistes".to_string()];
1497        assert_eq!(
1498            expand_history("!!", &history, None, false),
1499            Some("ldap user oistes".to_string())
1500        );
1501    }
1502
1503    #[test]
1504    fn expands_relative() {
1505        let history = vec![
1506            "ldap user oistes".to_string(),
1507            "ldap netgroup ucore".to_string(),
1508        ];
1509        assert_eq!(
1510            expand_history("!-1", &history, None, false),
1511            Some("ldap netgroup ucore".to_string())
1512        );
1513    }
1514
1515    #[test]
1516    fn expands_prefix() {
1517        let history = vec![
1518            "ldap user oistes".to_string(),
1519            "ldap netgroup ucore".to_string(),
1520        ];
1521        assert_eq!(
1522            expand_history("!ldap user", &history, None, false),
1523            Some("ldap user oistes".to_string())
1524        );
1525    }
1526
1527    #[test]
1528    fn submission_delegates_help_and_exit_to_host() {
1529        let history = disabled_history();
1530        let mut seen = Vec::new();
1531        let mut execute = |line: &str, _: &SharedHistory| {
1532            seen.push(line.to_string());
1533            Ok(match line {
1534                "help" => ReplLineResult::Continue("host help".to_string()),
1535                "!!" => ReplLineResult::ReplaceInput("ldap user oistes".to_string()),
1536                "exit" => ReplLineResult::Exit(7),
1537                other => ReplLineResult::Continue(other.to_string()),
1538            })
1539        };
1540        let mut submission = SubmissionContext {
1541            history_store: &history,
1542            execute: &mut execute,
1543        };
1544
1545        let help = process_submission("help", &mut submission).expect("help should succeed");
1546        let bang = process_submission("!!", &mut submission).expect("bang should succeed");
1547        let exit = process_submission("exit", &mut submission).expect("exit should succeed");
1548
1549        assert!(matches!(help, SubmissionResult::Print(text) if text == "host help"));
1550        assert!(matches!(bang, SubmissionResult::ReplaceInput(text) if text == "ldap user oistes"));
1551        assert!(matches!(exit, SubmissionResult::Exit(7)));
1552        assert_eq!(
1553            seen,
1554            vec!["help".to_string(), "!!".to_string(), "exit".to_string()]
1555        );
1556    }
1557
1558    #[test]
1559    fn completer_suggests_word_prefixes() {
1560        let mut completer = ReplCompleter::new(
1561            vec![
1562                "ldap".to_string(),
1563                "plugins".to_string(),
1564                "theme".to_string(),
1565            ],
1566            None,
1567            None,
1568        );
1569
1570        let completions = completer.complete("ld", 2);
1571        let values = completions
1572            .into_iter()
1573            .map(|suggestion| suggestion.value)
1574            .collect::<Vec<_>>();
1575        assert_eq!(values, vec!["ldap".to_string()]);
1576    }
1577
1578    #[test]
1579    fn completer_supports_fuzzy_word_matching() {
1580        let mut completer = ReplCompleter::new(
1581            vec![
1582                "ldap".to_string(),
1583                "plugins".to_string(),
1584                "theme".to_string(),
1585            ],
1586            None,
1587            None,
1588        );
1589
1590        let completions = completer.complete("lap", 3);
1591        let values = completions
1592            .into_iter()
1593            .map(|suggestion| suggestion.value)
1594            .collect::<Vec<_>>();
1595        assert!(values.contains(&"ldap".to_string()));
1596    }
1597
1598    #[test]
1599    fn completer_suggests_pipe_verbs_after_pipe() {
1600        let mut completer = ReplCompleter::new(vec!["ldap".to_string()], None, None);
1601        let completions = completer.complete("ldap user | F", "ldap user | F".len());
1602        let values = completions
1603            .into_iter()
1604            .map(|suggestion| suggestion.value)
1605            .collect::<Vec<_>>();
1606        assert!(values.contains(&"F".to_string()));
1607    }
1608
1609    #[test]
1610    fn default_pipe_verbs_include_extended_dsl_surface() {
1611        let verbs = default_pipe_verbs();
1612
1613        assert_eq!(
1614            verbs.get("?"),
1615            Some(&"Clean rows / exists filter".to_string())
1616        );
1617        assert_eq!(verbs.get("JQ"), Some(&"Run jq expression".to_string()));
1618        assert_eq!(verbs.get("VALUE"), Some(&"Extract values".to_string()));
1619    }
1620
1621    #[test]
1622    fn completer_with_tree_does_not_fallback_to_word_list() {
1623        let mut root = CompletionNode::default();
1624        root.children
1625            .insert("config".to_string(), CompletionNode::default());
1626        let tree = CompletionTree {
1627            root,
1628            ..CompletionTree::default()
1629        };
1630
1631        let mut completer = ReplCompleter::new(vec!["ldap".to_string()], Some(tree), None);
1632        let completions = completer.complete("zzz", 3);
1633        assert!(completions.is_empty());
1634    }
1635
1636    #[test]
1637    fn completer_can_use_projected_line_for_host_flags_unit() {
1638        let tree = completion_tree_with_config_show();
1639        let projector = Arc::new(|line: &str| {
1640            LineProjection::passthrough(line.replacen("--json", "      ", 1))
1641        });
1642        let mut completer = ReplCompleter::new(Vec::new(), Some(tree), Some(projector));
1643
1644        let completions = completer.complete("--json config sh", "--json config sh".len());
1645        let values = completions
1646            .into_iter()
1647            .map(|suggestion| suggestion.value)
1648            .collect::<Vec<_>>();
1649
1650        assert!(values.contains(&"show".to_string()));
1651    }
1652
1653    #[test]
1654    fn completer_hides_suggestions_requested_by_projection_unit() {
1655        let mut root = CompletionNode::default();
1656        root.flags
1657            .insert("--json".to_string(), FlagNode::new().flag_only());
1658        root.flags
1659            .insert("--debug".to_string(), FlagNode::new().flag_only());
1660        let tree = CompletionTree {
1661            root,
1662            ..CompletionTree::default()
1663        };
1664        let projector = Arc::new(|line: &str| {
1665            let mut hidden = BTreeSet::new();
1666            hidden.insert("--json".to_string());
1667            LineProjection {
1668                line: line.to_string(),
1669                hidden_suggestions: hidden,
1670            }
1671        });
1672        let mut completer = ReplCompleter::new(Vec::new(), Some(tree), Some(projector));
1673
1674        let values = completer
1675            .complete("-", 1)
1676            .into_iter()
1677            .map(|suggestion| suggestion.value)
1678            .collect::<Vec<_>>();
1679
1680        assert!(!values.contains(&"--json".to_string()));
1681        assert!(values.contains(&"--debug".to_string()));
1682    }
1683
1684    #[test]
1685    fn completer_uses_engine_metadata_for_subcommands() {
1686        let mut ldap = CompletionNode {
1687            tooltip: Some("Directory lookup".to_string()),
1688            ..CompletionNode::default()
1689        };
1690        ldap.children
1691            .insert("user".to_string(), CompletionNode::default());
1692        ldap.children
1693            .insert("host".to_string(), CompletionNode::default());
1694
1695        let tree = CompletionTree {
1696            root: CompletionNode::default().with_child("ldap", ldap),
1697            ..CompletionTree::default()
1698        };
1699
1700        let mut completer = ReplCompleter::new(Vec::new(), Some(tree), None);
1701        let completion = completer
1702            .complete("ld", 2)
1703            .into_iter()
1704            .find(|item| item.value == "ldap")
1705            .expect("ldap completion should exist");
1706
1707        assert!(completion.description.as_deref().is_some_and(|value| {
1708            value.contains("Directory lookup")
1709                && value.contains("subcommands:")
1710                && value.contains("host")
1711                && value.contains("user")
1712        }));
1713    }
1714
1715    #[test]
1716    fn color_parser_extracts_hex_and_named_colors() {
1717        assert_eq!(
1718            color_from_style_spec("bold #ff79c6"),
1719            Some(Color::Rgb(255, 121, 198))
1720        );
1721        assert_eq!(
1722            color_from_style_spec("fg:cyan underline"),
1723            Some(Color::Cyan)
1724        );
1725        assert_eq!(color_from_style_spec("bg:ansi141"), Some(Color::Fixed(141)));
1726        assert_eq!(
1727            color_from_style_spec("fg:rgb(80,250,123)"),
1728            Some(Color::Rgb(80, 250, 123))
1729        );
1730        assert!(color_from_style_spec("not-a-color").is_none());
1731    }
1732
1733    #[test]
1734    fn split_path_stub_without_slash_uses_current_directory_lookup() {
1735        let (lookup, insert_prefix, typed_prefix) = super::split_path_stub("do");
1736
1737        assert_eq!(lookup, PathBuf::from("."));
1738        assert_eq!(insert_prefix, "");
1739        assert_eq!(typed_prefix, "do");
1740    }
1741
1742    #[test]
1743    fn debug_step_parse_round_trips_known_values_unit() {
1744        assert_eq!(DebugStep::Tab.as_str(), "tab");
1745        assert_eq!(DebugStep::Up.as_str(), "up");
1746        assert_eq!(DebugStep::Down.as_str(), "down");
1747        assert_eq!(DebugStep::Left.as_str(), "left");
1748        assert_eq!(DebugStep::parse("shift-tab"), Some(DebugStep::BackTab));
1749        assert_eq!(DebugStep::parse("ENTER"), Some(DebugStep::Accept));
1750        assert_eq!(DebugStep::parse("esc"), Some(DebugStep::Close));
1751        assert_eq!(DebugStep::Right.as_str(), "right");
1752        assert_eq!(DebugStep::parse("wat"), None);
1753    }
1754
1755    #[test]
1756    fn debug_completion_and_steps_surface_menu_state_unit() {
1757        let tree = completion_tree_with_config_show();
1758        let debug = debug_completion(
1759            &tree,
1760            "config sh",
1761            "config sh".len(),
1762            CompletionDebugOptions::new(80, 6),
1763        );
1764        assert_eq!(debug.stub, "sh");
1765        assert!(debug.matches.iter().any(|item| item.id == "show"));
1766
1767        let frames = debug_completion_steps(
1768            &tree,
1769            "config sh",
1770            "config sh".len(),
1771            CompletionDebugOptions::new(80, 6),
1772            &[DebugStep::Tab, DebugStep::Accept],
1773        );
1774        assert_eq!(frames.len(), 2);
1775        assert_eq!(frames[0].step, "tab");
1776        assert!(frames[0].state.matches.iter().any(|item| item.id == "show"));
1777        assert_eq!(frames[1].step, "accept");
1778        assert_eq!(frames[1].state.line, "config show ");
1779    }
1780
1781    #[test]
1782    fn debug_completion_options_and_empty_steps_cover_builder_paths_unit() {
1783        let appearance = ReplAppearance {
1784            completion_text_style: Some("white".to_string()),
1785            completion_background_style: Some("black".to_string()),
1786            completion_highlight_style: Some("cyan".to_string()),
1787            command_highlight_style: Some("green".to_string()),
1788        };
1789        let options = CompletionDebugOptions::new(90, 12)
1790            .ansi(true)
1791            .unicode(true)
1792            .appearance(Some(&appearance));
1793
1794        assert!(options.ansi);
1795        assert!(options.unicode);
1796        assert!(options.appearance.is_some());
1797
1798        let tree = completion_tree_with_config_show();
1799        let frames = debug_completion_steps(&tree, "config sh", 9, options, &[]);
1800        assert!(frames.is_empty());
1801    }
1802
1803    #[test]
1804    fn debug_completion_navigation_variants_and_empty_matches_are_stable_unit() {
1805        let tree = completion_tree_with_config_show();
1806        let frames = debug_completion_steps(
1807            &tree,
1808            "config sh",
1809            "config sh".len(),
1810            CompletionDebugOptions::new(80, 6),
1811            &[
1812                DebugStep::Tab,
1813                DebugStep::Down,
1814                DebugStep::Right,
1815                DebugStep::Left,
1816                DebugStep::Up,
1817                DebugStep::BackTab,
1818                DebugStep::Close,
1819            ],
1820        );
1821        assert_eq!(frames.len(), 7);
1822        assert_eq!(
1823            frames.last().map(|frame| frame.step.as_str()),
1824            Some("close")
1825        );
1826
1827        let debug = debug_completion(&tree, "zzz", 3, CompletionDebugOptions::new(80, 6));
1828        assert!(debug.matches.is_empty());
1829        assert_eq!(debug.selected, -1);
1830    }
1831
1832    #[test]
1833    fn autocomplete_policy_and_path_helpers_cover_non_happy_paths_unit() {
1834        assert!(AutoCompleteEmacs::should_reopen_menu(&[
1835            EditCommand::InsertChar('x')
1836        ]));
1837        assert!(!AutoCompleteEmacs::should_reopen_menu(&[
1838            EditCommand::MoveToStart { select: false }
1839        ]));
1840
1841        let missing = path_suggestions(
1842            "/definitely/not/a/real/dir/",
1843            reedline::Span { start: 0, end: 0 },
1844        );
1845        assert!(missing.is_empty());
1846
1847        let (lookup, insert_prefix, typed_prefix) = split_path_stub("/tmp/demo/");
1848        assert_eq!(lookup, PathBuf::from("/tmp/demo/"));
1849        assert_eq!(insert_prefix, "/tmp/demo/");
1850        assert!(typed_prefix.is_empty());
1851    }
1852
1853    #[test]
1854    fn completion_debug_options_builders_and_empty_steps_unit() {
1855        let appearance = super::ReplAppearance {
1856            completion_text_style: Some("cyan".to_string()),
1857            ..Default::default()
1858        };
1859        let options = CompletionDebugOptions::new(120, 40)
1860            .ansi(true)
1861            .unicode(true)
1862            .appearance(Some(&appearance));
1863
1864        assert_eq!(options.width, 120);
1865        assert_eq!(options.height, 40);
1866        assert!(options.ansi);
1867        assert!(options.unicode);
1868        assert!(options.appearance.is_some());
1869
1870        let tree = completion_tree_with_config_show();
1871        let frames = debug_completion_steps(&tree, "config sh", 9, options, &[]);
1872        assert!(frames.is_empty());
1873    }
1874
1875    #[test]
1876    fn debug_completion_navigation_steps_cover_menu_branches_unit() {
1877        let tree = completion_tree_with_config_show();
1878        let frames = debug_completion_steps(
1879            &tree,
1880            "config sh",
1881            9,
1882            CompletionDebugOptions::new(80, 6),
1883            &[
1884                DebugStep::Tab,
1885                DebugStep::Down,
1886                DebugStep::Right,
1887                DebugStep::Left,
1888                DebugStep::Up,
1889                DebugStep::BackTab,
1890                DebugStep::Close,
1891            ],
1892        );
1893
1894        assert_eq!(frames.len(), 7);
1895        assert_eq!(frames[0].step, "tab");
1896        assert_eq!(frames[1].step, "down");
1897        assert_eq!(frames[2].step, "right");
1898        assert_eq!(frames[3].step, "left");
1899        assert_eq!(frames[4].step, "up");
1900        assert_eq!(frames[5].step, "backtab");
1901        assert_eq!(frames[6].step, "close");
1902    }
1903
1904    #[test]
1905    fn debug_completion_without_matches_reports_unmatched_cursor_state_unit() {
1906        let tree = completion_tree_with_config_show();
1907        let debug = debug_completion(&tree, "zzz", 99, CompletionDebugOptions::new(80, 6));
1908
1909        assert_eq!(debug.line, "zzz");
1910        assert_eq!(debug.cursor, 3);
1911        assert!(debug.matches.is_empty());
1912        assert_eq!(debug.selected, -1);
1913        assert_eq!(debug.stub, "zzz");
1914        assert_eq!(debug.replace_range, [0, 3]);
1915    }
1916
1917    #[test]
1918    fn autocomplete_emacs_reopens_for_edits_but_not_movement_unit() {
1919        assert!(super::AutoCompleteEmacs::should_reopen_menu(&[
1920            EditCommand::InsertChar('x')
1921        ]));
1922        assert!(super::AutoCompleteEmacs::should_reopen_menu(&[
1923            EditCommand::BackspaceWord
1924        ]));
1925        assert!(!super::AutoCompleteEmacs::should_reopen_menu(&[
1926            EditCommand::MoveLeft { select: false }
1927        ]));
1928        assert!(!super::AutoCompleteEmacs::should_reopen_menu(&[
1929            EditCommand::MoveToLineEnd { select: false }
1930        ]));
1931    }
1932
1933    #[test]
1934    fn process_submission_handles_restart_and_error_paths_unit() {
1935        let history = disabled_history();
1936
1937        let mut restart_execute = |_line: &str, _: &SharedHistory| {
1938            Ok(ReplLineResult::Restart {
1939                output: "restarting".to_string(),
1940                reload: ReplReloadKind::WithIntro,
1941            })
1942        };
1943        let mut submission = SubmissionContext {
1944            history_store: &history,
1945            execute: &mut restart_execute,
1946        };
1947        let restart =
1948            process_submission("config set", &mut submission).expect("restart should map");
1949        assert!(matches!(
1950            restart,
1951            SubmissionResult::Restart {
1952                output,
1953                reload: ReplReloadKind::WithIntro
1954            } if output == "restarting"
1955        ));
1956
1957        let mut failing_execute =
1958            |_line: &str, _: &SharedHistory| -> anyhow::Result<ReplLineResult> {
1959                Err(anyhow::anyhow!("boom"))
1960            };
1961        let mut failing_submission = SubmissionContext {
1962            history_store: &history,
1963            execute: &mut failing_execute,
1964        };
1965        let result = process_submission("broken", &mut failing_submission)
1966            .expect("error should be absorbed");
1967        assert!(matches!(result, SubmissionResult::Noop));
1968
1969        let mut noop_execute =
1970            |_line: &str, _: &SharedHistory| Ok(ReplLineResult::Continue("ignored".to_string()));
1971        let mut noop_submission = SubmissionContext {
1972            history_store: &history,
1973            execute: &mut noop_execute,
1974        };
1975        let result =
1976            process_submission("   ", &mut noop_submission).expect("blank lines should noop");
1977        assert!(matches!(result, SubmissionResult::Noop));
1978    }
1979
1980    #[test]
1981    fn highlighter_builder_requires_command_color_unit() {
1982        let tree = completion_tree_with_config_show();
1983        let none = build_repl_highlighter(&tree, &super::ReplAppearance::default(), None);
1984        assert!(none.is_none());
1985
1986        let some = build_repl_highlighter(
1987            &tree,
1988            &super::ReplAppearance {
1989                command_highlight_style: Some("green".to_string()),
1990                ..Default::default()
1991            },
1992            None,
1993        );
1994        assert!(some.is_some());
1995    }
1996
1997    #[test]
1998    fn path_suggestions_distinguish_files_and_directories_unit() {
1999        let root = make_temp_dir("osp-repl-paths");
2000        std::fs::write(root.join("alpha.txt"), "x").expect("file should be written");
2001        std::fs::create_dir_all(root.join("alpine")).expect("dir should be created");
2002        let stub = format!("{}/al", root.display());
2003
2004        let suggestions = path_suggestions(
2005            &stub,
2006            reedline::Span {
2007                start: 0,
2008                end: stub.len(),
2009            },
2010        );
2011        let values = suggestions
2012            .iter()
2013            .map(|item| {
2014                (
2015                    item.value.clone(),
2016                    item.description.clone(),
2017                    item.append_whitespace,
2018                )
2019            })
2020            .collect::<Vec<_>>();
2021
2022        assert!(values.iter().any(|(value, desc, append)| {
2023            value.ends_with("alpha.txt") && desc.as_deref() == Some("file") && *append
2024        }));
2025        assert!(values.iter().any(|(value, desc, append)| {
2026            value.ends_with("alpine/") && desc.as_deref() == Some("dir") && !*append
2027        }));
2028    }
2029
2030    #[test]
2031    fn trace_completion_writes_jsonl_when_enabled_unit() {
2032        let _guard = env_lock().lock().expect("env lock should not be poisoned");
2033        let temp_dir = make_temp_dir("osp-repl-trace");
2034        let trace_path = temp_dir.join("trace.jsonl");
2035        let previous_enabled = std::env::var("OSP_REPL_TRACE_COMPLETION").ok();
2036        let previous_path = std::env::var("OSP_REPL_TRACE_PATH").ok();
2037        set_env_var_for_test("OSP_REPL_TRACE_COMPLETION", "1");
2038        set_env_var_for_test("OSP_REPL_TRACE_PATH", &trace_path);
2039
2040        assert!(trace_completion_enabled());
2041        trace_completion(super::CompletionTraceEvent {
2042            event: "complete",
2043            line: "config sh",
2044            cursor: 9,
2045            stub: "sh",
2046            matches: vec!["show".to_string()],
2047            replace_range: Some([7, 9]),
2048            menu: None,
2049            buffer_before: None,
2050            buffer_after: None,
2051            cursor_before: None,
2052            cursor_after: None,
2053            accepted_value: None,
2054        });
2055
2056        let contents = std::fs::read_to_string(&trace_path).expect("trace file should exist");
2057        assert!(contents.contains("\"event\":\"complete\""));
2058        assert!(contents.contains("\"stub\":\"sh\""));
2059
2060        restore_env("OSP_REPL_TRACE_COMPLETION", previous_enabled);
2061        restore_env("OSP_REPL_TRACE_PATH", previous_path);
2062    }
2063
2064    #[test]
2065    fn trace_completion_enabled_recognizes_falsey_values_unit() {
2066        let _guard = env_lock().lock().expect("env lock should not be poisoned");
2067        let previous = std::env::var("OSP_REPL_TRACE_COMPLETION").ok();
2068
2069        set_env_var_for_test("OSP_REPL_TRACE_COMPLETION", "off");
2070        assert!(!trace_completion_enabled());
2071        set_env_var_for_test("OSP_REPL_TRACE_COMPLETION", "yes");
2072        assert!(trace_completion_enabled());
2073
2074        restore_env("OSP_REPL_TRACE_COMPLETION", previous);
2075    }
2076
2077    #[test]
2078    fn cursor_position_errors_are_recognized_unit() {
2079        assert!(is_cursor_position_error(&io::Error::from_raw_os_error(25)));
2080        assert!(is_cursor_position_error(&io::Error::other(
2081            "Cursor position could not be read"
2082        )));
2083        assert!(!is_cursor_position_error(&io::Error::other(
2084            "permission denied"
2085        )));
2086    }
2087
2088    #[test]
2089    fn cursor_position_report_parser_accepts_valid_sequences_unit() {
2090        assert_eq!(parse_cursor_position_report(b"\x1b[12;34R"), Some((34, 12)));
2091        assert_eq!(
2092            parse_cursor_position_report(b"\x1b[1;200R trailing"),
2093            Some((200, 1))
2094        );
2095        assert!(contains_cursor_position_report(b"noise\x1b[22;7R"));
2096    }
2097
2098    #[test]
2099    fn cursor_position_report_parser_rejects_invalid_sequences_unit() {
2100        assert_eq!(parse_cursor_position_report(b"\x1b[;34R"), None);
2101        assert_eq!(parse_cursor_position_report(b"\x1b[12;R"), None);
2102        assert_eq!(parse_cursor_position_report(b"\x1b[12;34"), None);
2103        assert!(!contains_cursor_position_report(b"\x1b[bad"));
2104    }
2105
2106    #[test]
2107    fn explicit_basic_input_mode_short_circuits_unit() {
2108        assert_eq!(
2109            basic_input_reason(ReplInputMode::Basic),
2110            Some(BasicInputReason::Explicit)
2111        );
2112    }
2113
2114    #[test]
2115    fn expand_home_and_prompt_renderers_behave_unit() {
2116        let _guard = env_lock().lock().expect("env lock should not be poisoned");
2117        let previous_home = std::env::var("HOME").ok();
2118        set_env_var_for_test("HOME", "/tmp/osp-home");
2119        assert_eq!(expand_home("~"), "/tmp/osp-home");
2120        assert_eq!(expand_home("~/cache"), "/tmp/osp-home/cache");
2121        assert_eq!(expand_home("/etc/hosts"), "/etc/hosts");
2122
2123        let right: PromptRightRenderer = Arc::new(|| "rhs".to_string());
2124        let prompt = OspPrompt::new("left".to_string(), "> ".to_string(), Some(right));
2125        assert_eq!(prompt.render_prompt_left(), "left");
2126        assert_eq!(prompt.render_prompt_right(), "rhs");
2127        assert_eq!(
2128            prompt.render_prompt_indicator(PromptEditMode::Default),
2129            "> "
2130        );
2131        assert_eq!(prompt.render_prompt_multiline_indicator(), "... ");
2132        assert_eq!(
2133            prompt.render_prompt_history_search_indicator(PromptHistorySearch {
2134                status: PromptHistorySearchStatus::Passing,
2135                term: "ldap".to_string(),
2136            }),
2137            "(reverse-search: ldap) "
2138        );
2139
2140        let simple = ReplPrompt::simple("osp");
2141        assert_eq!(simple.left, "osp");
2142        assert!(simple.indicator.is_empty());
2143
2144        let restart = ReplRunResult::Restart {
2145            output: "x".to_string(),
2146            reload: ReplReloadKind::Default,
2147        };
2148        assert!(matches!(
2149            restart,
2150            ReplRunResult::Restart {
2151                output,
2152                reload: ReplReloadKind::Default
2153            } if output == "x"
2154        ));
2155
2156        restore_env("HOME", previous_home);
2157    }
2158
2159    fn make_temp_dir(prefix: &str) -> PathBuf {
2160        let mut dir = std::env::temp_dir();
2161        let nonce = std::time::SystemTime::now()
2162            .duration_since(std::time::UNIX_EPOCH)
2163            .expect("time should be valid")
2164            .as_nanos();
2165        dir.push(format!("{prefix}-{nonce}"));
2166        std::fs::create_dir_all(&dir).expect("temp dir should be created");
2167        dir
2168    }
2169
2170    fn restore_env(key: &str, value: Option<String>) {
2171        if let Some(value) = value {
2172            set_env_var_for_test(key, value);
2173        } else {
2174            remove_env_var_for_test(key);
2175        }
2176    }
2177
2178    fn set_env_var_for_test(key: &str, value: impl AsRef<std::ffi::OsStr>) {
2179        // Test-only environment mutation is process-global on Rust 2024.
2180        // Keep the unsafe boundary explicit and local to these regression
2181        // tests instead of spreading raw calls through the module.
2182        unsafe {
2183            std::env::set_var(key, value);
2184        }
2185    }
2186
2187    fn remove_env_var_for_test(key: &str) {
2188        // See `set_env_var_for_test`; these tests intentionally restore the
2189        // process environment after probing env-dependent behavior.
2190        unsafe {
2191            std::env::remove_var(key);
2192        }
2193    }
2194}