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 [`AppSessionBuilder`] for session-scoped REPL state
23//! - use [`AppStateBuilder`] when you need a fully assembled runtime/session
24//!   snapshot outside the full CLI bootstrap
25//! - these types are host machinery, not lightweight semantic DTOs
26
27use std::collections::{HashMap, VecDeque};
28use std::sync::{Arc, RwLock};
29use std::time::Duration;
30
31use crate::config::{ConfigLayer, DEFAULT_SESSION_CACHE_MAX_RESULTS};
32use crate::core::row::Row;
33use crate::native::NativeCommandRegistry;
34use crate::plugin::PluginManager;
35use crate::repl::HistoryShellContext;
36
37use super::command_output::CliCommandResult;
38use super::runtime::{AppClients, AppRuntime, LaunchContext, RuntimeContext, UiState};
39use super::timing::TimingSummary;
40
41#[derive(Debug, Clone, Copy, Default)]
42/// Timing badge rendered in the prompt for the most recent command.
43pub struct DebugTimingBadge {
44    /// Prompt detail level used when rendering the badge.
45    pub level: u8,
46    pub(crate) summary: TimingSummary,
47}
48
49/// Shared prompt-timing storage that dispatch code can update and prompt
50/// rendering can read.
51#[derive(Clone, Default, Debug)]
52pub struct DebugTimingState {
53    inner: Arc<RwLock<Option<DebugTimingBadge>>>,
54}
55
56impl DebugTimingState {
57    /// Stores the current timing badge.
58    pub fn set(&self, badge: DebugTimingBadge) {
59        if let Ok(mut guard) = self.inner.write() {
60            *guard = Some(badge);
61        }
62    }
63
64    /// Clears any stored timing badge.
65    pub fn clear(&self) {
66        if let Ok(mut guard) = self.inner.write() {
67            *guard = None;
68        }
69    }
70
71    /// Returns the current timing badge, if one is available.
72    pub fn badge(&self) -> Option<DebugTimingBadge> {
73        self.inner.read().map(|value| *value).unwrap_or(None)
74    }
75}
76
77/// One entered command scope inside the interactive REPL shell stack.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct ReplScopeFrame {
80    command: String,
81}
82
83impl ReplScopeFrame {
84    /// Creates a frame for the given command name.
85    ///
86    /// # Examples
87    ///
88    /// ```
89    /// use osp_cli::app::ReplScopeFrame;
90    ///
91    /// let frame = ReplScopeFrame::new("theme");
92    /// assert_eq!(frame.command(), "theme");
93    /// ```
94    pub fn new(command: impl Into<String>) -> Self {
95        Self {
96            command: command.into(),
97        }
98    }
99
100    /// Returns the command name associated with this scope frame.
101    pub fn command(&self) -> &str {
102        self.command.as_str()
103    }
104}
105
106/// Nested REPL command-scope stack used for shell-style scoped interaction.
107///
108/// This is what lets the REPL stay "inside" a command family while still
109/// rendering scope labels, help targets, and history prefixes consistently.
110#[derive(Debug, Clone, Default, PartialEq, Eq)]
111pub struct ReplScopeStack {
112    frames: Vec<ReplScopeFrame>,
113}
114
115impl ReplScopeStack {
116    /// Returns `true` when the REPL is at the top-level scope.
117    pub fn is_root(&self) -> bool {
118        self.frames.is_empty()
119    }
120
121    /// Pushes a new command scope onto the stack.
122    pub fn enter(&mut self, command: impl Into<String>) {
123        self.frames.push(ReplScopeFrame::new(command));
124    }
125
126    /// Pops the current command scope from the stack.
127    pub fn leave(&mut self) -> Option<ReplScopeFrame> {
128        self.frames.pop()
129    }
130
131    /// Returns the command path represented by the current stack.
132    pub fn commands(&self) -> Vec<String> {
133        self.frames
134            .iter()
135            .map(|frame| frame.command.clone())
136            .collect()
137    }
138
139    /// Returns whether the stack already contains the given command.
140    pub fn contains_command(&self, command: &str) -> bool {
141        self.frames
142            .iter()
143            .any(|frame| frame.command.eq_ignore_ascii_case(command))
144    }
145
146    /// Returns a human-readable label for the current scope path.
147    ///
148    /// # Examples
149    ///
150    /// ```
151    /// use osp_cli::app::ReplScopeStack;
152    ///
153    /// let mut scope = ReplScopeStack::default();
154    /// assert_eq!(scope.display_label(), None);
155    ///
156    /// scope.enter("theme");
157    /// scope.enter("show");
158    /// assert_eq!(scope.display_label(), Some("theme / show".to_string()));
159    /// ```
160    pub fn display_label(&self) -> Option<String> {
161        if self.is_root() {
162            None
163        } else {
164            Some(
165                self.frames
166                    .iter()
167                    .map(|frame| frame.command.as_str())
168                    .collect::<Vec<_>>()
169                    .join(" / "),
170            )
171        }
172    }
173
174    /// Returns the history prefix used for shell-backed history entries.
175    ///
176    /// # Examples
177    ///
178    /// ```
179    /// use osp_cli::app::ReplScopeStack;
180    ///
181    /// let mut scope = ReplScopeStack::default();
182    /// scope.enter("theme");
183    /// scope.enter("show");
184    ///
185    /// assert_eq!(scope.history_prefix(), "theme show ");
186    /// ```
187    pub fn history_prefix(&self) -> String {
188        if self.is_root() {
189            String::new()
190        } else {
191            format!(
192                "{} ",
193                self.frames
194                    .iter()
195                    .map(|frame| frame.command.as_str())
196                    .collect::<Vec<_>>()
197                    .join(" ")
198            )
199        }
200    }
201
202    /// Prepends the active scope path unless the tokens are already scoped.
203    ///
204    /// # Examples
205    ///
206    /// ```
207    /// use osp_cli::app::ReplScopeStack;
208    ///
209    /// let mut scope = ReplScopeStack::default();
210    /// scope.enter("theme");
211    ///
212    /// assert_eq!(
213    ///     scope.prefixed_tokens(&["show".to_string(), "dracula".to_string()]),
214    ///     vec!["theme".to_string(), "show".to_string(), "dracula".to_string()]
215    /// );
216    /// ```
217    pub fn prefixed_tokens(&self, tokens: &[String]) -> Vec<String> {
218        let prefix = self.commands();
219        if prefix.is_empty() || tokens.starts_with(&prefix) {
220            return tokens.to_vec();
221        }
222        let mut full = prefix;
223        full.extend_from_slice(tokens);
224        full
225    }
226
227    /// Returns help tokens for the current scope.
228    ///
229    /// # Examples
230    ///
231    /// ```
232    /// use osp_cli::app::ReplScopeStack;
233    ///
234    /// let mut scope = ReplScopeStack::default();
235    /// scope.enter("theme");
236    ///
237    /// assert_eq!(scope.help_tokens(), vec!["theme".to_string(), "--help".to_string()]);
238    /// ```
239    pub fn help_tokens(&self) -> Vec<String> {
240        let mut tokens = self.commands();
241        if !tokens.is_empty() {
242            tokens.push("--help".to_string());
243        }
244        tokens
245    }
246}
247
248/// Session-scoped REPL state, caches, and prompt metadata.
249#[non_exhaustive]
250pub struct AppSession {
251    /// Prompt prefix shown before any scope label.
252    pub prompt_prefix: String,
253    /// Whether history capture is enabled for this session.
254    pub history_enabled: bool,
255    /// Shell-scoped history prefix state shared with the history store.
256    pub history_shell: HistoryShellContext,
257    /// Shared prompt timing badge state.
258    pub prompt_timing: DebugTimingState,
259    pub(crate) startup_prompt_timing_pending: bool,
260    /// Current nested command scope within the REPL.
261    pub scope: ReplScopeStack,
262    /// Rows returned by the most recent successful REPL command.
263    pub last_rows: Vec<Row>,
264    /// Summary of the most recent failed REPL command.
265    pub last_failure: Option<LastFailure>,
266    /// Cached row outputs keyed by command line.
267    pub result_cache: HashMap<String, Vec<Row>>,
268    /// Eviction order for the row-result cache.
269    pub cache_order: VecDeque<String>,
270    pub(crate) command_cache: HashMap<String, CliCommandResult>,
271    pub(crate) command_cache_order: VecDeque<String>,
272    /// Maximum number of cached result sets to retain.
273    pub max_cached_results: usize,
274    /// Session-scoped config overrides layered above persisted config.
275    pub config_overrides: ConfigLayer,
276}
277
278#[derive(Debug, Clone, PartialEq, Eq)]
279/// Summary of the last failed REPL command.
280pub struct LastFailure {
281    /// Command line that produced the failure.
282    pub command_line: String,
283    /// Short failure summary suitable for prompts or status output.
284    pub summary: String,
285    /// Longer failure detail for follow-up inspection.
286    pub detail: String,
287}
288
289impl AppSession {
290    /// Starts a builder for session-scoped host state.
291    pub fn builder() -> AppSessionBuilder {
292        AppSessionBuilder::new()
293    }
294
295    /// Creates a session with bounded caches for row and command results.
296    pub fn with_cache_limit(max_cached_results: usize) -> Self {
297        let bounded = max_cached_results.max(1);
298        Self {
299            prompt_prefix: "osp".to_string(),
300            history_enabled: true,
301            history_shell: HistoryShellContext::default(),
302            prompt_timing: DebugTimingState::default(),
303            startup_prompt_timing_pending: true,
304            scope: ReplScopeStack::default(),
305            last_rows: Vec::new(),
306            last_failure: None,
307            result_cache: HashMap::new(),
308            cache_order: VecDeque::new(),
309            command_cache: HashMap::new(),
310            command_cache_order: VecDeque::new(),
311            max_cached_results: bounded,
312            config_overrides: ConfigLayer::default(),
313        }
314    }
315
316    /// Creates the default session snapshot for the current resolved config.
317    pub(crate) fn from_resolved_config(config: &crate::config::ResolvedConfig) -> Self {
318        let session_cache_max_results = crate::app::host::config_usize(
319            config,
320            "session.cache.max_results",
321            DEFAULT_SESSION_CACHE_MAX_RESULTS as usize,
322        );
323        Self::builder()
324            .with_cache_limit(session_cache_max_results)
325            .build()
326    }
327
328    /// Creates the default session snapshot for the current resolved config
329    /// and attaches the supplied session-layer overrides.
330    pub(crate) fn from_resolved_config_with_overrides(
331        config: &crate::config::ResolvedConfig,
332        config_overrides: ConfigLayer,
333    ) -> Self {
334        Self::builder()
335            .with_cache_limit(crate::app::host::config_usize(
336                config,
337                "session.cache.max_results",
338                DEFAULT_SESSION_CACHE_MAX_RESULTS as usize,
339            ))
340            .with_config_overrides(config_overrides)
341            .build()
342    }
343
344    /// Stores the latest successful row output and updates the result cache.
345    pub fn record_result(&mut self, command_line: &str, rows: Vec<Row>) {
346        let key = command_line.trim().to_string();
347        if key.is_empty() {
348            return;
349        }
350
351        self.last_rows = rows.clone();
352        if !self.result_cache.contains_key(&key)
353            && self.result_cache.len() >= self.max_cached_results
354            && let Some(evict_key) = self.cache_order.pop_front()
355        {
356            self.result_cache.remove(&evict_key);
357        }
358
359        self.cache_order.retain(|item| item != &key);
360        self.cache_order.push_back(key.clone());
361        self.result_cache.insert(key, rows);
362    }
363
364    /// Records details about the latest failed command.
365    pub fn record_failure(
366        &mut self,
367        command_line: &str,
368        summary: impl Into<String>,
369        detail: impl Into<String>,
370    ) {
371        let command_line = command_line.trim().to_string();
372        if command_line.is_empty() {
373            return;
374        }
375        self.last_failure = Some(LastFailure {
376            command_line,
377            summary: summary.into(),
378            detail: detail.into(),
379        });
380    }
381
382    /// Returns cached rows for a previously executed command line.
383    pub fn cached_rows(&self, command_line: &str) -> Option<&[Row]> {
384        self.result_cache
385            .get(command_line.trim())
386            .map(|rows| rows.as_slice())
387    }
388
389    pub(crate) fn record_cached_command(&mut self, cache_key: &str, result: &CliCommandResult) {
390        let cache_key = cache_key.trim().to_string();
391        if cache_key.is_empty() {
392            return;
393        }
394
395        if !self.command_cache.contains_key(&cache_key)
396            && self.command_cache.len() >= self.max_cached_results
397            && let Some(evict_key) = self.command_cache_order.pop_front()
398        {
399            self.command_cache.remove(&evict_key);
400        }
401
402        self.command_cache_order.retain(|item| item != &cache_key);
403        self.command_cache_order.push_back(cache_key.clone());
404        self.command_cache.insert(cache_key, result.clone());
405    }
406
407    pub(crate) fn cached_command(&self, cache_key: &str) -> Option<CliCommandResult> {
408        self.command_cache.get(cache_key.trim()).cloned()
409    }
410
411    /// Updates the prompt timing badge for the most recent command.
412    pub fn record_prompt_timing(
413        &self,
414        level: u8,
415        total: Duration,
416        parse: Option<Duration>,
417        execute: Option<Duration>,
418        render: Option<Duration>,
419    ) {
420        if level == 0 {
421            self.prompt_timing.clear();
422            return;
423        }
424
425        self.prompt_timing.set(DebugTimingBadge {
426            level,
427            summary: TimingSummary {
428                total,
429                parse,
430                execute,
431                render,
432            },
433        });
434    }
435
436    /// Seeds the initial prompt timing badge emitted during startup.
437    pub fn seed_startup_prompt_timing(&mut self, level: u8, total: Duration) {
438        if !self.startup_prompt_timing_pending {
439            return;
440        }
441        self.startup_prompt_timing_pending = false;
442        if level == 0 {
443            return;
444        }
445
446        self.prompt_timing.set(DebugTimingBadge {
447            level,
448            summary: TimingSummary {
449                total,
450                parse: None,
451                execute: None,
452                render: None,
453            },
454        });
455    }
456
457    /// Synchronizes history context with the current REPL scope.
458    pub fn sync_history_shell_context(&self) {
459        self.history_shell.set_prefix(self.scope.history_prefix());
460    }
461}
462
463/// Builder for [`AppSession`].
464///
465/// This is the guided construction path for session-scoped REPL state.
466pub struct AppSessionBuilder {
467    prompt_prefix: String,
468    history_enabled: bool,
469    history_shell: HistoryShellContext,
470    max_cached_results: usize,
471    config_overrides: ConfigLayer,
472}
473
474impl Default for AppSessionBuilder {
475    fn default() -> Self {
476        Self::new()
477    }
478}
479
480impl AppSessionBuilder {
481    /// Starts a session builder with the crate's default prompt and cache size.
482    pub fn new() -> Self {
483        Self {
484            prompt_prefix: "osp".to_string(),
485            history_enabled: true,
486            history_shell: HistoryShellContext::default(),
487            max_cached_results: DEFAULT_SESSION_CACHE_MAX_RESULTS as usize,
488            config_overrides: ConfigLayer::default(),
489        }
490    }
491
492    /// Replaces the prompt prefix shown ahead of any scope label.
493    pub fn with_prompt_prefix(mut self, prompt_prefix: impl Into<String>) -> Self {
494        self.prompt_prefix = prompt_prefix.into();
495        self
496    }
497
498    /// Enables or disables history capture for the built session.
499    pub fn with_history_enabled(mut self, history_enabled: bool) -> Self {
500        self.history_enabled = history_enabled;
501        self
502    }
503
504    /// Replaces the shell-scoped history context shared with the history store.
505    pub fn with_history_shell(mut self, history_shell: HistoryShellContext) -> Self {
506        self.history_shell = history_shell;
507        self
508    }
509
510    /// Replaces the maximum number of cached row/command results.
511    pub fn with_cache_limit(mut self, max_cached_results: usize) -> Self {
512        self.max_cached_results = max_cached_results;
513        self
514    }
515
516    /// Replaces the session-scoped config overrides layered above persisted config.
517    pub fn with_config_overrides(mut self, config_overrides: ConfigLayer) -> Self {
518        self.config_overrides = config_overrides;
519        self
520    }
521
522    /// Builds the configured [`AppSession`].
523    pub fn build(self) -> AppSession {
524        let mut session = AppSession::with_cache_limit(self.max_cached_results);
525        session.prompt_prefix = self.prompt_prefix;
526        session.history_enabled = self.history_enabled;
527        session.history_shell = self.history_shell;
528        session.config_overrides = self.config_overrides;
529        session
530    }
531}
532
533pub(crate) struct AppStateInit {
534    pub context: RuntimeContext,
535    pub config: crate::config::ResolvedConfig,
536    pub render_settings: crate::ui::RenderSettings,
537    pub message_verbosity: crate::ui::messages::MessageLevel,
538    pub debug_verbosity: u8,
539    pub plugins: crate::plugin::PluginManager,
540    pub native_commands: NativeCommandRegistry,
541    pub themes: crate::ui::theme_loader::ThemeCatalog,
542    pub launch: LaunchContext,
543}
544
545pub(crate) struct AppStateParts {
546    pub runtime: AppRuntime,
547    pub session: AppSession,
548    pub clients: AppClients,
549}
550
551impl AppStateParts {
552    fn from_init(init: AppStateInit, session_override: Option<AppSession>) -> Self {
553        let clients = AppClients::new(init.plugins, init.native_commands);
554        let config = crate::app::ConfigState::new(init.config);
555        let ui = crate::app::UiState::builder(init.render_settings)
556            .with_message_verbosity(init.message_verbosity)
557            .with_debug_verbosity(init.debug_verbosity)
558            .build();
559        let auth = crate::app::AuthState::from_resolved_with_external_policies(
560            config.resolved(),
561            clients.plugins(),
562            clients.native_commands(),
563        );
564        let runtime = AppRuntime::new(init.context, config, ui, auth, init.themes, init.launch);
565        let session = session_override
566            .unwrap_or_else(|| AppSession::from_resolved_config(runtime.config.resolved()));
567
568        Self {
569            runtime,
570            session,
571            clients,
572        }
573    }
574}
575
576/// Aggregate application state shared between runtime and session logic.
577#[non_exhaustive]
578pub struct AppState {
579    /// Runtime-scoped services and resolved config state.
580    pub runtime: AppRuntime,
581    /// Session-scoped REPL caches and prompt metadata.
582    pub session: AppSession,
583    /// Shared client registries used during command execution.
584    pub clients: AppClients,
585}
586
587impl AppState {
588    /// Starts a builder for a fully assembled application state snapshot.
589    pub fn builder(
590        context: RuntimeContext,
591        config: crate::config::ResolvedConfig,
592        ui: UiState,
593    ) -> AppStateBuilder {
594        AppStateBuilder::new(context, config, ui)
595    }
596
597    /// Builds a full application-state snapshot by deriving UI state from the
598    /// resolved config and runtime context.
599    ///
600    /// # Examples
601    ///
602    /// ```
603    /// use osp_cli::app::{AppState, RuntimeContext, TerminalKind};
604    /// use osp_cli::config::{ConfigLayer, ConfigResolver, ResolveOptions};
605    ///
606    /// let mut defaults = ConfigLayer::default();
607    /// defaults.set("profile.default", "default");
608    /// defaults.set("ui.message.verbosity", "warning");
609    ///
610    /// let mut resolver = ConfigResolver::default();
611    /// resolver.set_defaults(defaults);
612    /// let config = resolver.resolve(ResolveOptions::new().with_terminal("repl")).unwrap();
613    ///
614    /// let state = AppState::from_resolved_config(
615    ///     RuntimeContext::new(None, TerminalKind::Repl, None),
616    ///     config,
617    /// )
618    /// .unwrap();
619    ///
620    /// assert_eq!(state.runtime.config.resolved().active_profile(), "default");
621    /// assert_eq!(state.runtime.ui.message_verbosity.as_env_str(), "warning");
622    /// assert!(state.clients.plugins().explicit_dirs().is_empty());
623    /// ```
624    pub fn from_resolved_config(
625        context: RuntimeContext,
626        config: crate::config::ResolvedConfig,
627    ) -> miette::Result<Self> {
628        AppStateBuilder::from_resolved_config(context, config).map(AppStateBuilder::build)
629    }
630
631    #[cfg(test)]
632    pub(crate) fn new(init: AppStateInit) -> Self {
633        Self::from_parts(AppStateParts::from_init(init, None))
634    }
635
636    pub(crate) fn from_parts(parts: AppStateParts) -> Self {
637        Self {
638            runtime: parts.runtime,
639            session: parts.session,
640            clients: parts.clients,
641        }
642    }
643
644    pub(crate) fn replace_parts(&mut self, parts: AppStateParts) {
645        self.runtime = parts.runtime;
646        self.session = parts.session;
647        self.clients = parts.clients;
648    }
649
650    /// Returns the prompt prefix configured for the current session.
651    pub fn prompt_prefix(&self) -> String {
652        self.session.prompt_prefix.clone()
653    }
654
655    /// Synchronizes the history shell context with the current session scope.
656    pub fn sync_history_shell_context(&self) {
657        self.session.sync_history_shell_context();
658    }
659
660    /// Records rows produced by a REPL command.
661    pub fn record_repl_rows(&mut self, command_line: &str, rows: Vec<Row>) {
662        self.session.record_result(command_line, rows);
663    }
664
665    /// Records a failed REPL command and its associated messages.
666    pub fn record_repl_failure(
667        &mut self,
668        command_line: &str,
669        summary: impl Into<String>,
670        detail: impl Into<String>,
671    ) {
672        self.session.record_failure(command_line, summary, detail);
673    }
674
675    /// Returns the rows from the most recent successful REPL command.
676    pub fn last_repl_rows(&self) -> Vec<Row> {
677        self.session.last_rows.clone()
678    }
679
680    /// Returns details about the most recent failed REPL command.
681    pub fn last_repl_failure(&self) -> Option<LastFailure> {
682        self.session.last_failure.clone()
683    }
684
685    /// Returns cached rows for a previously executed REPL command.
686    pub fn cached_repl_rows(&self, command_line: &str) -> Option<Vec<Row>> {
687        self.session
688            .cached_rows(command_line)
689            .map(ToOwned::to_owned)
690    }
691
692    /// Returns the number of cached REPL result sets.
693    pub fn repl_cache_size(&self) -> usize {
694        self.session.result_cache.len()
695    }
696}
697
698/// Builder for [`AppState`].
699///
700/// This is the canonical embedder-facing factory for runtime/session/client
701/// state when callers need a snapshot without going through full CLI bootstrap.
702pub struct AppStateBuilder {
703    context: RuntimeContext,
704    config: crate::config::ResolvedConfig,
705    ui: UiState,
706    launch: LaunchContext,
707    plugins: Option<PluginManager>,
708    native_commands: NativeCommandRegistry,
709    session: Option<AppSession>,
710    themes: Option<crate::ui::theme_loader::ThemeCatalog>,
711}
712
713impl AppStateBuilder {
714    /// Starts building an application-state snapshot from the resolved config
715    /// and UI state the caller wants to expose.
716    pub fn new(
717        context: RuntimeContext,
718        config: crate::config::ResolvedConfig,
719        ui: UiState,
720    ) -> Self {
721        Self {
722            context,
723            config,
724            ui,
725            launch: LaunchContext::default(),
726            plugins: None,
727            native_commands: NativeCommandRegistry::default(),
728            session: None,
729            themes: None,
730        }
731    }
732
733    /// Starts a builder by deriving UI state from the resolved config and
734    /// runtime context.
735    pub fn from_resolved_config(
736        context: RuntimeContext,
737        config: crate::config::ResolvedConfig,
738    ) -> miette::Result<Self> {
739        // This path is the canonical embedder factory: derive host inputs once
740        // and hand callers a coherent runtime/session/client snapshot.
741        let host_inputs = crate::app::assembly::ResolvedHostInputs::derive(
742            &context,
743            &config,
744            &LaunchContext::default(),
745            crate::app::assembly::RenderSettingsSeed::DefaultAuto,
746            None,
747            None,
748            None,
749        )?;
750        crate::ui::theme_loader::log_theme_issues(&host_inputs.themes.issues);
751        Ok(Self {
752            context,
753            config,
754            ui: host_inputs.ui,
755            launch: LaunchContext::default(),
756            plugins: None,
757            native_commands: NativeCommandRegistry::default(),
758            session: Some(host_inputs.default_session),
759            themes: Some(host_inputs.themes),
760        })
761    }
762
763    /// Replaces the launch-time provenance used for cache and plugin setup.
764    pub fn with_launch(mut self, launch: LaunchContext) -> Self {
765        self.launch = launch;
766        self
767    }
768
769    /// Replaces the plugin manager used when assembling shared clients.
770    pub fn with_plugins(mut self, plugins: PluginManager) -> Self {
771        self.plugins = Some(plugins);
772        self
773    }
774
775    /// Replaces the native command registry used when assembling shared clients.
776    pub fn with_native_commands(mut self, native_commands: NativeCommandRegistry) -> Self {
777        self.native_commands = native_commands;
778        self
779    }
780
781    /// Replaces the session snapshot carried by the built app state.
782    pub fn with_session(mut self, session: AppSession) -> Self {
783        self.session = Some(session);
784        self
785    }
786
787    /// Replaces the loaded theme catalog used during state assembly.
788    pub(crate) fn with_themes(mut self, themes: crate::ui::theme_loader::ThemeCatalog) -> Self {
789        self.themes = Some(themes);
790        self
791    }
792
793    /// Builds the configured [`AppState`].
794    pub fn build(self) -> AppState {
795        let themes = self.themes.unwrap_or_else(|| {
796            let themes = crate::ui::theme_loader::load_theme_catalog(&self.config);
797            crate::ui::theme_loader::log_theme_issues(&themes.issues);
798            themes
799        });
800        let plugins = self
801            .plugins
802            .unwrap_or_else(|| default_plugin_manager(&self.config, &self.launch));
803
804        let crate::app::UiState {
805            render_settings,
806            message_verbosity,
807            debug_verbosity,
808            ..
809        } = self.ui;
810
811        AppState::from_parts(AppStateParts::from_init(
812            AppStateInit {
813                context: self.context,
814                config: self.config,
815                render_settings,
816                message_verbosity,
817                debug_verbosity,
818                plugins,
819                native_commands: self.native_commands,
820                themes,
821                launch: self.launch,
822            },
823            self.session,
824        ))
825    }
826}
827
828fn default_plugin_manager(
829    config: &crate::config::ResolvedConfig,
830    launch: &LaunchContext,
831) -> PluginManager {
832    crate::app::assembly::build_plugin_manager(config, launch, None)
833}