Skip to main content

osp_cli/app/
session.rs

1//! Session-scoped host state for one logical app run.
2//!
3//! This module exists to hold mutable state that should survive across commands
4//! within the same session, but should not be promoted to global runtime
5//! state.
6//!
7//! High-level flow:
8//!
9//! - track prompt timing and last-failure details
10//! - maintain REPL scope stack and small in-memory caches
11//! - bundle session state that host code needs to carry between dispatches
12//!
13//! Contract:
14//!
15//! - session data here is narrower-lived than the runtime state in
16//!   [`super::runtime`]
17//! - long-lived environment/config/plugin bootstrap state should not drift into
18//!   this module
19//!
20//! Public API shape:
21//!
22//! - use [`AppSession::builder`] or [`AppSession::with_cache_limit`] plus the
23//!   `with_*` chainers for session-scoped REPL state
24//! - use [`AppStateBuilder`] when you need a fully assembled runtime/session
25//!   snapshot outside the full CLI bootstrap
26//! - these types are host machinery, not lightweight semantic DTOs
27
28use std::collections::{HashMap, VecDeque};
29use std::sync::{Arc, RwLock};
30use std::time::Duration;
31
32use crate::config::{ConfigLayer, DEFAULT_SESSION_CACHE_MAX_RESULTS};
33use crate::core::row::Row;
34use crate::native::NativeCommandRegistry;
35use crate::plugin::PluginManager;
36use crate::repl::HistoryShellContext;
37
38use super::command_output::CliCommandResult;
39use super::runtime::{AppClients, AppRuntime, LaunchContext, RuntimeContext, UiState};
40use super::timing::TimingSummary;
41
42#[derive(Debug, Clone, Copy, Default)]
43/// Timing badge rendered in the prompt for the most recent command.
44pub struct DebugTimingBadge {
45    /// Prompt detail level used when rendering the badge.
46    pub level: u8,
47    pub(crate) summary: TimingSummary,
48}
49
50/// Shared prompt-timing storage that dispatch code can update and prompt
51/// rendering can read.
52#[derive(Clone, Default, Debug)]
53pub struct DebugTimingState {
54    inner: Arc<RwLock<Option<DebugTimingBadge>>>,
55}
56
57impl DebugTimingState {
58    /// Stores the current timing badge.
59    pub fn set(&self, badge: DebugTimingBadge) {
60        if let Ok(mut guard) = self.inner.write() {
61            *guard = Some(badge);
62        }
63    }
64
65    /// Clears any stored timing badge.
66    pub fn clear(&self) {
67        if let Ok(mut guard) = self.inner.write() {
68            *guard = None;
69        }
70    }
71
72    /// Returns the current timing badge, if one is available.
73    pub fn badge(&self) -> Option<DebugTimingBadge> {
74        self.inner.read().map(|value| *value).unwrap_or(None)
75    }
76}
77
78/// One entered command scope inside the interactive REPL shell stack.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct ReplScopeFrame {
81    command: String,
82}
83
84impl ReplScopeFrame {
85    /// Creates a frame for the given command name.
86    ///
87    /// # Examples
88    ///
89    /// ```
90    /// use osp_cli::app::ReplScopeFrame;
91    ///
92    /// let frame = ReplScopeFrame::new("theme");
93    /// assert_eq!(frame.command(), "theme");
94    /// ```
95    pub fn new(command: impl Into<String>) -> Self {
96        Self {
97            command: command.into(),
98        }
99    }
100
101    /// Returns the command name associated with this scope frame.
102    pub fn command(&self) -> &str {
103        self.command.as_str()
104    }
105}
106
107/// Nested REPL command-scope stack used for shell-style scoped interaction.
108///
109/// This is what lets the REPL stay "inside" a command family while still
110/// rendering scope labels, help targets, and history prefixes consistently.
111#[derive(Debug, Clone, Default, PartialEq, Eq)]
112pub struct ReplScopeStack {
113    frames: Vec<ReplScopeFrame>,
114}
115
116impl ReplScopeStack {
117    /// Returns `true` when the REPL is at the top-level scope.
118    pub fn is_root(&self) -> bool {
119        self.frames.is_empty()
120    }
121
122    /// Pushes a new command scope onto the stack.
123    pub fn enter(&mut self, command: impl Into<String>) {
124        self.frames.push(ReplScopeFrame::new(command));
125    }
126
127    /// Pops the current command scope from the stack.
128    pub fn leave(&mut self) -> Option<ReplScopeFrame> {
129        self.frames.pop()
130    }
131
132    /// Returns the command path represented by the current stack.
133    pub fn commands(&self) -> Vec<String> {
134        self.frames
135            .iter()
136            .map(|frame| frame.command.clone())
137            .collect()
138    }
139
140    /// Returns whether the stack already contains the given command.
141    pub fn contains_command(&self, command: &str) -> bool {
142        self.frames
143            .iter()
144            .any(|frame| frame.command.eq_ignore_ascii_case(command))
145    }
146
147    /// Returns a human-readable label for the current scope path.
148    ///
149    /// # Examples
150    ///
151    /// ```
152    /// use osp_cli::app::ReplScopeStack;
153    ///
154    /// let mut scope = ReplScopeStack::default();
155    /// assert_eq!(scope.display_label(), None);
156    ///
157    /// scope.enter("theme");
158    /// scope.enter("show");
159    /// assert_eq!(scope.display_label(), Some("theme / show".to_string()));
160    /// ```
161    pub fn display_label(&self) -> Option<String> {
162        if self.is_root() {
163            None
164        } else {
165            Some(
166                self.frames
167                    .iter()
168                    .map(|frame| frame.command.as_str())
169                    .collect::<Vec<_>>()
170                    .join(" / "),
171            )
172        }
173    }
174
175    /// Returns the history prefix used for shell-backed history entries.
176    ///
177    /// # Examples
178    ///
179    /// ```
180    /// use osp_cli::app::ReplScopeStack;
181    ///
182    /// let mut scope = ReplScopeStack::default();
183    /// scope.enter("theme");
184    /// scope.enter("show");
185    ///
186    /// assert_eq!(scope.history_prefix(), "theme show ");
187    /// ```
188    pub fn history_prefix(&self) -> String {
189        if self.is_root() {
190            String::new()
191        } else {
192            format!(
193                "{} ",
194                self.frames
195                    .iter()
196                    .map(|frame| frame.command.as_str())
197                    .collect::<Vec<_>>()
198                    .join(" ")
199            )
200        }
201    }
202
203    /// Returns the active history scope prefix, if the REPL is inside a shell.
204    ///
205    /// # Examples
206    ///
207    /// ```
208    /// use osp_cli::app::ReplScopeStack;
209    ///
210    /// let mut scope = ReplScopeStack::default();
211    /// assert_eq!(scope.history_scope_prefix(), None);
212    ///
213    /// scope.enter("theme");
214    /// assert_eq!(scope.history_scope_prefix(), Some("theme ".to_string()));
215    /// ```
216    pub fn history_scope_prefix(&self) -> Option<String> {
217        let prefix = self.history_prefix();
218        if prefix.is_empty() {
219            None
220        } else {
221            Some(prefix)
222        }
223    }
224
225    /// Returns the user-facing label for the current history scope.
226    ///
227    /// # Examples
228    ///
229    /// ```
230    /// use osp_cli::app::ReplScopeStack;
231    ///
232    /// let mut scope = ReplScopeStack::default();
233    /// assert_eq!(scope.history_scope_label(), "root history");
234    ///
235    /// scope.enter("theme");
236    /// scope.enter("show");
237    /// assert_eq!(scope.history_scope_label(), "theme / show shell history");
238    /// ```
239    pub fn history_scope_label(&self) -> String {
240        self.display_label()
241            .map(|label| format!("{label} shell history"))
242            .unwrap_or_else(|| "root history".to_string())
243    }
244
245    /// Prepends the active scope path unless the tokens are already scoped.
246    ///
247    /// # Examples
248    ///
249    /// ```
250    /// use osp_cli::app::ReplScopeStack;
251    ///
252    /// let mut scope = ReplScopeStack::default();
253    /// scope.enter("theme");
254    ///
255    /// assert_eq!(
256    ///     scope.prefixed_tokens(&["show".to_string(), "dracula".to_string()]),
257    ///     vec!["theme".to_string(), "show".to_string(), "dracula".to_string()]
258    /// );
259    /// ```
260    pub fn prefixed_tokens(&self, tokens: &[String]) -> Vec<String> {
261        let prefix = self.commands();
262        if prefix.is_empty() || tokens.starts_with(&prefix) {
263            return tokens.to_vec();
264        }
265        let mut full = prefix;
266        full.extend_from_slice(tokens);
267        full
268    }
269
270    /// Returns help tokens for the current scope.
271    ///
272    /// # Examples
273    ///
274    /// ```
275    /// use osp_cli::app::ReplScopeStack;
276    ///
277    /// let mut scope = ReplScopeStack::default();
278    /// scope.enter("theme");
279    ///
280    /// assert_eq!(scope.help_tokens(), vec!["theme".to_string(), "--help".to_string()]);
281    /// ```
282    pub fn help_tokens(&self) -> Vec<String> {
283        let mut tokens = self.commands();
284        if !tokens.is_empty() {
285            tokens.push("--help".to_string());
286        }
287        tokens
288    }
289}
290
291/// Session-scoped REPL state, caches, and prompt metadata.
292#[non_exhaustive]
293#[must_use]
294pub struct AppSession {
295    /// Prompt prefix shown before any scope label.
296    pub prompt_prefix: String,
297    /// Whether history capture is enabled for this session.
298    pub history_enabled: bool,
299    /// Shell-scoped history prefix state shared with the history store.
300    pub history_shell: HistoryShellContext,
301    /// Shared prompt timing badge state.
302    pub prompt_timing: DebugTimingState,
303    pub(crate) startup_prompt_timing_pending: bool,
304    /// Current nested command scope within the REPL.
305    pub scope: ReplScopeStack,
306    /// Rows returned by the most recent successful REPL command.
307    pub last_rows: Vec<Row>,
308    /// Summary of the most recent failed REPL command.
309    pub last_failure: Option<LastFailure>,
310    /// Cached row outputs keyed by command line.
311    pub result_cache: HashMap<String, Vec<Row>>,
312    /// Eviction order for the row-result cache.
313    pub cache_order: VecDeque<String>,
314    pub(crate) command_cache: HashMap<String, CliCommandResult>,
315    pub(crate) command_cache_order: VecDeque<String>,
316    /// Maximum number of cached result sets to retain.
317    pub max_cached_results: usize,
318    /// Session-scoped config overrides layered above persisted config.
319    pub config_overrides: ConfigLayer,
320}
321
322#[derive(Debug, Clone, PartialEq, Eq)]
323/// Summary of the last failed REPL command.
324pub struct LastFailure {
325    /// Command line that produced the failure.
326    pub command_line: String,
327    /// Short failure summary suitable for prompts or status output.
328    pub summary: String,
329    /// Longer failure detail for follow-up inspection.
330    pub detail: String,
331}
332
333#[derive(Debug, Clone, PartialEq, Eq)]
334pub(crate) enum ReplExitTransition {
335    ExitRoot,
336    LeftShell {
337        frame: ReplScopeFrame,
338        now_root: bool,
339    },
340}
341
342#[derive(Debug, Clone)]
343pub(crate) struct AppSessionRebuildState {
344    prompt_prefix: String,
345    history_enabled: bool,
346    history_shell: HistoryShellContext,
347    prompt_timing: DebugTimingState,
348    startup_prompt_timing_pending: bool,
349    scope: ReplScopeStack,
350    last_rows: Vec<Row>,
351    last_failure: Option<LastFailure>,
352    result_cache: HashMap<String, Vec<Row>>,
353    cache_order: VecDeque<String>,
354    max_cached_results: usize,
355    config_overrides: ConfigLayer,
356}
357
358impl AppSessionRebuildState {
359    pub(crate) fn is_scoped(&self) -> bool {
360        !self.scope.is_root()
361    }
362
363    pub(crate) fn session_layer(&self) -> Option<ConfigLayer> {
364        (!self.config_overrides.entries().is_empty()).then(|| self.config_overrides.clone())
365    }
366
367    fn restore_into(self, next: &mut AppSession) {
368        next.prompt_prefix = self.prompt_prefix;
369        next.history_enabled = self.history_enabled;
370        next.history_shell = self.history_shell;
371        next.prompt_timing = self.prompt_timing;
372        next.startup_prompt_timing_pending = self.startup_prompt_timing_pending;
373        next.scope = self.scope;
374        next.last_rows = self.last_rows;
375        next.last_failure = self.last_failure;
376        next.result_cache = self.result_cache;
377        next.cache_order = self.cache_order;
378        // Command execution results depend on live runtime/plugin/config state,
379        // so a rebuild keeps row history but must drop command-result caches.
380        next.command_cache.clear();
381        next.command_cache_order.clear();
382        next.max_cached_results = self.max_cached_results;
383        next.config_overrides = self.config_overrides;
384        next.sync_history_shell_context();
385    }
386}
387
388impl AppSession {
389    /// Starts the builder for session-scoped host state.
390    ///
391    /// Prefer this when you want a neutral starting point and do not want the
392    /// first constructor call to imply that cache sizing is the primary concern.
393    ///
394    /// # Examples
395    ///
396    /// ```
397    /// use osp_cli::app::AppSession;
398    ///
399    /// let session = AppSession::builder()
400    ///     .with_prompt_prefix("demo")
401    ///     .with_history_enabled(false)
402    ///     .build();
403    ///
404    /// assert_eq!(session.prompt_prefix, "demo");
405    /// assert!(!session.history_enabled);
406    /// ```
407    pub fn builder() -> AppSessionBuilder {
408        AppSessionBuilder::new()
409    }
410
411    /// Creates a session with bounded caches for row and command results.
412    ///
413    /// A requested cache limit of `0` is clamped to `1` so the session never
414    /// stores a zero-capacity cache by accident.
415    ///
416    /// # Examples
417    ///
418    /// ```
419    /// use osp_cli::app::AppSession;
420    ///
421    /// let session = AppSession::with_cache_limit(4).with_prompt_prefix("demo");
422    ///
423    /// assert_eq!(session.max_cached_results, 4);
424    /// assert_eq!(session.prompt_prefix, "demo");
425    /// ```
426    pub fn with_cache_limit(max_cached_results: usize) -> Self {
427        let bounded = max_cached_results.max(1);
428        Self {
429            prompt_prefix: "osp".to_string(),
430            history_enabled: true,
431            history_shell: HistoryShellContext::default(),
432            prompt_timing: DebugTimingState::default(),
433            startup_prompt_timing_pending: true,
434            scope: ReplScopeStack::default(),
435            last_rows: Vec::new(),
436            last_failure: None,
437            result_cache: HashMap::new(),
438            cache_order: VecDeque::new(),
439            command_cache: HashMap::new(),
440            command_cache_order: VecDeque::new(),
441            max_cached_results: bounded,
442            config_overrides: ConfigLayer::default(),
443        }
444    }
445
446    /// Creates the default session snapshot for the current resolved config.
447    pub(crate) fn from_resolved_config(config: &crate::config::ResolvedConfig) -> Self {
448        let session_cache_max_results = crate::app::config_usize(
449            config,
450            "session.cache.max_results",
451            DEFAULT_SESSION_CACHE_MAX_RESULTS as usize,
452        );
453        Self::with_cache_limit(session_cache_max_results)
454    }
455
456    /// Creates the default session snapshot for the current resolved config
457    /// and attaches the supplied session-layer overrides.
458    pub(crate) fn from_resolved_config_with_overrides(
459        config: &crate::config::ResolvedConfig,
460        config_overrides: ConfigLayer,
461    ) -> Self {
462        Self::with_cache_limit(crate::app::config_usize(
463            config,
464            "session.cache.max_results",
465            DEFAULT_SESSION_CACHE_MAX_RESULTS as usize,
466        ))
467        .with_config_overrides(config_overrides)
468    }
469
470    /// Replaces the prompt prefix shown ahead of any scope label.
471    pub fn with_prompt_prefix(mut self, prompt_prefix: impl Into<String>) -> Self {
472        self.prompt_prefix = prompt_prefix.into();
473        self
474    }
475
476    /// Enables or disables history capture for this session.
477    pub fn with_history_enabled(mut self, history_enabled: bool) -> Self {
478        self.history_enabled = history_enabled;
479        self
480    }
481
482    /// Replaces the shell-scoped history context shared with the history store.
483    pub fn with_history_shell(mut self, history_shell: HistoryShellContext) -> Self {
484        self.history_shell = history_shell;
485        self
486    }
487
488    /// Replaces the session-scoped config overrides layered above persisted config.
489    pub fn with_config_overrides(mut self, config_overrides: ConfigLayer) -> Self {
490        self.config_overrides = config_overrides;
491        self
492    }
493
494    /// Enters a nested REPL shell scope and synchronizes history context.
495    pub fn enter_repl_scope(&mut self, command: impl Into<String>) {
496        self.scope.enter(command);
497        self.sync_history_shell_context();
498    }
499
500    /// Leaves the current REPL shell scope and synchronizes history context.
501    pub fn leave_repl_scope(&mut self) -> Option<ReplScopeFrame> {
502        let frame = self.scope.leave()?;
503        self.sync_history_shell_context();
504        Some(frame)
505    }
506
507    /// Applies a user `exit` request against the current REPL scope.
508    pub(crate) fn request_repl_exit(&mut self) -> ReplExitTransition {
509        if self.scope.is_root() {
510            self.sync_history_shell_context();
511            ReplExitTransition::ExitRoot
512        } else {
513            match self.leave_repl_scope() {
514                Some(frame) => ReplExitTransition::LeftShell {
515                    now_root: self.scope.is_root(),
516                    frame,
517                },
518                None => ReplExitTransition::ExitRoot,
519            }
520        }
521    }
522
523    /// Finalizes a completed REPL line by synchronizing derived session state.
524    pub(crate) fn finish_repl_line(&self) {
525        self.sync_history_shell_context();
526    }
527
528    /// Captures the session-scoped state that must survive a runtime rebuild.
529    pub(crate) fn capture_rebuild_state(&self) -> AppSessionRebuildState {
530        AppSessionRebuildState {
531            prompt_prefix: self.prompt_prefix.clone(),
532            history_enabled: self.history_enabled,
533            history_shell: self.history_shell.clone(),
534            prompt_timing: self.prompt_timing.clone(),
535            startup_prompt_timing_pending: self.startup_prompt_timing_pending,
536            scope: self.scope.clone(),
537            last_rows: self.last_rows.clone(),
538            last_failure: self.last_failure.clone(),
539            result_cache: self.result_cache.clone(),
540            cache_order: self.cache_order.clone(),
541            max_cached_results: self.max_cached_results,
542            config_overrides: self.config_overrides.clone(),
543        }
544    }
545
546    /// Restores session-scoped state after a runtime rebuild.
547    pub(crate) fn restore_rebuild_state(&mut self, state: AppSessionRebuildState) {
548        state.restore_into(self);
549    }
550
551    /// Stores the latest successful row output and updates the result cache.
552    pub fn record_result(&mut self, command_line: &str, rows: Vec<Row>) {
553        let key = command_line.trim().to_string();
554        if key.is_empty() {
555            return;
556        }
557
558        self.last_rows = rows.clone();
559        if !self.result_cache.contains_key(&key)
560            && self.result_cache.len() >= self.max_cached_results
561            && let Some(evict_key) = self.cache_order.pop_front()
562        {
563            self.result_cache.remove(&evict_key);
564        }
565
566        self.cache_order.retain(|item| item != &key);
567        self.cache_order.push_back(key.clone());
568        self.result_cache.insert(key, rows);
569    }
570
571    /// Records details about the latest failed command.
572    pub fn record_failure(
573        &mut self,
574        command_line: &str,
575        summary: impl Into<String>,
576        detail: impl Into<String>,
577    ) {
578        let command_line = command_line.trim().to_string();
579        if command_line.is_empty() {
580            return;
581        }
582        self.last_failure = Some(LastFailure {
583            command_line,
584            summary: summary.into(),
585            detail: detail.into(),
586        });
587    }
588
589    /// Returns cached rows for a previously executed command line.
590    pub fn cached_rows(&self, command_line: &str) -> Option<&[Row]> {
591        self.result_cache
592            .get(command_line.trim())
593            .map(|rows| rows.as_slice())
594    }
595
596    pub(crate) fn record_cached_command(&mut self, cache_key: &str, result: &CliCommandResult) {
597        let cache_key = cache_key.trim().to_string();
598        if cache_key.is_empty() {
599            return;
600        }
601
602        if !self.command_cache.contains_key(&cache_key)
603            && self.command_cache.len() >= self.max_cached_results
604            && let Some(evict_key) = self.command_cache_order.pop_front()
605        {
606            self.command_cache.remove(&evict_key);
607        }
608
609        self.command_cache_order.retain(|item| item != &cache_key);
610        self.command_cache_order.push_back(cache_key.clone());
611        self.command_cache.insert(cache_key, result.clone());
612    }
613
614    pub(crate) fn cached_command(&self, cache_key: &str) -> Option<CliCommandResult> {
615        self.command_cache.get(cache_key.trim()).cloned()
616    }
617
618    /// Updates the prompt timing badge for the most recent command.
619    pub fn record_prompt_timing(
620        &self,
621        level: u8,
622        total: Duration,
623        parse: Option<Duration>,
624        execute: Option<Duration>,
625        render: Option<Duration>,
626    ) {
627        if level == 0 {
628            self.prompt_timing.clear();
629            return;
630        }
631
632        self.prompt_timing.set(DebugTimingBadge {
633            level,
634            summary: TimingSummary {
635                total,
636                parse,
637                execute,
638                render,
639            },
640        });
641    }
642
643    /// Seeds the initial prompt timing badge emitted during startup.
644    pub fn seed_startup_prompt_timing(&mut self, level: u8, total: Duration) {
645        if !self.startup_prompt_timing_pending {
646            return;
647        }
648        self.startup_prompt_timing_pending = false;
649        if level == 0 {
650            return;
651        }
652
653        self.prompt_timing.set(DebugTimingBadge {
654            level,
655            summary: TimingSummary {
656                total,
657                parse: None,
658                execute: None,
659                render: None,
660            },
661        });
662    }
663
664    /// Synchronizes history context with the current REPL scope.
665    pub fn sync_history_shell_context(&self) {
666        self.history_shell.set_prefix(self.scope.history_prefix());
667    }
668}
669
670impl Default for AppSession {
671    fn default() -> Self {
672        Self::with_cache_limit(DEFAULT_SESSION_CACHE_MAX_RESULTS as usize)
673    }
674}
675
676/// Builder for [`AppSession`].
677///
678/// Prefer this when callers want a neutral session-construction entrypoint and
679/// plan to configure prompt/history behavior before building the final value.
680#[must_use]
681pub struct AppSessionBuilder {
682    prompt_prefix: String,
683    history_enabled: bool,
684    history_shell: HistoryShellContext,
685    max_cached_results: usize,
686    config_overrides: ConfigLayer,
687}
688
689impl Default for AppSessionBuilder {
690    fn default() -> Self {
691        Self::new()
692    }
693}
694
695impl AppSessionBuilder {
696    /// Starts a session builder with the crate's default prompt and cache size.
697    pub fn new() -> Self {
698        Self {
699            prompt_prefix: "osp".to_string(),
700            history_enabled: true,
701            history_shell: HistoryShellContext::default(),
702            max_cached_results: DEFAULT_SESSION_CACHE_MAX_RESULTS as usize,
703            config_overrides: ConfigLayer::default(),
704        }
705    }
706
707    /// Replaces the prompt prefix shown ahead of any scope label.
708    pub fn with_prompt_prefix(mut self, prompt_prefix: impl Into<String>) -> Self {
709        self.prompt_prefix = prompt_prefix.into();
710        self
711    }
712
713    /// Enables or disables history capture for the built session.
714    pub fn with_history_enabled(mut self, history_enabled: bool) -> Self {
715        self.history_enabled = history_enabled;
716        self
717    }
718
719    /// Replaces the shell-scoped history context shared with the history store.
720    pub fn with_history_shell(mut self, history_shell: HistoryShellContext) -> Self {
721        self.history_shell = history_shell;
722        self
723    }
724
725    /// Replaces the maximum number of cached row/command results.
726    pub fn with_cache_limit(mut self, max_cached_results: usize) -> Self {
727        self.max_cached_results = max_cached_results;
728        self
729    }
730
731    /// Replaces the session-scoped config overrides layered above persisted config.
732    pub fn with_config_overrides(mut self, config_overrides: ConfigLayer) -> Self {
733        self.config_overrides = config_overrides;
734        self
735    }
736
737    /// Builds the configured [`AppSession`].
738    pub fn build(self) -> AppSession {
739        AppSession::with_cache_limit(self.max_cached_results)
740            .with_prompt_prefix(self.prompt_prefix)
741            .with_history_enabled(self.history_enabled)
742            .with_history_shell(self.history_shell)
743            .with_config_overrides(self.config_overrides)
744    }
745}
746
747pub(crate) struct AppStateInit {
748    pub context: RuntimeContext,
749    pub config: crate::config::ResolvedConfig,
750    pub render_settings: crate::ui::RenderSettings,
751    pub message_verbosity: crate::ui::messages::MessageLevel,
752    pub debug_verbosity: u8,
753    pub plugins: crate::plugin::PluginManager,
754    pub native_commands: NativeCommandRegistry,
755    pub themes: crate::ui::theme_catalog::ThemeCatalog,
756    pub launch: LaunchContext,
757}
758
759pub(crate) struct AppStateParts {
760    pub runtime: AppRuntime,
761    pub session: AppSession,
762    pub clients: AppClients,
763}
764
765impl AppStateParts {
766    fn from_init(init: AppStateInit, session_override: Option<AppSession>) -> Self {
767        let clients = AppClients::new(init.plugins, init.native_commands);
768        let config = crate::app::ConfigState::new(init.config);
769        let ui = crate::app::UiState::new(
770            init.render_settings,
771            init.message_verbosity,
772            init.debug_verbosity,
773        );
774        let auth = crate::app::AuthState::from_resolved_with_external_policies(
775            config.resolved(),
776            clients.plugins(),
777            clients.native_commands(),
778        );
779        let runtime = AppRuntime::new(init.context, config, ui, auth, init.themes, init.launch);
780        let session = session_override
781            .unwrap_or_else(|| AppSession::from_resolved_config(runtime.config.resolved()));
782
783        Self {
784            runtime,
785            session,
786            clients,
787        }
788    }
789}
790
791/// Aggregate application state shared between runtime and session logic.
792#[non_exhaustive]
793#[must_use]
794pub struct AppState {
795    /// Runtime-scoped services and resolved config state.
796    pub runtime: AppRuntime,
797    /// Session-scoped REPL caches and prompt metadata.
798    pub session: AppSession,
799    /// Shared client registries used during command execution.
800    pub clients: AppClients,
801}
802
803impl AppState {
804    /// Builds a full application-state snapshot by deriving UI state from the
805    /// resolved config and runtime context.
806    ///
807    /// # Examples
808    ///
809    /// ```
810    /// use osp_cli::app::{AppState, RuntimeContext, TerminalKind};
811    /// use osp_cli::config::{ConfigLayer, ConfigResolver, ResolveOptions};
812    ///
813    /// let mut defaults = ConfigLayer::default();
814    /// defaults.set("profile.default", "default");
815    /// defaults.set("ui.message.verbosity", "warning");
816    ///
817    /// let mut resolver = ConfigResolver::default();
818    /// resolver.set_defaults(defaults);
819    /// let config = resolver.resolve(ResolveOptions::new().with_terminal("repl")).unwrap();
820    ///
821    /// let state = AppState::from_resolved_config(
822    ///     RuntimeContext::new(None, TerminalKind::Repl, None),
823    ///     config,
824    /// )
825    /// .unwrap();
826    ///
827    /// assert_eq!(state.runtime.config.resolved().active_profile(), "default");
828    /// assert_eq!(state.runtime.ui.message_verbosity.as_env_str(), "warning");
829    /// assert!(state.clients.plugins().explicit_dirs().is_empty());
830    /// ```
831    pub fn from_resolved_config(
832        context: RuntimeContext,
833        config: crate::config::ResolvedConfig,
834    ) -> miette::Result<Self> {
835        AppStateBuilder::from_resolved_config(context, config).map(AppStateBuilder::build)
836    }
837
838    #[cfg(test)]
839    pub(crate) fn new(init: AppStateInit) -> Self {
840        Self::from_parts(AppStateParts::from_init(init, None))
841    }
842
843    pub(crate) fn from_parts(parts: AppStateParts) -> Self {
844        Self {
845            runtime: parts.runtime,
846            session: parts.session,
847            clients: parts.clients,
848        }
849    }
850
851    pub(crate) fn replace_parts(&mut self, parts: AppStateParts) {
852        self.runtime = parts.runtime;
853        self.session = parts.session;
854        self.clients = parts.clients;
855    }
856
857    /// Returns the prompt prefix configured for the current session.
858    pub fn prompt_prefix(&self) -> String {
859        self.session.prompt_prefix.clone()
860    }
861
862    /// Synchronizes the history shell context with the current session scope.
863    pub fn sync_history_shell_context(&self) {
864        self.session.sync_history_shell_context();
865    }
866
867    /// Records rows produced by a REPL command.
868    pub fn record_repl_rows(&mut self, command_line: &str, rows: Vec<Row>) {
869        self.session.record_result(command_line, rows);
870    }
871
872    /// Records a failed REPL command and its associated messages.
873    pub fn record_repl_failure(
874        &mut self,
875        command_line: &str,
876        summary: impl Into<String>,
877        detail: impl Into<String>,
878    ) {
879        self.session.record_failure(command_line, summary, detail);
880    }
881
882    /// Returns the rows from the most recent successful REPL command.
883    pub fn last_repl_rows(&self) -> Vec<Row> {
884        self.session.last_rows.clone()
885    }
886
887    /// Returns details about the most recent failed REPL command.
888    pub fn last_repl_failure(&self) -> Option<LastFailure> {
889        self.session.last_failure.clone()
890    }
891
892    /// Returns cached rows for a previously executed REPL command.
893    pub fn cached_repl_rows(&self, command_line: &str) -> Option<Vec<Row>> {
894        self.session
895            .cached_rows(command_line)
896            .map(ToOwned::to_owned)
897    }
898
899    /// Returns the number of cached REPL result sets.
900    pub fn repl_cache_size(&self) -> usize {
901        self.session.result_cache.len()
902    }
903}
904
905/// Builder for [`AppState`].
906///
907/// This is the canonical manual-construction factory for runtime/session/client
908/// state when callers need a snapshot without going through full CLI bootstrap.
909///
910/// Use [`AppStateBuilder::from_resolved_config`] for the normal config-driven
911/// path, then override specific pieces such as the session or plugin manager as
912/// needed. Use [`AppStateBuilder::new`] only when the caller already has a
913/// fully chosen [`UiState`] and wants the builder to assemble the remaining
914/// runtime/session/client pieces around it.
915///
916/// # Examples
917///
918/// ```
919/// use osp_cli::app::{AppSession, AppStateBuilder, RuntimeContext, TerminalKind};
920/// use osp_cli::config::{ConfigLayer, ConfigResolver, ResolveOptions};
921///
922/// let mut defaults = ConfigLayer::default();
923/// defaults.set("profile.default", "default");
924///
925/// let mut resolver = ConfigResolver::default();
926/// resolver.set_defaults(defaults);
927/// let config = resolver.resolve(ResolveOptions::new().with_terminal("repl")).unwrap();
928///
929/// let state = AppStateBuilder::from_resolved_config(
930///     RuntimeContext::new(None, TerminalKind::Repl, None),
931///     config,
932/// )?
933/// .with_session(AppSession::with_cache_limit(32).with_prompt_prefix("demo"))
934/// .build();
935///
936/// assert_eq!(state.prompt_prefix(), "demo");
937/// # Ok::<(), miette::Report>(())
938/// ```
939#[must_use]
940pub struct AppStateBuilder {
941    context: RuntimeContext,
942    config: crate::config::ResolvedConfig,
943    ui: UiState,
944    launch: LaunchContext,
945    plugins: Option<PluginManager>,
946    native_commands: NativeCommandRegistry,
947    session: Option<AppSession>,
948    themes: Option<crate::ui::theme_catalog::ThemeCatalog>,
949}
950
951impl AppStateBuilder {
952    /// Starts building an application-state snapshot from the resolved config
953    /// and UI state the caller wants to expose.
954    ///
955    /// This is the manual-construction path. Prefer
956    /// [`AppStateBuilder::from_resolved_config`] when the builder should derive
957    /// UI defaults from config and runtime context first.
958    pub fn new(
959        context: RuntimeContext,
960        config: crate::config::ResolvedConfig,
961        ui: UiState,
962    ) -> Self {
963        Self {
964            context,
965            config,
966            ui,
967            launch: LaunchContext::default(),
968            plugins: None,
969            native_commands: NativeCommandRegistry::default(),
970            session: None,
971            themes: None,
972        }
973    }
974
975    pub(crate) fn from_host_inputs(
976        context: RuntimeContext,
977        config: crate::config::ResolvedConfig,
978        host_inputs: crate::app::assembly::ResolvedHostInputs,
979    ) -> Self {
980        Self {
981            context,
982            config,
983            ui: host_inputs.ui,
984            launch: LaunchContext::default(),
985            plugins: Some(host_inputs.plugins),
986            native_commands: NativeCommandRegistry::default(),
987            session: Some(host_inputs.default_session),
988            themes: Some(host_inputs.themes),
989        }
990    }
991
992    /// Starts a builder by deriving UI state from the resolved config and
993    /// runtime context.
994    ///
995    /// This is the canonical embedder entrypoint when you want one coherent
996    /// host snapshot and only plan to override selected pieces before build.
997    pub fn from_resolved_config(
998        context: RuntimeContext,
999        config: crate::config::ResolvedConfig,
1000    ) -> miette::Result<Self> {
1001        // This path is the canonical embedder factory: derive host inputs once
1002        // and hand callers a coherent runtime/session snapshot. Plugins are
1003        // intentionally derived later at build time from the final launch
1004        // context, because callers may still override launch roots before the
1005        // state is assembled.
1006        let host_inputs = crate::app::assembly::ResolvedHostInputs::derive(
1007            &context,
1008            &config,
1009            &LaunchContext::default(),
1010            crate::app::assembly::RenderSettingsSeed::DefaultAuto,
1011            None,
1012            None,
1013            None,
1014        )?;
1015        crate::ui::theme_catalog::log_theme_issues(&host_inputs.themes.issues);
1016        Ok(Self {
1017            context,
1018            config,
1019            ui: host_inputs.ui,
1020            launch: LaunchContext::default(),
1021            plugins: None,
1022            native_commands: NativeCommandRegistry::default(),
1023            session: Some(host_inputs.default_session),
1024            themes: Some(host_inputs.themes),
1025        })
1026    }
1027
1028    /// Replaces the launch-time provenance used for cache and plugin setup.
1029    ///
1030    /// If omitted, the builder keeps [`LaunchContext::default`].
1031    pub fn with_launch(mut self, launch: LaunchContext) -> Self {
1032        self.launch = launch;
1033        self
1034    }
1035
1036    /// Replaces the plugin manager used when assembling shared clients.
1037    ///
1038    /// If omitted, the builder derives the plugin manager from the resolved
1039    /// config plus the current launch context during [`AppStateBuilder::build`].
1040    pub fn with_plugins(mut self, plugins: PluginManager) -> Self {
1041        self.plugins = Some(plugins);
1042        self
1043    }
1044
1045    /// Replaces the native command registry used when assembling shared
1046    /// clients.
1047    ///
1048    /// If omitted, the builder keeps [`NativeCommandRegistry::default`].
1049    pub fn with_native_commands(mut self, native_commands: NativeCommandRegistry) -> Self {
1050        self.native_commands = native_commands;
1051        self
1052    }
1053
1054    /// Replaces the session snapshot carried by the built app state.
1055    ///
1056    /// If omitted, the builder uses the derived/default session for the
1057    /// current config.
1058    pub fn with_session(mut self, session: AppSession) -> Self {
1059        self.session = Some(session);
1060        self
1061    }
1062
1063    /// Builds the configured [`AppState`].
1064    ///
1065    /// This assembles one coherent runtime/session/client snapshot, deriving any
1066    /// omitted pieces such as themes, plugin manager, and default session before
1067    /// returning the final value.
1068    pub fn build(self) -> AppState {
1069        let Self {
1070            context,
1071            config,
1072            ui,
1073            launch,
1074            plugins,
1075            native_commands,
1076            session,
1077            themes,
1078        } = self;
1079        let should_log_theme_issues = themes.is_none();
1080        let derived_defaults = if themes.is_none() || plugins.is_none() || session.is_none() {
1081            Some(crate::app::assembly::derive_host_defaults(
1082                &config, &launch, None, None,
1083            ))
1084        } else {
1085            None
1086        };
1087        let (derived_themes, derived_plugins, derived_session) = match derived_defaults {
1088            Some(defaults) => (
1089                Some(defaults.themes),
1090                Some(defaults.plugins),
1091                Some(defaults.default_session),
1092            ),
1093            None => (None, None, None),
1094        };
1095        let themes = themes.or(derived_themes).unwrap_or_else(|| {
1096            crate::app::assembly::derive_host_defaults(&config, &launch, None, None).themes
1097        });
1098        let plugins = plugins.or(derived_plugins).unwrap_or_else(|| {
1099            crate::app::assembly::derive_host_defaults(&config, &launch, None, None).plugins
1100        });
1101        let session = session.or(derived_session).or_else(|| {
1102            Some(
1103                crate::app::assembly::derive_host_defaults(&config, &launch, None, None)
1104                    .default_session,
1105            )
1106        });
1107        if should_log_theme_issues {
1108            crate::ui::theme_catalog::log_theme_issues(&themes.issues);
1109        }
1110
1111        let crate::app::UiState {
1112            render_settings,
1113            message_verbosity,
1114            debug_verbosity,
1115            ..
1116        } = ui;
1117
1118        AppState::from_parts(AppStateParts::from_init(
1119            AppStateInit {
1120                context,
1121                config,
1122                render_settings,
1123                message_verbosity,
1124                debug_verbosity,
1125                plugins,
1126                native_commands,
1127                themes,
1128                launch,
1129            },
1130            session,
1131        ))
1132    }
1133}
1134
1135#[cfg(test)]
1136mod tests {
1137    use std::time::Duration;
1138
1139    use serde_json::Value;
1140
1141    use super::{AppSession, ReplExitTransition};
1142    use crate::config::ConfigLayer;
1143
1144    #[test]
1145    fn request_repl_exit_tracks_root_and_nested_scope_transitions_unit() {
1146        let mut root = AppSession::with_cache_limit(4);
1147        assert!(matches!(
1148            root.request_repl_exit(),
1149            ReplExitTransition::ExitRoot
1150        ));
1151
1152        let mut nested = AppSession::with_cache_limit(4);
1153        nested.enter_repl_scope("ldap");
1154        assert!(matches!(
1155            nested.request_repl_exit(),
1156            ReplExitTransition::LeftShell {
1157                now_root: true,
1158                frame,
1159            } if frame.command() == "ldap"
1160        ));
1161        assert!(nested.scope.is_root());
1162
1163        let mut deep = AppSession::with_cache_limit(4);
1164        deep.enter_repl_scope("ldap");
1165        deep.enter_repl_scope("user");
1166        assert!(matches!(
1167            deep.request_repl_exit(),
1168            ReplExitTransition::LeftShell {
1169                now_root: false,
1170                frame,
1171            } if frame.command() == "user"
1172        ));
1173        assert_eq!(deep.scope.commands(), vec!["ldap".to_string()]);
1174    }
1175
1176    #[test]
1177    fn rebuild_state_round_trip_preserves_rows_and_scope_unit() {
1178        let mut session = AppSession::with_cache_limit(4)
1179            .with_prompt_prefix("osp-dev")
1180            .with_history_enabled(false);
1181        let mut overrides = ConfigLayer::default();
1182        overrides.set("ui.format", "json");
1183        session = session.with_config_overrides(overrides);
1184        session.max_cached_results = 7;
1185        session.enter_repl_scope("ldap");
1186        session.enter_repl_scope("user");
1187        session.record_prompt_timing(2, Duration::from_secs(3), None, None, None);
1188        session.startup_prompt_timing_pending = false;
1189
1190        let mut row = crate::core::row::Row::new();
1191        row.insert("name".to_string(), Value::from("alice"));
1192        session.record_result("list users", vec![row.clone()]);
1193        session.record_failure("list users", "Command failed", "detail");
1194        session.record_cached_command("config show", &super::CliCommandResult::text("cached"));
1195
1196        let snapshot = session.capture_rebuild_state();
1197        let mut restored = AppSession::with_cache_limit(1);
1198        restored.restore_rebuild_state(snapshot);
1199
1200        assert_eq!(restored.prompt_prefix, "osp-dev");
1201        assert!(!restored.history_enabled);
1202        assert_eq!(restored.max_cached_results, 7);
1203        assert_eq!(
1204            restored.scope.commands(),
1205            vec!["ldap".to_string(), "user".to_string()]
1206        );
1207        assert_eq!(
1208            restored.history_shell.prefix(),
1209            Some("ldap user ".to_string())
1210        );
1211        assert_eq!(restored.cached_rows("list users"), Some(&[row][..]));
1212        assert!(restored.command_cache.is_empty());
1213        assert!(restored.command_cache_order.is_empty());
1214        assert_eq!(
1215            restored
1216                .last_failure
1217                .as_ref()
1218                .map(|failure| failure.summary.as_str()),
1219            Some("Command failed")
1220        );
1221        assert_eq!(
1222            restored.prompt_timing.badge().map(|badge| badge.level),
1223            Some(2)
1224        );
1225        assert!(!restored.startup_prompt_timing_pending);
1226        assert_eq!(restored.config_overrides.entries().len(), 1);
1227        assert_eq!(restored.config_overrides.entries()[0].key, "ui.format");
1228        assert_eq!(
1229            restored.config_overrides.entries()[0].value.to_string(),
1230            "json"
1231        );
1232    }
1233}