Skip to main content

osp_cli/app/
session.rs

1//! Session-scoped host state.
2
3use std::collections::{HashMap, VecDeque};
4use std::sync::{Arc, RwLock};
5use std::time::Duration;
6
7use crate::config::{ConfigLayer, DEFAULT_SESSION_CACHE_MAX_RESULTS};
8use crate::core::command_policy::CommandPolicyRegistry;
9use crate::core::row::Row;
10use crate::native::NativeCommandRegistry;
11use crate::repl::HistoryShellContext;
12
13use super::command_output::CliCommandResult;
14use super::runtime::{
15    AppClients, AppRuntime, AuthState, ConfigState, LaunchContext, RuntimeContext, UiState,
16};
17use super::timing::TimingSummary;
18
19#[derive(Debug, Clone, Copy, Default)]
20pub struct DebugTimingBadge {
21    pub level: u8,
22    pub(crate) summary: TimingSummary,
23}
24
25#[derive(Clone, Default, Debug)]
26pub struct DebugTimingState {
27    inner: Arc<RwLock<Option<DebugTimingBadge>>>,
28}
29
30impl DebugTimingState {
31    pub fn set(&self, badge: DebugTimingBadge) {
32        if let Ok(mut guard) = self.inner.write() {
33            *guard = Some(badge);
34        }
35    }
36
37    pub fn clear(&self) {
38        if let Ok(mut guard) = self.inner.write() {
39            *guard = None;
40        }
41    }
42
43    pub fn badge(&self) -> Option<DebugTimingBadge> {
44        self.inner.read().map(|value| *value).unwrap_or(None)
45    }
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct ReplScopeFrame {
50    command: String,
51}
52
53impl ReplScopeFrame {
54    pub fn new(command: impl Into<String>) -> Self {
55        Self {
56            command: command.into(),
57        }
58    }
59
60    pub fn command(&self) -> &str {
61        self.command.as_str()
62    }
63}
64
65#[derive(Debug, Clone, Default, PartialEq, Eq)]
66pub struct ReplScopeStack {
67    frames: Vec<ReplScopeFrame>,
68}
69
70impl ReplScopeStack {
71    pub fn is_root(&self) -> bool {
72        self.frames.is_empty()
73    }
74
75    pub fn enter(&mut self, command: impl Into<String>) {
76        self.frames.push(ReplScopeFrame::new(command));
77    }
78
79    pub fn leave(&mut self) -> Option<ReplScopeFrame> {
80        self.frames.pop()
81    }
82
83    pub fn commands(&self) -> Vec<String> {
84        self.frames
85            .iter()
86            .map(|frame| frame.command.clone())
87            .collect()
88    }
89
90    pub fn contains_command(&self, command: &str) -> bool {
91        self.frames
92            .iter()
93            .any(|frame| frame.command.eq_ignore_ascii_case(command))
94    }
95
96    pub fn display_label(&self) -> Option<String> {
97        if self.is_root() {
98            None
99        } else {
100            Some(
101                self.frames
102                    .iter()
103                    .map(|frame| frame.command.as_str())
104                    .collect::<Vec<_>>()
105                    .join(" / "),
106            )
107        }
108    }
109
110    pub fn history_prefix(&self) -> String {
111        if self.is_root() {
112            String::new()
113        } else {
114            format!(
115                "{} ",
116                self.frames
117                    .iter()
118                    .map(|frame| frame.command.as_str())
119                    .collect::<Vec<_>>()
120                    .join(" ")
121            )
122        }
123    }
124
125    pub fn prefixed_tokens(&self, tokens: &[String]) -> Vec<String> {
126        let prefix = self.commands();
127        if prefix.is_empty() || tokens.starts_with(&prefix) {
128            return tokens.to_vec();
129        }
130        let mut full = prefix;
131        full.extend_from_slice(tokens);
132        full
133    }
134
135    pub fn help_tokens(&self) -> Vec<String> {
136        let mut tokens = self.commands();
137        if !tokens.is_empty() {
138            tokens.push("--help".to_string());
139        }
140        tokens
141    }
142}
143
144pub struct AppSession {
145    pub prompt_prefix: String,
146    pub history_enabled: bool,
147    pub history_shell: HistoryShellContext,
148    pub prompt_timing: DebugTimingState,
149    pub scope: ReplScopeStack,
150    pub last_rows: Vec<Row>,
151    pub last_failure: Option<LastFailure>,
152    pub result_cache: HashMap<String, Vec<Row>>,
153    pub cache_order: VecDeque<String>,
154    pub(crate) command_cache: HashMap<String, CliCommandResult>,
155    pub(crate) command_cache_order: VecDeque<String>,
156    pub max_cached_results: usize,
157    pub config_overrides: ConfigLayer,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct LastFailure {
162    pub command_line: String,
163    pub summary: String,
164    pub detail: String,
165}
166
167impl AppSession {
168    pub fn with_cache_limit(max_cached_results: usize) -> Self {
169        let bounded = max_cached_results.max(1);
170        Self {
171            prompt_prefix: "osp".to_string(),
172            history_enabled: true,
173            history_shell: HistoryShellContext::default(),
174            prompt_timing: DebugTimingState::default(),
175            scope: ReplScopeStack::default(),
176            last_rows: Vec::new(),
177            last_failure: None,
178            result_cache: HashMap::new(),
179            cache_order: VecDeque::new(),
180            command_cache: HashMap::new(),
181            command_cache_order: VecDeque::new(),
182            max_cached_results: bounded,
183            config_overrides: ConfigLayer::default(),
184        }
185    }
186
187    pub fn record_result(&mut self, command_line: &str, rows: Vec<Row>) {
188        let key = command_line.trim().to_string();
189        if key.is_empty() {
190            return;
191        }
192
193        self.last_rows = rows.clone();
194        if !self.result_cache.contains_key(&key)
195            && self.result_cache.len() >= self.max_cached_results
196            && let Some(evict_key) = self.cache_order.pop_front()
197        {
198            self.result_cache.remove(&evict_key);
199        }
200
201        self.cache_order.retain(|item| item != &key);
202        self.cache_order.push_back(key.clone());
203        self.result_cache.insert(key, rows);
204    }
205
206    pub fn record_failure(
207        &mut self,
208        command_line: &str,
209        summary: impl Into<String>,
210        detail: impl Into<String>,
211    ) {
212        let command_line = command_line.trim().to_string();
213        if command_line.is_empty() {
214            return;
215        }
216        self.last_failure = Some(LastFailure {
217            command_line,
218            summary: summary.into(),
219            detail: detail.into(),
220        });
221    }
222
223    pub fn cached_rows(&self, command_line: &str) -> Option<&[Row]> {
224        self.result_cache
225            .get(command_line.trim())
226            .map(|rows| rows.as_slice())
227    }
228
229    pub(crate) fn record_cached_command(&mut self, cache_key: &str, result: &CliCommandResult) {
230        let cache_key = cache_key.trim().to_string();
231        if cache_key.is_empty() {
232            return;
233        }
234
235        if !self.command_cache.contains_key(&cache_key)
236            && self.command_cache.len() >= self.max_cached_results
237            && let Some(evict_key) = self.command_cache_order.pop_front()
238        {
239            self.command_cache.remove(&evict_key);
240        }
241
242        self.command_cache_order.retain(|item| item != &cache_key);
243        self.command_cache_order.push_back(cache_key.clone());
244        self.command_cache.insert(cache_key, result.clone());
245    }
246
247    pub(crate) fn cached_command(&self, cache_key: &str) -> Option<CliCommandResult> {
248        self.command_cache.get(cache_key.trim()).cloned()
249    }
250
251    pub fn record_prompt_timing(
252        &self,
253        level: u8,
254        total: Duration,
255        parse: Option<Duration>,
256        execute: Option<Duration>,
257        render: Option<Duration>,
258    ) {
259        if level == 0 {
260            self.prompt_timing.clear();
261            return;
262        }
263
264        self.prompt_timing.set(DebugTimingBadge {
265            level,
266            summary: TimingSummary {
267                total,
268                parse,
269                execute,
270                render,
271            },
272        });
273    }
274
275    pub fn sync_history_shell_context(&self) {
276        self.history_shell.set_prefix(self.scope.history_prefix());
277    }
278}
279
280pub(crate) struct AppStateInit {
281    pub context: RuntimeContext,
282    pub config: crate::config::ResolvedConfig,
283    pub render_settings: crate::ui::RenderSettings,
284    pub message_verbosity: crate::ui::messages::MessageLevel,
285    pub debug_verbosity: u8,
286    pub plugins: crate::plugin::PluginManager,
287    pub native_commands: NativeCommandRegistry,
288    pub themes: crate::ui::theme_loader::ThemeCatalog,
289    pub launch: LaunchContext,
290}
291
292pub struct AppState {
293    pub runtime: AppRuntime,
294    pub session: AppSession,
295    pub clients: AppClients,
296}
297
298impl AppState {
299    pub(crate) fn new(init: AppStateInit) -> Self {
300        let config_state = ConfigState::new(init.config);
301        let mut auth_state = AuthState::from_resolved(config_state.resolved());
302        let plugin_policy = init
303            .plugins
304            .command_policy_registry()
305            .unwrap_or_else(|err| {
306                tracing::warn!(error = %err, "failed to build plugin command policy registry");
307                CommandPolicyRegistry::default()
308            });
309        let external_policy = merge_policy_registries(
310            plugin_policy,
311            init.native_commands.command_policy_registry(),
312        );
313        auth_state.replace_external_policy(external_policy);
314        let session_cache_max_results = crate::app::host::config_usize(
315            config_state.resolved(),
316            "session.cache.max_results",
317            DEFAULT_SESSION_CACHE_MAX_RESULTS as usize,
318        );
319
320        Self {
321            runtime: AppRuntime {
322                context: init.context,
323                config: config_state,
324                ui: UiState {
325                    render_settings: init.render_settings,
326                    message_verbosity: init.message_verbosity,
327                    debug_verbosity: init.debug_verbosity,
328                },
329                auth: auth_state,
330                themes: init.themes,
331                launch: init.launch,
332            },
333            session: AppSession::with_cache_limit(session_cache_max_results),
334            clients: AppClients::new(init.plugins, init.native_commands),
335        }
336    }
337
338    pub fn prompt_prefix(&self) -> String {
339        self.session.prompt_prefix.clone()
340    }
341
342    pub fn sync_history_shell_context(&self) {
343        self.session.sync_history_shell_context();
344    }
345
346    pub fn record_repl_rows(&mut self, command_line: &str, rows: Vec<Row>) {
347        self.session.record_result(command_line, rows);
348    }
349
350    pub fn record_repl_failure(
351        &mut self,
352        command_line: &str,
353        summary: impl Into<String>,
354        detail: impl Into<String>,
355    ) {
356        self.session.record_failure(command_line, summary, detail);
357    }
358
359    pub fn last_repl_rows(&self) -> Vec<Row> {
360        self.session.last_rows.clone()
361    }
362
363    pub fn last_repl_failure(&self) -> Option<LastFailure> {
364        self.session.last_failure.clone()
365    }
366
367    pub fn cached_repl_rows(&self, command_line: &str) -> Option<Vec<Row>> {
368        self.session
369            .cached_rows(command_line)
370            .map(ToOwned::to_owned)
371    }
372
373    pub fn repl_cache_size(&self) -> usize {
374        self.session.result_cache.len()
375    }
376}
377
378fn merge_policy_registries(
379    mut left: CommandPolicyRegistry,
380    right: CommandPolicyRegistry,
381) -> CommandPolicyRegistry {
382    for policy in right.entries() {
383        left.register(policy.clone());
384    }
385    left
386}