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    /// Prepends the active scope path unless the tokens are already scoped.
204    ///
205    /// # Examples
206    ///
207    /// ```
208    /// use osp_cli::app::ReplScopeStack;
209    ///
210    /// let mut scope = ReplScopeStack::default();
211    /// scope.enter("theme");
212    ///
213    /// assert_eq!(
214    ///     scope.prefixed_tokens(&["show".to_string(), "dracula".to_string()]),
215    ///     vec!["theme".to_string(), "show".to_string(), "dracula".to_string()]
216    /// );
217    /// ```
218    pub fn prefixed_tokens(&self, tokens: &[String]) -> Vec<String> {
219        let prefix = self.commands();
220        if prefix.is_empty() || tokens.starts_with(&prefix) {
221            return tokens.to_vec();
222        }
223        let mut full = prefix;
224        full.extend_from_slice(tokens);
225        full
226    }
227
228    /// Returns help tokens for the current scope.
229    ///
230    /// # Examples
231    ///
232    /// ```
233    /// use osp_cli::app::ReplScopeStack;
234    ///
235    /// let mut scope = ReplScopeStack::default();
236    /// scope.enter("theme");
237    ///
238    /// assert_eq!(scope.help_tokens(), vec!["theme".to_string(), "--help".to_string()]);
239    /// ```
240    pub fn help_tokens(&self) -> Vec<String> {
241        let mut tokens = self.commands();
242        if !tokens.is_empty() {
243            tokens.push("--help".to_string());
244        }
245        tokens
246    }
247}
248
249/// Session-scoped REPL state, caches, and prompt metadata.
250#[non_exhaustive]
251#[must_use]
252pub struct AppSession {
253    /// Prompt prefix shown before any scope label.
254    pub prompt_prefix: String,
255    /// Whether history capture is enabled for this session.
256    pub history_enabled: bool,
257    /// Shell-scoped history prefix state shared with the history store.
258    pub history_shell: HistoryShellContext,
259    /// Shared prompt timing badge state.
260    pub prompt_timing: DebugTimingState,
261    pub(crate) startup_prompt_timing_pending: bool,
262    /// Current nested command scope within the REPL.
263    pub scope: ReplScopeStack,
264    /// Rows returned by the most recent successful REPL command.
265    pub last_rows: Vec<Row>,
266    /// Summary of the most recent failed REPL command.
267    pub last_failure: Option<LastFailure>,
268    /// Cached row outputs keyed by command line.
269    pub result_cache: HashMap<String, Vec<Row>>,
270    /// Eviction order for the row-result cache.
271    pub cache_order: VecDeque<String>,
272    pub(crate) command_cache: HashMap<String, CliCommandResult>,
273    pub(crate) command_cache_order: VecDeque<String>,
274    /// Maximum number of cached result sets to retain.
275    pub max_cached_results: usize,
276    /// Session-scoped config overrides layered above persisted config.
277    pub config_overrides: ConfigLayer,
278}
279
280#[derive(Debug, Clone, PartialEq, Eq)]
281/// Summary of the last failed REPL command.
282pub struct LastFailure {
283    /// Command line that produced the failure.
284    pub command_line: String,
285    /// Short failure summary suitable for prompts or status output.
286    pub summary: String,
287    /// Longer failure detail for follow-up inspection.
288    pub detail: String,
289}
290
291impl AppSession {
292    /// Starts the builder for session-scoped host state.
293    ///
294    /// Prefer this when you want a neutral starting point and do not want the
295    /// first constructor call to imply that cache sizing is the primary concern.
296    ///
297    /// # Examples
298    ///
299    /// ```
300    /// use osp_cli::app::AppSession;
301    ///
302    /// let session = AppSession::builder()
303    ///     .with_prompt_prefix("demo")
304    ///     .with_history_enabled(false)
305    ///     .build();
306    ///
307    /// assert_eq!(session.prompt_prefix, "demo");
308    /// assert!(!session.history_enabled);
309    /// ```
310    pub fn builder() -> AppSessionBuilder {
311        AppSessionBuilder::new()
312    }
313
314    /// Creates a session with bounded caches for row and command results.
315    ///
316    /// A requested cache limit of `0` is clamped to `1` so the session never
317    /// stores a zero-capacity cache by accident.
318    ///
319    /// # Examples
320    ///
321    /// ```
322    /// use osp_cli::app::AppSession;
323    ///
324    /// let session = AppSession::with_cache_limit(4).with_prompt_prefix("demo");
325    ///
326    /// assert_eq!(session.max_cached_results, 4);
327    /// assert_eq!(session.prompt_prefix, "demo");
328    /// ```
329    pub fn with_cache_limit(max_cached_results: usize) -> Self {
330        let bounded = max_cached_results.max(1);
331        Self {
332            prompt_prefix: "osp".to_string(),
333            history_enabled: true,
334            history_shell: HistoryShellContext::default(),
335            prompt_timing: DebugTimingState::default(),
336            startup_prompt_timing_pending: true,
337            scope: ReplScopeStack::default(),
338            last_rows: Vec::new(),
339            last_failure: None,
340            result_cache: HashMap::new(),
341            cache_order: VecDeque::new(),
342            command_cache: HashMap::new(),
343            command_cache_order: VecDeque::new(),
344            max_cached_results: bounded,
345            config_overrides: ConfigLayer::default(),
346        }
347    }
348
349    /// Creates the default session snapshot for the current resolved config.
350    pub(crate) fn from_resolved_config(config: &crate::config::ResolvedConfig) -> Self {
351        let session_cache_max_results = crate::app::host::config_usize(
352            config,
353            "session.cache.max_results",
354            DEFAULT_SESSION_CACHE_MAX_RESULTS as usize,
355        );
356        Self::with_cache_limit(session_cache_max_results)
357    }
358
359    /// Creates the default session snapshot for the current resolved config
360    /// and attaches the supplied session-layer overrides.
361    pub(crate) fn from_resolved_config_with_overrides(
362        config: &crate::config::ResolvedConfig,
363        config_overrides: ConfigLayer,
364    ) -> Self {
365        Self::with_cache_limit(crate::app::host::config_usize(
366            config,
367            "session.cache.max_results",
368            DEFAULT_SESSION_CACHE_MAX_RESULTS as usize,
369        ))
370        .with_config_overrides(config_overrides)
371    }
372
373    /// Replaces the prompt prefix shown ahead of any scope label.
374    pub fn with_prompt_prefix(mut self, prompt_prefix: impl Into<String>) -> Self {
375        self.prompt_prefix = prompt_prefix.into();
376        self
377    }
378
379    /// Enables or disables history capture for this session.
380    pub fn with_history_enabled(mut self, history_enabled: bool) -> Self {
381        self.history_enabled = history_enabled;
382        self
383    }
384
385    /// Replaces the shell-scoped history context shared with the history store.
386    pub fn with_history_shell(mut self, history_shell: HistoryShellContext) -> Self {
387        self.history_shell = history_shell;
388        self
389    }
390
391    /// Replaces the session-scoped config overrides layered above persisted config.
392    pub fn with_config_overrides(mut self, config_overrides: ConfigLayer) -> Self {
393        self.config_overrides = config_overrides;
394        self
395    }
396
397    /// Stores the latest successful row output and updates the result cache.
398    pub fn record_result(&mut self, command_line: &str, rows: Vec<Row>) {
399        let key = command_line.trim().to_string();
400        if key.is_empty() {
401            return;
402        }
403
404        self.last_rows = rows.clone();
405        if !self.result_cache.contains_key(&key)
406            && self.result_cache.len() >= self.max_cached_results
407            && let Some(evict_key) = self.cache_order.pop_front()
408        {
409            self.result_cache.remove(&evict_key);
410        }
411
412        self.cache_order.retain(|item| item != &key);
413        self.cache_order.push_back(key.clone());
414        self.result_cache.insert(key, rows);
415    }
416
417    /// Records details about the latest failed command.
418    pub fn record_failure(
419        &mut self,
420        command_line: &str,
421        summary: impl Into<String>,
422        detail: impl Into<String>,
423    ) {
424        let command_line = command_line.trim().to_string();
425        if command_line.is_empty() {
426            return;
427        }
428        self.last_failure = Some(LastFailure {
429            command_line,
430            summary: summary.into(),
431            detail: detail.into(),
432        });
433    }
434
435    /// Returns cached rows for a previously executed command line.
436    pub fn cached_rows(&self, command_line: &str) -> Option<&[Row]> {
437        self.result_cache
438            .get(command_line.trim())
439            .map(|rows| rows.as_slice())
440    }
441
442    pub(crate) fn record_cached_command(&mut self, cache_key: &str, result: &CliCommandResult) {
443        let cache_key = cache_key.trim().to_string();
444        if cache_key.is_empty() {
445            return;
446        }
447
448        if !self.command_cache.contains_key(&cache_key)
449            && self.command_cache.len() >= self.max_cached_results
450            && let Some(evict_key) = self.command_cache_order.pop_front()
451        {
452            self.command_cache.remove(&evict_key);
453        }
454
455        self.command_cache_order.retain(|item| item != &cache_key);
456        self.command_cache_order.push_back(cache_key.clone());
457        self.command_cache.insert(cache_key, result.clone());
458    }
459
460    pub(crate) fn cached_command(&self, cache_key: &str) -> Option<CliCommandResult> {
461        self.command_cache.get(cache_key.trim()).cloned()
462    }
463
464    /// Updates the prompt timing badge for the most recent command.
465    pub fn record_prompt_timing(
466        &self,
467        level: u8,
468        total: Duration,
469        parse: Option<Duration>,
470        execute: Option<Duration>,
471        render: Option<Duration>,
472    ) {
473        if level == 0 {
474            self.prompt_timing.clear();
475            return;
476        }
477
478        self.prompt_timing.set(DebugTimingBadge {
479            level,
480            summary: TimingSummary {
481                total,
482                parse,
483                execute,
484                render,
485            },
486        });
487    }
488
489    /// Seeds the initial prompt timing badge emitted during startup.
490    pub fn seed_startup_prompt_timing(&mut self, level: u8, total: Duration) {
491        if !self.startup_prompt_timing_pending {
492            return;
493        }
494        self.startup_prompt_timing_pending = false;
495        if level == 0 {
496            return;
497        }
498
499        self.prompt_timing.set(DebugTimingBadge {
500            level,
501            summary: TimingSummary {
502                total,
503                parse: None,
504                execute: None,
505                render: None,
506            },
507        });
508    }
509
510    /// Synchronizes history context with the current REPL scope.
511    pub fn sync_history_shell_context(&self) {
512        self.history_shell.set_prefix(self.scope.history_prefix());
513    }
514}
515
516impl Default for AppSession {
517    fn default() -> Self {
518        Self::with_cache_limit(DEFAULT_SESSION_CACHE_MAX_RESULTS as usize)
519    }
520}
521
522/// Builder for [`AppSession`].
523///
524/// Prefer this when callers want a neutral session-construction entrypoint and
525/// plan to configure prompt/history behavior before building the final value.
526#[must_use]
527pub struct AppSessionBuilder {
528    prompt_prefix: String,
529    history_enabled: bool,
530    history_shell: HistoryShellContext,
531    max_cached_results: usize,
532    config_overrides: ConfigLayer,
533}
534
535impl Default for AppSessionBuilder {
536    fn default() -> Self {
537        Self::new()
538    }
539}
540
541impl AppSessionBuilder {
542    /// Starts a session builder with the crate's default prompt and cache size.
543    pub fn new() -> Self {
544        Self {
545            prompt_prefix: "osp".to_string(),
546            history_enabled: true,
547            history_shell: HistoryShellContext::default(),
548            max_cached_results: DEFAULT_SESSION_CACHE_MAX_RESULTS as usize,
549            config_overrides: ConfigLayer::default(),
550        }
551    }
552
553    /// Replaces the prompt prefix shown ahead of any scope label.
554    pub fn with_prompt_prefix(mut self, prompt_prefix: impl Into<String>) -> Self {
555        self.prompt_prefix = prompt_prefix.into();
556        self
557    }
558
559    /// Enables or disables history capture for the built session.
560    pub fn with_history_enabled(mut self, history_enabled: bool) -> Self {
561        self.history_enabled = history_enabled;
562        self
563    }
564
565    /// Replaces the shell-scoped history context shared with the history store.
566    pub fn with_history_shell(mut self, history_shell: HistoryShellContext) -> Self {
567        self.history_shell = history_shell;
568        self
569    }
570
571    /// Replaces the maximum number of cached row/command results.
572    pub fn with_cache_limit(mut self, max_cached_results: usize) -> Self {
573        self.max_cached_results = max_cached_results;
574        self
575    }
576
577    /// Replaces the session-scoped config overrides layered above persisted config.
578    pub fn with_config_overrides(mut self, config_overrides: ConfigLayer) -> Self {
579        self.config_overrides = config_overrides;
580        self
581    }
582
583    /// Builds the configured [`AppSession`].
584    pub fn build(self) -> AppSession {
585        AppSession::with_cache_limit(self.max_cached_results)
586            .with_prompt_prefix(self.prompt_prefix)
587            .with_history_enabled(self.history_enabled)
588            .with_history_shell(self.history_shell)
589            .with_config_overrides(self.config_overrides)
590    }
591}
592
593pub(crate) struct AppStateInit {
594    pub context: RuntimeContext,
595    pub config: crate::config::ResolvedConfig,
596    pub render_settings: crate::ui::RenderSettings,
597    pub message_verbosity: crate::ui::messages::MessageLevel,
598    pub debug_verbosity: u8,
599    pub plugins: crate::plugin::PluginManager,
600    pub native_commands: NativeCommandRegistry,
601    pub themes: crate::ui::theme_loader::ThemeCatalog,
602    pub launch: LaunchContext,
603}
604
605pub(crate) struct AppStateParts {
606    pub runtime: AppRuntime,
607    pub session: AppSession,
608    pub clients: AppClients,
609}
610
611impl AppStateParts {
612    fn from_init(init: AppStateInit, session_override: Option<AppSession>) -> Self {
613        let clients = AppClients::new(init.plugins, init.native_commands);
614        let config = crate::app::ConfigState::new(init.config);
615        let ui = crate::app::UiState::new(
616            init.render_settings,
617            init.message_verbosity,
618            init.debug_verbosity,
619        );
620        let auth = crate::app::AuthState::from_resolved_with_external_policies(
621            config.resolved(),
622            clients.plugins(),
623            clients.native_commands(),
624        );
625        let runtime = AppRuntime::new(init.context, config, ui, auth, init.themes, init.launch);
626        let session = session_override
627            .unwrap_or_else(|| AppSession::from_resolved_config(runtime.config.resolved()));
628
629        Self {
630            runtime,
631            session,
632            clients,
633        }
634    }
635}
636
637/// Aggregate application state shared between runtime and session logic.
638#[non_exhaustive]
639#[must_use]
640pub struct AppState {
641    /// Runtime-scoped services and resolved config state.
642    pub runtime: AppRuntime,
643    /// Session-scoped REPL caches and prompt metadata.
644    pub session: AppSession,
645    /// Shared client registries used during command execution.
646    pub clients: AppClients,
647}
648
649impl AppState {
650    /// Builds a full application-state snapshot by deriving UI state from the
651    /// resolved config and runtime context.
652    ///
653    /// # Examples
654    ///
655    /// ```
656    /// use osp_cli::app::{AppState, RuntimeContext, TerminalKind};
657    /// use osp_cli::config::{ConfigLayer, ConfigResolver, ResolveOptions};
658    ///
659    /// let mut defaults = ConfigLayer::default();
660    /// defaults.set("profile.default", "default");
661    /// defaults.set("ui.message.verbosity", "warning");
662    ///
663    /// let mut resolver = ConfigResolver::default();
664    /// resolver.set_defaults(defaults);
665    /// let config = resolver.resolve(ResolveOptions::new().with_terminal("repl")).unwrap();
666    ///
667    /// let state = AppState::from_resolved_config(
668    ///     RuntimeContext::new(None, TerminalKind::Repl, None),
669    ///     config,
670    /// )
671    /// .unwrap();
672    ///
673    /// assert_eq!(state.runtime.config.resolved().active_profile(), "default");
674    /// assert_eq!(state.runtime.ui.message_verbosity.as_env_str(), "warning");
675    /// assert!(state.clients.plugins().explicit_dirs().is_empty());
676    /// ```
677    pub fn from_resolved_config(
678        context: RuntimeContext,
679        config: crate::config::ResolvedConfig,
680    ) -> miette::Result<Self> {
681        AppStateBuilder::from_resolved_config(context, config).map(AppStateBuilder::build)
682    }
683
684    #[cfg(test)]
685    pub(crate) fn new(init: AppStateInit) -> Self {
686        Self::from_parts(AppStateParts::from_init(init, None))
687    }
688
689    pub(crate) fn from_parts(parts: AppStateParts) -> Self {
690        Self {
691            runtime: parts.runtime,
692            session: parts.session,
693            clients: parts.clients,
694        }
695    }
696
697    pub(crate) fn replace_parts(&mut self, parts: AppStateParts) {
698        self.runtime = parts.runtime;
699        self.session = parts.session;
700        self.clients = parts.clients;
701    }
702
703    /// Returns the prompt prefix configured for the current session.
704    pub fn prompt_prefix(&self) -> String {
705        self.session.prompt_prefix.clone()
706    }
707
708    /// Synchronizes the history shell context with the current session scope.
709    pub fn sync_history_shell_context(&self) {
710        self.session.sync_history_shell_context();
711    }
712
713    /// Records rows produced by a REPL command.
714    pub fn record_repl_rows(&mut self, command_line: &str, rows: Vec<Row>) {
715        self.session.record_result(command_line, rows);
716    }
717
718    /// Records a failed REPL command and its associated messages.
719    pub fn record_repl_failure(
720        &mut self,
721        command_line: &str,
722        summary: impl Into<String>,
723        detail: impl Into<String>,
724    ) {
725        self.session.record_failure(command_line, summary, detail);
726    }
727
728    /// Returns the rows from the most recent successful REPL command.
729    pub fn last_repl_rows(&self) -> Vec<Row> {
730        self.session.last_rows.clone()
731    }
732
733    /// Returns details about the most recent failed REPL command.
734    pub fn last_repl_failure(&self) -> Option<LastFailure> {
735        self.session.last_failure.clone()
736    }
737
738    /// Returns cached rows for a previously executed REPL command.
739    pub fn cached_repl_rows(&self, command_line: &str) -> Option<Vec<Row>> {
740        self.session
741            .cached_rows(command_line)
742            .map(ToOwned::to_owned)
743    }
744
745    /// Returns the number of cached REPL result sets.
746    pub fn repl_cache_size(&self) -> usize {
747        self.session.result_cache.len()
748    }
749}
750
751/// Builder for [`AppState`].
752///
753/// This is the canonical manual-construction factory for runtime/session/client
754/// state when callers need a snapshot without going through full CLI bootstrap.
755///
756/// Use [`AppStateBuilder::from_resolved_config`] for the normal config-driven
757/// path, then override specific pieces such as the session or plugin manager as
758/// needed. Use [`AppStateBuilder::new`] only when the caller already has a
759/// fully chosen [`UiState`] and wants the builder to assemble the remaining
760/// runtime/session/client pieces around it.
761///
762/// # Examples
763///
764/// ```
765/// use osp_cli::app::{AppSession, AppStateBuilder, RuntimeContext, TerminalKind};
766/// use osp_cli::config::{ConfigLayer, ConfigResolver, ResolveOptions};
767///
768/// let mut defaults = ConfigLayer::default();
769/// defaults.set("profile.default", "default");
770///
771/// let mut resolver = ConfigResolver::default();
772/// resolver.set_defaults(defaults);
773/// let config = resolver.resolve(ResolveOptions::new().with_terminal("repl")).unwrap();
774///
775/// let state = AppStateBuilder::from_resolved_config(
776///     RuntimeContext::new(None, TerminalKind::Repl, None),
777///     config,
778/// )?
779/// .with_session(AppSession::with_cache_limit(32).with_prompt_prefix("demo"))
780/// .build();
781///
782/// assert_eq!(state.prompt_prefix(), "demo");
783/// # Ok::<(), miette::Report>(())
784/// ```
785#[must_use]
786pub struct AppStateBuilder {
787    context: RuntimeContext,
788    config: crate::config::ResolvedConfig,
789    ui: UiState,
790    launch: LaunchContext,
791    plugins: Option<PluginManager>,
792    native_commands: NativeCommandRegistry,
793    session: Option<AppSession>,
794    themes: Option<crate::ui::theme_loader::ThemeCatalog>,
795}
796
797impl AppStateBuilder {
798    /// Starts building an application-state snapshot from the resolved config
799    /// and UI state the caller wants to expose.
800    ///
801    /// This is the manual-construction path. Prefer
802    /// [`AppStateBuilder::from_resolved_config`] when the builder should derive
803    /// UI defaults from config and runtime context first.
804    pub fn new(
805        context: RuntimeContext,
806        config: crate::config::ResolvedConfig,
807        ui: UiState,
808    ) -> Self {
809        Self {
810            context,
811            config,
812            ui,
813            launch: LaunchContext::default(),
814            plugins: None,
815            native_commands: NativeCommandRegistry::default(),
816            session: None,
817            themes: None,
818        }
819    }
820
821    /// Starts a builder by deriving UI state from the resolved config and
822    /// runtime context.
823    ///
824    /// This is the canonical embedder entrypoint when you want one coherent
825    /// host snapshot and only plan to override selected pieces before build.
826    pub fn from_resolved_config(
827        context: RuntimeContext,
828        config: crate::config::ResolvedConfig,
829    ) -> miette::Result<Self> {
830        // This path is the canonical embedder factory: derive host inputs once
831        // and hand callers a coherent runtime/session/client snapshot.
832        let host_inputs = crate::app::assembly::ResolvedHostInputs::derive(
833            &context,
834            &config,
835            &LaunchContext::default(),
836            crate::app::assembly::RenderSettingsSeed::DefaultAuto,
837            None,
838            None,
839            None,
840        )?;
841        crate::ui::theme_loader::log_theme_issues(&host_inputs.themes.issues);
842        Ok(Self {
843            context,
844            config,
845            ui: host_inputs.ui,
846            launch: LaunchContext::default(),
847            plugins: None,
848            native_commands: NativeCommandRegistry::default(),
849            session: Some(host_inputs.default_session),
850            themes: Some(host_inputs.themes),
851        })
852    }
853
854    /// Replaces the launch-time provenance used for cache and plugin setup.
855    ///
856    /// If omitted, the builder keeps [`LaunchContext::default`].
857    pub fn with_launch(mut self, launch: LaunchContext) -> Self {
858        self.launch = launch;
859        self
860    }
861
862    /// Replaces the plugin manager used when assembling shared clients.
863    ///
864    /// If omitted, the builder derives the plugin manager from the resolved
865    /// config plus the current launch context during [`AppStateBuilder::build`].
866    pub fn with_plugins(mut self, plugins: PluginManager) -> Self {
867        self.plugins = Some(plugins);
868        self
869    }
870
871    /// Replaces the native command registry used when assembling shared
872    /// clients.
873    ///
874    /// If omitted, the builder keeps [`NativeCommandRegistry::default`].
875    pub fn with_native_commands(mut self, native_commands: NativeCommandRegistry) -> Self {
876        self.native_commands = native_commands;
877        self
878    }
879
880    /// Replaces the session snapshot carried by the built app state.
881    ///
882    /// If omitted, the builder uses the derived/default session for the
883    /// current config.
884    pub fn with_session(mut self, session: AppSession) -> Self {
885        self.session = Some(session);
886        self
887    }
888
889    /// Replaces the loaded theme catalog used during state assembly.
890    pub(crate) fn with_themes(mut self, themes: crate::ui::theme_loader::ThemeCatalog) -> Self {
891        self.themes = Some(themes);
892        self
893    }
894
895    /// Builds the configured [`AppState`].
896    ///
897    /// This assembles one coherent runtime/session/client snapshot, deriving any
898    /// omitted pieces such as themes, plugin manager, and default session before
899    /// returning the final value.
900    pub fn build(self) -> AppState {
901        let themes = self.themes.unwrap_or_else(|| {
902            let themes = crate::ui::theme_loader::load_theme_catalog(&self.config);
903            crate::ui::theme_loader::log_theme_issues(&themes.issues);
904            themes
905        });
906        let plugins = self
907            .plugins
908            .unwrap_or_else(|| default_plugin_manager(&self.config, &self.launch));
909
910        let crate::app::UiState {
911            render_settings,
912            message_verbosity,
913            debug_verbosity,
914            ..
915        } = self.ui;
916
917        AppState::from_parts(AppStateParts::from_init(
918            AppStateInit {
919                context: self.context,
920                config: self.config,
921                render_settings,
922                message_verbosity,
923                debug_verbosity,
924                plugins,
925                native_commands: self.native_commands,
926                themes,
927                launch: self.launch,
928            },
929            self.session,
930        ))
931    }
932}
933
934fn default_plugin_manager(
935    config: &crate::config::ResolvedConfig,
936    launch: &LaunchContext,
937) -> PluginManager {
938    crate::app::assembly::build_plugin_manager(config, launch, None)
939}