1use 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)]
43pub struct DebugTimingBadge {
45 pub level: u8,
47 pub(crate) summary: TimingSummary,
48}
49
50#[derive(Clone, Default, Debug)]
53pub struct DebugTimingState {
54 inner: Arc<RwLock<Option<DebugTimingBadge>>>,
55}
56
57impl DebugTimingState {
58 pub fn set(&self, badge: DebugTimingBadge) {
60 if let Ok(mut guard) = self.inner.write() {
61 *guard = Some(badge);
62 }
63 }
64
65 pub fn clear(&self) {
67 if let Ok(mut guard) = self.inner.write() {
68 *guard = None;
69 }
70 }
71
72 pub fn badge(&self) -> Option<DebugTimingBadge> {
74 self.inner.read().map(|value| *value).unwrap_or(None)
75 }
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct ReplScopeFrame {
81 command: String,
82}
83
84impl ReplScopeFrame {
85 pub fn new(command: impl Into<String>) -> Self {
96 Self {
97 command: command.into(),
98 }
99 }
100
101 pub fn command(&self) -> &str {
103 self.command.as_str()
104 }
105}
106
107#[derive(Debug, Clone, Default, PartialEq, Eq)]
112pub struct ReplScopeStack {
113 frames: Vec<ReplScopeFrame>,
114}
115
116impl ReplScopeStack {
117 pub fn is_root(&self) -> bool {
119 self.frames.is_empty()
120 }
121
122 pub fn enter(&mut self, command: impl Into<String>) {
124 self.frames.push(ReplScopeFrame::new(command));
125 }
126
127 pub fn leave(&mut self) -> Option<ReplScopeFrame> {
129 self.frames.pop()
130 }
131
132 pub fn commands(&self) -> Vec<String> {
134 self.frames
135 .iter()
136 .map(|frame| frame.command.clone())
137 .collect()
138 }
139
140 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 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 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 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 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 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 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#[non_exhaustive]
293#[must_use]
294pub struct AppSession {
295 pub prompt_prefix: String,
297 pub history_enabled: bool,
299 pub history_shell: HistoryShellContext,
301 pub prompt_timing: DebugTimingState,
303 pub(crate) startup_prompt_timing_pending: bool,
304 pub scope: ReplScopeStack,
306 pub last_rows: Vec<Row>,
308 pub last_failure: Option<LastFailure>,
310 pub result_cache: HashMap<String, Vec<Row>>,
312 pub cache_order: VecDeque<String>,
314 pub(crate) command_cache: HashMap<String, CliCommandResult>,
315 pub(crate) command_cache_order: VecDeque<String>,
316 pub max_cached_results: usize,
318 pub config_overrides: ConfigLayer,
320}
321
322#[derive(Debug, Clone, PartialEq, Eq)]
323pub struct LastFailure {
325 pub command_line: String,
327 pub summary: String,
329 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 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 pub fn builder() -> AppSessionBuilder {
408 AppSessionBuilder::new()
409 }
410
411 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 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 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 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 pub fn with_history_enabled(mut self, history_enabled: bool) -> Self {
478 self.history_enabled = history_enabled;
479 self
480 }
481
482 pub fn with_history_shell(mut self, history_shell: HistoryShellContext) -> Self {
484 self.history_shell = history_shell;
485 self
486 }
487
488 pub fn with_config_overrides(mut self, config_overrides: ConfigLayer) -> Self {
490 self.config_overrides = config_overrides;
491 self
492 }
493
494 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 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 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 pub(crate) fn finish_repl_line(&self) {
525 self.sync_history_shell_context();
526 }
527
528 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 pub(crate) fn restore_rebuild_state(&mut self, state: AppSessionRebuildState) {
548 state.restore_into(self);
549 }
550
551 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 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 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 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 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 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#[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 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 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 pub fn with_history_enabled(mut self, history_enabled: bool) -> Self {
715 self.history_enabled = history_enabled;
716 self
717 }
718
719 pub fn with_history_shell(mut self, history_shell: HistoryShellContext) -> Self {
721 self.history_shell = history_shell;
722 self
723 }
724
725 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 pub fn with_config_overrides(mut self, config_overrides: ConfigLayer) -> Self {
733 self.config_overrides = config_overrides;
734 self
735 }
736
737 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#[non_exhaustive]
793#[must_use]
794pub struct AppState {
795 pub runtime: AppRuntime,
797 pub session: AppSession,
799 pub clients: AppClients,
801}
802
803impl AppState {
804 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 pub fn prompt_prefix(&self) -> String {
859 self.session.prompt_prefix.clone()
860 }
861
862 pub fn sync_history_shell_context(&self) {
864 self.session.sync_history_shell_context();
865 }
866
867 pub fn record_repl_rows(&mut self, command_line: &str, rows: Vec<Row>) {
869 self.session.record_result(command_line, rows);
870 }
871
872 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 pub fn last_repl_rows(&self) -> Vec<Row> {
884 self.session.last_rows.clone()
885 }
886
887 pub fn last_repl_failure(&self) -> Option<LastFailure> {
889 self.session.last_failure.clone()
890 }
891
892 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 pub fn repl_cache_size(&self) -> usize {
901 self.session.result_cache.len()
902 }
903}
904
905#[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 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 pub fn from_resolved_config(
998 context: RuntimeContext,
999 config: crate::config::ResolvedConfig,
1000 ) -> miette::Result<Self> {
1001 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 pub fn with_launch(mut self, launch: LaunchContext) -> Self {
1032 self.launch = launch;
1033 self
1034 }
1035
1036 pub fn with_plugins(mut self, plugins: PluginManager) -> Self {
1041 self.plugins = Some(plugins);
1042 self
1043 }
1044
1045 pub fn with_native_commands(mut self, native_commands: NativeCommandRegistry) -> Self {
1050 self.native_commands = native_commands;
1051 self
1052 }
1053
1054 pub fn with_session(mut self, session: AppSession) -> Self {
1059 self.session = Some(session);
1060 self
1061 }
1062
1063 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}