Skip to main content

osp_cli/repl/engine/
config.rs

1//! Host-facing REPL configuration and outcome types.
2//!
3//! These types are the stable semantic surface around the editor engine:
4//! callers describe prompts, history, completion, and restart behavior here,
5//! while the neighboring editor modules keep the lower-level reedline
6//! integration private.
7
8use std::collections::BTreeSet;
9use std::sync::Arc;
10
11use crate::completion::CompletionTree;
12
13use super::super::history_store::HistoryConfig;
14
15pub(crate) const DEFAULT_HISTORY_MENU_ROWS: u16 = 5;
16
17/// Static prompt text shown by the interactive editor.
18///
19/// The right-hand prompt is configured separately through
20/// [`PromptRightRenderer`] because it is often dynamic.
21#[derive(Debug, Clone)]
22pub struct ReplPrompt {
23    /// Left prompt text shown before the input buffer.
24    pub left: String,
25    /// Prompt indicator rendered after `left`.
26    pub indicator: String,
27}
28
29/// Lazily renders the right-hand prompt for a REPL frame.
30pub type PromptRightRenderer = Arc<dyn Fn() -> String + Send + Sync>;
31
32/// Pre-processed editor input used for completion and highlighting.
33///
34/// REPL input can contain host-level flags or aliases that should not
35/// participate in command completion. A [`LineProjector`] can blank those
36/// spans while also hiding corresponding suggestions.
37#[derive(Debug, Clone, Default, PartialEq, Eq)]
38#[must_use]
39pub struct LineProjection {
40    /// Projected line passed to completion and highlighting.
41    pub line: String,
42    /// Suggestion values that should be hidden for this projection.
43    pub hidden_suggestions: BTreeSet<String>,
44}
45
46impl LineProjection {
47    /// Returns a projection that leaves the line untouched.
48    pub fn passthrough(line: impl Into<String>) -> Self {
49        Self {
50            line: line.into(),
51            hidden_suggestions: BTreeSet::new(),
52        }
53    }
54
55    /// Marks suggestion values that should be suppressed for this projection.
56    pub fn with_hidden_suggestions(mut self, hidden_suggestions: BTreeSet<String>) -> Self {
57        self.hidden_suggestions = hidden_suggestions;
58        self
59    }
60}
61
62/// Projects a raw editor line into the view used by completion/highlighting.
63pub type LineProjector = Arc<dyn Fn(&str) -> LineProjection + Send + Sync>;
64
65/// Selects how aggressively the REPL should use the interactive line editor.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum ReplInputMode {
68    /// Use the interactive editor when terminal capabilities support it, else
69    /// fall back to basic stdin line reading.
70    Auto,
71    /// Prefer the interactive editor even when the cursor-position capability
72    /// probe would skip it.
73    ///
74    /// Non-terminal stdin or stdout still force the basic fallback.
75    Interactive,
76    /// Use plain stdin line reading instead of `reedline`.
77    Basic,
78}
79
80/// Controls how a command-triggered REPL restart should be presented.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub enum ReplReloadKind {
83    /// Rebuild the REPL and continue without reprinting the intro surface.
84    Default,
85    /// Rebuild the REPL and re-render the intro/help chrome.
86    WithIntro,
87}
88
89/// Outcome of executing one submitted REPL line.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub enum ReplLineResult {
92    /// Print output and continue the current session.
93    Continue(String),
94    /// Replace the current input buffer instead of printing output.
95    ReplaceInput(String),
96    /// Exit the REPL with the given process status.
97    Exit(i32),
98    /// Rebuild the REPL runtime, optionally showing intro chrome again.
99    Restart {
100        /// Output to print before restarting.
101        output: String,
102        /// Restart presentation mode to use.
103        reload: ReplReloadKind,
104    },
105}
106
107/// Outcome of one `run_repl` session.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub enum ReplRunResult {
110    /// Exit the editor loop and return a process status.
111    Exit(i32),
112    /// Restart the surrounding REPL host loop with refreshed state.
113    Restart {
114        /// Output to print before restarting.
115        output: String,
116        /// Restart presentation mode to use.
117        reload: ReplReloadKind,
118    },
119}
120
121/// Editor-host configuration for one REPL run.
122///
123/// This is the semantic boundary the app host should configure. The engine
124/// implementation may change, but callers should still only describe prompt,
125/// completion, history, and input-mode intent here.
126#[non_exhaustive]
127#[must_use]
128pub struct ReplRunConfig {
129    /// Left prompt and indicator strings.
130    pub prompt: ReplPrompt,
131    /// Legacy root words used when no structured completion tree is provided.
132    pub completion_words: Vec<String>,
133    /// Structured completion tree for commands, flags, and pipe verbs.
134    pub completion_tree: Option<CompletionTree>,
135    /// Visual configuration for completion menus and command highlighting.
136    pub appearance: ReplAppearance,
137    /// History backend configuration for the session.
138    pub history_config: HistoryConfig,
139    /// Chooses between interactive and basic input handling.
140    pub input_mode: ReplInputMode,
141    /// Optional renderer for the right-hand prompt.
142    pub prompt_right: Option<PromptRightRenderer>,
143    /// Optional projector used before completion/highlighting analysis.
144    pub line_projector: Option<LineProjector>,
145}
146
147impl ReplRunConfig {
148    /// Creates the exact REPL runtime baseline for one run.
149    ///
150    /// The baseline starts with no completion words, no structured completion
151    /// tree, default appearance overrides, [`ReplInputMode::Auto`], no
152    /// right-hand prompt renderer, and no line projector.
153    pub fn new(prompt: ReplPrompt, history_config: HistoryConfig) -> Self {
154        Self {
155            prompt,
156            completion_words: Vec::new(),
157            completion_tree: None,
158            appearance: ReplAppearance::default(),
159            history_config,
160            input_mode: ReplInputMode::Auto,
161            prompt_right: None,
162            line_projector: None,
163        }
164    }
165
166    /// Starts guided construction for a REPL run.
167    ///
168    /// # Examples
169    ///
170    /// ```
171    /// use osp_cli::repl::{HistoryConfig, ReplInputMode, ReplPrompt, ReplRunConfig};
172    ///
173    /// let config = ReplRunConfig::builder(
174    ///     ReplPrompt::simple("osp> "),
175    ///     HistoryConfig::builder().build(),
176    /// )
177    /// .with_completion_words(["help", "exit"])
178    /// .with_input_mode(ReplInputMode::Basic)
179    /// .build();
180    ///
181    /// assert_eq!(config.prompt.left, "osp> ");
182    /// assert_eq!(config.input_mode, ReplInputMode::Basic);
183    /// assert_eq!(
184    ///     config.completion_words,
185    ///     vec!["help".to_string(), "exit".to_string()]
186    /// );
187    /// ```
188    pub fn builder(prompt: ReplPrompt, history_config: HistoryConfig) -> ReplRunConfigBuilder {
189        ReplRunConfigBuilder::new(prompt, history_config)
190    }
191}
192
193/// Builder for [`ReplRunConfig`].
194#[must_use]
195pub struct ReplRunConfigBuilder {
196    config: ReplRunConfig,
197}
198
199impl ReplRunConfigBuilder {
200    /// Starts a builder from the required prompt and history settings.
201    pub fn new(prompt: ReplPrompt, history_config: HistoryConfig) -> Self {
202        Self {
203            config: ReplRunConfig::new(prompt, history_config),
204        }
205    }
206
207    /// Replaces the legacy fallback completion words.
208    ///
209    /// If omitted, the config keeps an empty fallback word list.
210    pub fn with_completion_words<I, S>(mut self, completion_words: I) -> Self
211    where
212        I: IntoIterator<Item = S>,
213        S: Into<String>,
214    {
215        self.config.completion_words = completion_words.into_iter().map(Into::into).collect();
216        self
217    }
218
219    /// Replaces the structured completion tree.
220    ///
221    /// If omitted, the REPL uses no structured completion tree.
222    pub fn with_completion_tree(mut self, completion_tree: Option<CompletionTree>) -> Self {
223        self.config.completion_tree = completion_tree;
224        self
225    }
226
227    /// Replaces the REPL appearance overrides.
228    ///
229    /// If omitted, the config keeps [`ReplAppearance::default`].
230    pub fn with_appearance(mut self, appearance: ReplAppearance) -> Self {
231        self.config.appearance = appearance;
232        self
233    }
234
235    /// Replaces the history configuration.
236    ///
237    /// If omitted, the builder keeps the history configuration passed to
238    /// [`ReplRunConfigBuilder::new`].
239    pub fn with_history_config(mut self, history_config: HistoryConfig) -> Self {
240        self.config.history_config = history_config;
241        self
242    }
243
244    /// Replaces the input-mode policy.
245    ///
246    /// If omitted, the config keeps [`ReplInputMode::Auto`].
247    pub fn with_input_mode(mut self, input_mode: ReplInputMode) -> Self {
248        self.config.input_mode = input_mode;
249        self
250    }
251
252    /// Replaces the optional right-prompt renderer.
253    ///
254    /// If omitted, the REPL renders no right-hand prompt.
255    pub fn with_prompt_right(mut self, prompt_right: Option<PromptRightRenderer>) -> Self {
256        self.config.prompt_right = prompt_right;
257        self
258    }
259
260    /// Replaces the optional completion/highlighting line projector.
261    ///
262    /// If omitted, the REPL analyzes the raw input line directly.
263    pub fn with_line_projector(mut self, line_projector: Option<LineProjector>) -> Self {
264        self.config.line_projector = line_projector;
265        self
266    }
267
268    /// Builds the configured [`ReplRunConfig`].
269    pub fn build(self) -> ReplRunConfig {
270        self.config
271    }
272}
273
274impl ReplPrompt {
275    /// Builds a prompt with no indicator suffix.
276    ///
277    /// # Examples
278    ///
279    /// ```
280    /// use osp_cli::repl::ReplPrompt;
281    ///
282    /// let prompt = ReplPrompt::simple("osp> ");
283    ///
284    /// assert_eq!(prompt.left, "osp> ");
285    /// assert!(prompt.indicator.is_empty());
286    /// ```
287    pub fn simple(left: impl Into<String>) -> Self {
288        Self {
289            left: left.into(),
290            indicator: String::new(),
291        }
292    }
293}
294
295/// Style overrides for REPL-only completion and highlighting chrome.
296#[derive(Debug, Clone)]
297#[non_exhaustive]
298#[must_use]
299pub struct ReplAppearance {
300    /// Style applied to non-selected completion text.
301    pub completion_text_style: Option<String>,
302    /// Background style applied to the completion menu.
303    pub completion_background_style: Option<String>,
304    /// Style applied to the selected completion entry.
305    pub completion_highlight_style: Option<String>,
306    /// Style applied to recognized command segments in the input line.
307    pub command_highlight_style: Option<String>,
308    /// Maximum number of visible rows in the history search menu.
309    pub history_menu_rows: u16,
310}
311
312impl ReplAppearance {
313    /// Starts guided construction for REPL-only appearance overrides.
314    ///
315    /// # Examples
316    ///
317    /// ```
318    /// use osp_cli::repl::ReplAppearance;
319    ///
320    /// let appearance = ReplAppearance::builder()
321    ///     .with_history_menu_rows(8)
322    ///     .with_command_highlight_style(Some("green".to_string()))
323    ///     .build();
324    ///
325    /// assert_eq!(appearance.history_menu_rows, 8);
326    /// assert_eq!(appearance.command_highlight_style.as_deref(), Some("green"));
327    /// ```
328    pub fn builder() -> ReplAppearanceBuilder {
329        ReplAppearanceBuilder::new()
330    }
331}
332
333impl Default for ReplAppearance {
334    fn default() -> Self {
335        Self {
336            completion_text_style: None,
337            completion_background_style: None,
338            completion_highlight_style: None,
339            command_highlight_style: None,
340            history_menu_rows: DEFAULT_HISTORY_MENU_ROWS,
341        }
342    }
343}
344
345/// Builder for [`ReplAppearance`].
346#[derive(Debug, Clone, Default)]
347#[must_use]
348pub struct ReplAppearanceBuilder {
349    appearance: ReplAppearance,
350}
351
352impl ReplAppearanceBuilder {
353    /// Starts a builder from the default REPL appearance baseline.
354    pub fn new() -> Self {
355        Self {
356            appearance: ReplAppearance::default(),
357        }
358    }
359
360    /// Replaces the style applied to non-selected completion text.
361    ///
362    /// If omitted, the REPL keeps the theme/default completion text style.
363    pub fn with_completion_text_style(mut self, completion_text_style: Option<String>) -> Self {
364        self.appearance.completion_text_style = completion_text_style;
365        self
366    }
367
368    /// Replaces the menu background style.
369    ///
370    /// If omitted, the REPL keeps the theme/default completion background
371    /// style.
372    pub fn with_completion_background_style(
373        mut self,
374        completion_background_style: Option<String>,
375    ) -> Self {
376        self.appearance.completion_background_style = completion_background_style;
377        self
378    }
379
380    /// Replaces the style applied to the selected completion entry.
381    ///
382    /// If omitted, the REPL keeps the theme/default completion highlight
383    /// style.
384    pub fn with_completion_highlight_style(
385        mut self,
386        completion_highlight_style: Option<String>,
387    ) -> Self {
388        self.appearance.completion_highlight_style = completion_highlight_style;
389        self
390    }
391
392    /// Replaces the style applied to recognized command segments.
393    ///
394    /// If omitted, the REPL keeps the theme/default command-highlight style.
395    pub fn with_command_highlight_style(mut self, command_highlight_style: Option<String>) -> Self {
396        self.appearance.command_highlight_style = command_highlight_style;
397        self
398    }
399
400    /// Replaces the maximum number of visible history-menu rows.
401    ///
402    /// If omitted, the builder keeps the default history-menu row count.
403    pub fn with_history_menu_rows(mut self, history_menu_rows: u16) -> Self {
404        self.appearance.history_menu_rows = history_menu_rows;
405        self
406    }
407
408    /// Builds the configured [`ReplAppearance`].
409    pub fn build(self) -> ReplAppearance {
410        self.appearance
411    }
412}
413
414#[cfg(test)]
415mod tests {
416    use super::{
417        ReplAppearance, ReplInputMode, ReplPrompt, ReplReloadKind, ReplRunConfig, ReplRunResult,
418    };
419    use crate::repl::HistoryConfig;
420
421    #[test]
422    fn run_config_builder_captures_host_surface_choices() {
423        let appearance = ReplAppearance::builder()
424            .with_history_menu_rows(8)
425            .with_command_highlight_style(Some("green".to_string()))
426            .build();
427        let config = ReplRunConfig::builder(
428            ReplPrompt::simple("osp> "),
429            HistoryConfig::builder().build(),
430        )
431        .with_completion_words(["help", "exit"])
432        .with_appearance(appearance.clone())
433        .with_input_mode(ReplInputMode::Basic)
434        .build();
435
436        assert_eq!(config.prompt.left, "osp> ");
437        assert_eq!(config.input_mode, ReplInputMode::Basic);
438        assert_eq!(
439            config.completion_words,
440            vec!["help".to_string(), "exit".to_string()]
441        );
442        assert_eq!(config.appearance.history_menu_rows, 8);
443        assert_eq!(
444            config.appearance.command_highlight_style.as_deref(),
445            Some("green")
446        );
447    }
448
449    #[test]
450    fn prompt_and_restart_outcomes_stay_plain_semantic_payloads() {
451        let prompt = ReplPrompt::simple("osp");
452        assert_eq!(prompt.left, "osp");
453        assert!(prompt.indicator.is_empty());
454
455        let restart = ReplRunResult::Restart {
456            output: "reloading".to_string(),
457            reload: ReplReloadKind::WithIntro,
458        };
459        assert!(matches!(
460            restart,
461            ReplRunResult::Restart {
462                output,
463                reload: ReplReloadKind::WithIntro
464            } if output == "reloading"
465        ));
466    }
467}