Skip to main content

osp_cli/repl/
history_store.rs

1//! Persistent REPL history with profile-aware visibility and shell-prefix
2//! scoping.
3//!
4//! Records are stored oldest-to-newest. Public listing helpers preserve that
5//! order while filtering by the active profile scope, optional shell prefix,
6//! and configured exclusion patterns. Shell-scoped views strip the prefix back
7//! off so callers see the command as it was typed inside that shell.
8//!
9//! Pruning and clearing only remove records visible in the chosen scope. The
10//! store also records terminal identifiers on entries for provenance, but that
11//! metadata is not currently part of view scoping.
12//!
13//! Public API shape:
14//!
15//! - [`HistoryConfig::builder`] is the guided construction path and produces a
16//!   normalized config snapshot on `build()`
17//! - [`SharedHistory`] is the public facade; the raw `reedline` store stays
18//!   crate-private
19
20use std::fs::File;
21use std::io::{BufRead, BufReader};
22use std::path::{Path, PathBuf};
23use std::sync::{Arc, Mutex, RwLock};
24use std::time::{Duration, SystemTime, UNIX_EPOCH};
25
26use anyhow::Result;
27use reedline::{
28    CommandLineSearch, History, HistoryItem, HistoryItemId, HistorySessionId, ReedlineError,
29    ReedlineErrorVariants, Result as ReedlineResult, SearchDirection, SearchFilter, SearchQuery,
30};
31use serde::{Deserialize, Serialize};
32
33/// Configuration for REPL history persistence, visibility, and shell scoping.
34#[derive(Debug, Clone)]
35#[non_exhaustive]
36#[must_use]
37pub struct HistoryConfig {
38    /// Path to the history file, when persistence is enabled.
39    pub path: Option<PathBuf>,
40    /// Maximum number of retained history entries.
41    pub max_entries: usize,
42    /// Whether history capture is enabled.
43    pub enabled: bool,
44    /// Whether duplicate commands should be collapsed.
45    pub dedupe: bool,
46    /// Whether entries should be partitioned by active profile.
47    pub profile_scoped: bool,
48    /// Prefix patterns excluded from persistence.
49    pub exclude_patterns: Vec<String>,
50    /// Active profile identifier used for scoping.
51    pub profile: Option<String>,
52    /// Active terminal identifier recorded on saved entries.
53    pub terminal: Option<String>,
54    /// Shared shell-prefix scope used to filter and strip shell-prefixed views.
55    pub shell_context: HistoryShellContext,
56}
57
58impl Default for HistoryConfig {
59    fn default() -> Self {
60        Self {
61            path: None,
62            max_entries: 1_000,
63            enabled: true,
64            dedupe: true,
65            profile_scoped: true,
66            exclude_patterns: Vec::new(),
67            profile: None,
68            terminal: None,
69            shell_context: HistoryShellContext::default(),
70        }
71    }
72}
73
74impl HistoryConfig {
75    /// Starts guided construction for REPL history configuration.
76    ///
77    /// # Examples
78    ///
79    /// ```
80    /// use std::path::PathBuf;
81    ///
82    /// use osp_cli::repl::HistoryConfig;
83    ///
84    /// let config = HistoryConfig::builder()
85    ///     .with_path(Some(PathBuf::from("/tmp/osp-history.jsonl")))
86    ///     .with_max_entries(250)
87    ///     .with_profile(Some(" Dev ".to_string()))
88    ///     .build();
89    ///
90    /// assert_eq!(config.max_entries, 250);
91    /// assert_eq!(config.profile.as_deref(), Some("dev"));
92    /// ```
93    pub fn builder() -> HistoryConfigBuilder {
94        HistoryConfigBuilder::new()
95    }
96
97    /// Normalizes configured identifiers and exclusion patterns.
98    pub fn normalized(mut self) -> Self {
99        self.exclude_patterns =
100            normalize_exclude_patterns(std::mem::take(&mut self.exclude_patterns));
101        self.profile = normalize_identifier(self.profile.take());
102        self.terminal = normalize_identifier(self.terminal.take());
103        self
104    }
105
106    fn persist_enabled(&self) -> bool {
107        self.enabled && self.path.is_some() && self.max_entries > 0
108    }
109}
110
111/// Builder for [`HistoryConfig`].
112#[derive(Debug, Clone, Default)]
113#[must_use]
114pub struct HistoryConfigBuilder {
115    config: HistoryConfig,
116}
117
118impl HistoryConfigBuilder {
119    /// Starts a builder with normal REPL-history defaults.
120    pub fn new() -> Self {
121        Self {
122            config: HistoryConfig::default(),
123        }
124    }
125
126    /// Replaces the optional persistence path.
127    ///
128    /// If omitted, history persistence stays in-memory only.
129    pub fn with_path(mut self, path: Option<PathBuf>) -> Self {
130        self.config.path = path;
131        self
132    }
133
134    /// Replaces the retained-entry limit.
135    ///
136    /// If omitted, the builder keeps the default retained-entry limit.
137    pub fn with_max_entries(mut self, max_entries: usize) -> Self {
138        self.config.max_entries = max_entries;
139        self
140    }
141
142    /// Enables or disables history capture.
143    ///
144    /// If omitted, history capture stays enabled.
145    pub fn with_enabled(mut self, enabled: bool) -> Self {
146        self.config.enabled = enabled;
147        self
148    }
149
150    /// Enables or disables duplicate collapsing.
151    ///
152    /// If omitted, duplicate collapsing stays enabled.
153    pub fn with_dedupe(mut self, dedupe: bool) -> Self {
154        self.config.dedupe = dedupe;
155        self
156    }
157
158    /// Enables or disables profile scoping.
159    ///
160    /// If omitted, history remains profile-scoped.
161    pub fn with_profile_scoped(mut self, profile_scoped: bool) -> Self {
162        self.config.profile_scoped = profile_scoped;
163        self
164    }
165
166    /// Replaces the excluded command patterns.
167    ///
168    /// If omitted, no exclusion patterns are applied.
169    pub fn with_exclude_patterns<I, S>(mut self, exclude_patterns: I) -> Self
170    where
171        I: IntoIterator<Item = S>,
172        S: Into<String>,
173    {
174        self.config.exclude_patterns = exclude_patterns.into_iter().map(Into::into).collect();
175        self
176    }
177
178    /// Replaces the active profile used for scoping.
179    ///
180    /// If omitted, history entries are not tagged with a profile identifier.
181    pub fn with_profile(mut self, profile: Option<String>) -> Self {
182        self.config.profile = profile;
183        self
184    }
185
186    /// Replaces the active terminal label recorded on entries.
187    ///
188    /// If omitted, saved entries carry no terminal label.
189    pub fn with_terminal(mut self, terminal: Option<String>) -> Self {
190        self.config.terminal = terminal;
191        self
192    }
193
194    /// Replaces the shared shell context used for scoped history views.
195    ///
196    /// If omitted, the builder keeps [`HistoryShellContext::default`].
197    pub fn with_shell_context(mut self, shell_context: HistoryShellContext) -> Self {
198        self.config.shell_context = shell_context;
199        self
200    }
201
202    /// Builds a normalized history configuration.
203    pub fn build(self) -> HistoryConfig {
204        self.config.normalized()
205    }
206}
207
208/// Shared shell-prefix state used to scope history to nested shell integrations.
209#[derive(Clone, Default, Debug)]
210pub struct HistoryShellContext {
211    inner: Arc<RwLock<Option<String>>>,
212}
213
214impl HistoryShellContext {
215    /// Creates a shell context with an initial normalized prefix.
216    pub fn new(prefix: impl Into<String>) -> Self {
217        let context = Self::default();
218        context.set_prefix(prefix);
219        context
220    }
221
222    /// Sets or replaces the normalized shell prefix.
223    pub fn set_prefix(&self, prefix: impl Into<String>) {
224        if let Ok(mut guard) = self.inner.write() {
225            *guard = normalize_shell_prefix(prefix.into());
226        }
227    }
228
229    /// Clears the current shell prefix.
230    pub fn clear(&self) {
231        if let Ok(mut guard) = self.inner.write() {
232            *guard = None;
233        }
234    }
235
236    /// Returns the current normalized shell prefix, if one is set.
237    pub fn prefix(&self) -> Option<String> {
238        self.inner.read().map(|value| value.clone()).unwrap_or(None)
239    }
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize)]
243struct HistoryRecord {
244    id: i64,
245    command_line: String,
246    #[serde(default)]
247    timestamp_ms: Option<i64>,
248    #[serde(default)]
249    duration_ms: Option<i64>,
250    #[serde(default)]
251    exit_status: Option<i64>,
252    #[serde(default)]
253    cwd: Option<String>,
254    #[serde(default)]
255    hostname: Option<String>,
256    #[serde(default)]
257    session_id: Option<i64>,
258    #[serde(default)]
259    profile: Option<String>,
260    #[serde(default)]
261    terminal: Option<String>,
262}
263
264/// Visible history entry returned by listing operations after scope filtering.
265#[derive(Debug, Clone)]
266pub struct HistoryEntry {
267    /// Stable identifier within the visible ordered entry list.
268    pub id: i64,
269    /// Recorded timestamp in milliseconds since the Unix epoch, when available.
270    pub timestamp_ms: Option<i64>,
271    /// Command line as presented in the selected scope.
272    pub command: String,
273}
274
275/// Thread-safe facade over the REPL history store.
276///
277/// Listing helpers return entries in oldest-to-newest order. Mutating helpers
278/// such as prune and clear only touch entries visible in the chosen scope.
279#[derive(Clone)]
280pub struct SharedHistory {
281    inner: Arc<Mutex<OspHistoryStore>>,
282}
283
284impl SharedHistory {
285    /// Creates a shared history store from the provided configuration.
286    ///
287    /// Persisted history loading is best-effort: unreadable files and malformed
288    /// lines are ignored during initialization.
289    pub fn new(config: HistoryConfig) -> Self {
290        Self {
291            inner: Arc::new(Mutex::new(OspHistoryStore::new(config))),
292        }
293    }
294
295    /// Returns whether history capture is enabled for the current config.
296    pub fn enabled(&self) -> bool {
297        self.inner
298            .lock()
299            .map(|store| store.history_enabled())
300            .unwrap_or(false)
301    }
302
303    /// Returns visible commands in oldest-to-newest order using the active
304    /// shell scope.
305    pub fn recent_commands(&self) -> Vec<String> {
306        self.inner
307            .lock()
308            .map(|store| store.recent_commands())
309            .unwrap_or_default()
310    }
311
312    /// Returns visible commands in oldest-to-newest order for the provided
313    /// shell prefix.
314    ///
315    /// Matching profile scope and exclusion patterns still apply. When a shell
316    /// prefix is provided, the returned commands have that prefix stripped.
317    pub fn recent_commands_for(&self, shell_prefix: Option<&str>) -> Vec<String> {
318        self.inner
319            .lock()
320            .map(|store| store.recent_commands_for(shell_prefix))
321            .unwrap_or_default()
322    }
323
324    /// Returns visible history entries in oldest-to-newest order using the
325    /// active shell scope.
326    pub fn list_entries(&self) -> Vec<HistoryEntry> {
327        self.inner
328            .lock()
329            .map(|store| store.list_entries())
330            .unwrap_or_default()
331    }
332
333    /// Returns visible history entries in oldest-to-newest order for the
334    /// provided shell prefix.
335    pub fn list_entries_for(&self, shell_prefix: Option<&str>) -> Vec<HistoryEntry> {
336        self.inner
337            .lock()
338            .map(|store| store.list_entries_for(shell_prefix))
339            .unwrap_or_default()
340    }
341
342    /// Removes the oldest visible entries, keeping at most `keep` entries in
343    /// the active scope.
344    ///
345    /// Returns the number of removed entries.
346    ///
347    /// # Errors
348    ///
349    /// Returns an error when the history lock is poisoned or when persisting
350    /// the updated history file fails.
351    pub fn prune(&self, keep: usize) -> Result<usize> {
352        let mut guard = self
353            .inner
354            .lock()
355            .map_err(|_| anyhow::anyhow!("history lock poisoned"))?;
356        guard.prune(keep)
357    }
358
359    /// Removes the oldest visible entries for a specific shell scope, keeping
360    /// at most `keep`.
361    ///
362    /// Returns the number of removed entries.
363    ///
364    /// # Errors
365    ///
366    /// Returns an error when the history lock is poisoned or when persisting
367    /// the updated history file fails.
368    pub fn prune_for(&self, keep: usize, shell_prefix: Option<&str>) -> Result<usize> {
369        let mut guard = self
370            .inner
371            .lock()
372            .map_err(|_| anyhow::anyhow!("history lock poisoned"))?;
373        guard.prune_for(keep, shell_prefix)
374    }
375
376    /// Clears all entries visible in the current scope.
377    ///
378    /// Returns the number of removed entries.
379    ///
380    /// # Errors
381    ///
382    /// Returns an error when the history lock is poisoned or when persisting
383    /// the updated history file fails.
384    pub fn clear_scoped(&self) -> Result<usize> {
385        let mut guard = self
386            .inner
387            .lock()
388            .map_err(|_| anyhow::anyhow!("history lock poisoned"))?;
389        guard.clear_scoped()
390    }
391
392    /// Clears all entries visible to the provided shell prefix.
393    ///
394    /// Returns the number of removed entries.
395    ///
396    /// # Errors
397    ///
398    /// Returns an error when the history lock is poisoned or when persisting
399    /// the updated history file fails.
400    pub fn clear_for(&self, shell_prefix: Option<&str>) -> Result<usize> {
401        let mut guard = self
402            .inner
403            .lock()
404            .map_err(|_| anyhow::anyhow!("history lock poisoned"))?;
405        guard.clear_for(shell_prefix)
406    }
407
408    /// Saves one command line through the underlying `reedline::History`
409    /// implementation.
410    ///
411    /// # Errors
412    ///
413    /// Returns an error when the history lock is poisoned or when the
414    /// underlying history backend rejects or fails to persist the item.
415    pub fn save_command_line(&self, command_line: &str) -> Result<()> {
416        let mut guard = self
417            .inner
418            .lock()
419            .map_err(|_| anyhow::anyhow!("history lock poisoned"))?;
420        let item = HistoryItem::from_command_line(command_line);
421        History::save(&mut *guard, item).map(|_| ())?;
422        Ok(())
423    }
424}
425
426/// `reedline::History` implementation backed by newline-delimited JSON records.
427///
428/// The store keeps insertion order, applies visibility rules when presenting
429/// commands back to callers, and writes the full persisted record stream back
430/// out atomically after mutations.
431pub(crate) struct OspHistoryStore {
432    config: HistoryConfig,
433    records: Vec<HistoryRecord>,
434}
435
436impl OspHistoryStore {
437    /// Creates a history store and eagerly loads persisted records when
438    /// persistence is enabled.
439    pub fn new(config: HistoryConfig) -> Self {
440        let config = config.normalized();
441        let mut records = Vec::new();
442        if config.persist_enabled()
443            && let Some(path) = &config.path
444        {
445            records = load_records(path);
446        }
447        let mut store = Self { config, records };
448        store.trim_to_capacity();
449        store
450    }
451
452    /// Returns whether history operations are active for this store.
453    ///
454    /// This is false when history is disabled or when the configured capacity is
455    /// zero.
456    pub fn history_enabled(&self) -> bool {
457        self.config.enabled && self.config.max_entries > 0
458    }
459
460    /// Returns visible commands in oldest-to-newest order using the active
461    /// shell scope.
462    pub fn recent_commands(&self) -> Vec<String> {
463        self.recent_commands_for(self.shell_prefix().as_deref())
464    }
465
466    /// Returns visible commands in oldest-to-newest order for the provided
467    /// shell prefix.
468    ///
469    /// Profile scoping and exclusion patterns still apply.
470    pub fn recent_commands_for(&self, shell_prefix: Option<&str>) -> Vec<String> {
471        let shell_prefix = normalize_scope_prefix(shell_prefix);
472        self.records
473            .iter()
474            .filter_map(|record| {
475                self.record_view_if_allowed(record, shell_prefix.as_deref(), true)
476                    .map(|_| record.command_line.clone())
477            })
478            .collect()
479    }
480
481    /// Returns visible history entries in oldest-to-newest order using the
482    /// active shell scope.
483    pub fn list_entries(&self) -> Vec<HistoryEntry> {
484        self.list_entries_for(self.shell_prefix().as_deref())
485    }
486
487    /// Returns visible history entries in oldest-to-newest order for the
488    /// provided shell prefix.
489    pub fn list_entries_for(&self, shell_prefix: Option<&str>) -> Vec<HistoryEntry> {
490        if !self.history_enabled() {
491            return Vec::new();
492        }
493        let shell_prefix = normalize_scope_prefix(shell_prefix);
494        let mut out = Vec::new();
495        let mut id = 0i64;
496        for record in &self.records {
497            let Some(view) = self.record_view_if_allowed(record, shell_prefix.as_deref(), true)
498            else {
499                continue;
500            };
501            id += 1;
502            out.push(HistoryEntry {
503                id,
504                timestamp_ms: record.timestamp_ms,
505                command: view,
506            });
507        }
508        out
509    }
510
511    /// Removes the oldest visible entries, keeping at most `keep` in the active
512    /// scope.
513    ///
514    /// Returns the number of removed entries.
515    pub fn prune(&mut self, keep: usize) -> Result<usize> {
516        let shell_prefix = self.shell_prefix();
517        self.prune_for(keep, shell_prefix.as_deref())
518    }
519
520    /// Removes the oldest visible entries for a specific shell scope, keeping
521    /// at most `keep`.
522    ///
523    /// Returns the number of removed entries.
524    pub fn prune_for(&mut self, keep: usize, shell_prefix: Option<&str>) -> Result<usize> {
525        if !self.history_enabled() {
526            return Ok(0);
527        }
528        let shell_prefix = normalize_scope_prefix(shell_prefix);
529        let mut eligible = Vec::new();
530        for (idx, record) in self.records.iter().enumerate() {
531            if self
532                .record_view_if_allowed(record, shell_prefix.as_deref(), true)
533                .is_some()
534            {
535                eligible.push(idx);
536            }
537        }
538
539        if keep == 0 {
540            return self.remove_records(&eligible);
541        }
542
543        if eligible.len() <= keep {
544            return Ok(0);
545        }
546
547        let remove_count = eligible.len() - keep;
548        let to_remove = eligible.into_iter().take(remove_count).collect::<Vec<_>>();
549        self.remove_records(&to_remove)
550    }
551
552    /// Clears all entries visible in the current scope.
553    ///
554    /// This is equivalent to `prune(0)`.
555    ///
556    /// Returns the number of removed entries.
557    pub fn clear_scoped(&mut self) -> Result<usize> {
558        self.prune(0)
559    }
560
561    /// Clears all entries visible to the provided shell prefix.
562    ///
563    /// This is equivalent to `prune_for(0, shell_prefix)`.
564    ///
565    /// Returns the number of removed entries.
566    pub fn clear_for(&mut self, shell_prefix: Option<&str>) -> Result<usize> {
567        self.prune_for(0, shell_prefix)
568    }
569
570    fn profile_allows(&self, record: &HistoryRecord) -> bool {
571        if !self.config.profile_scoped {
572            return true;
573        }
574        match (self.config.profile.as_deref(), record.profile.as_deref()) {
575            (Some(active), Some(profile)) => active == profile,
576            (Some(_), None) => false,
577            _ => true,
578        }
579    }
580
581    fn shell_prefix(&self) -> Option<String> {
582        self.config.shell_context.prefix()
583    }
584
585    fn shell_allows(&self, record: &HistoryRecord, shell_prefix: Option<&str>) -> bool {
586        command_matches_shell_prefix(&record.command_line, shell_prefix)
587    }
588
589    fn view_command_line(&self, command: &str, shell_prefix: Option<&str>) -> String {
590        strip_shell_prefix(command, shell_prefix)
591    }
592
593    fn record_view_if_allowed(
594        &self,
595        record: &HistoryRecord,
596        shell_prefix: Option<&str>,
597        require_shell: bool,
598    ) -> Option<String> {
599        if !self.profile_allows(record) {
600            return None;
601        }
602        if require_shell && !self.shell_allows(record, shell_prefix) {
603            return None;
604        }
605        let view_command = self.view_command_line(&record.command_line, shell_prefix);
606        if self.is_command_excluded(&view_command) {
607            return None;
608        }
609        Some(view_command)
610    }
611
612    fn is_command_excluded(&self, command: &str) -> bool {
613        is_excluded_command(command, &self.config.exclude_patterns)
614    }
615
616    fn next_id(&self) -> i64 {
617        self.records.len() as i64
618    }
619
620    fn trim_to_capacity(&mut self) {
621        if self.config.max_entries == 0 {
622            self.records.clear();
623            return;
624        }
625        if self.records.len() > self.config.max_entries {
626            let start = self.records.len() - self.config.max_entries;
627            self.records = self.records.split_off(start);
628        }
629        for (idx, record) in self.records.iter_mut().enumerate() {
630            record.id = idx as i64;
631        }
632    }
633
634    fn append_record(&mut self, mut record: HistoryRecord) -> HistoryItemId {
635        record.id = self.next_id();
636        self.records.push(record);
637        self.trim_to_capacity();
638        HistoryItemId::new(self.records.len() as i64 - 1)
639    }
640
641    fn remove_records(&mut self, indices: &[usize]) -> Result<usize> {
642        if indices.is_empty() {
643            return Ok(0);
644        }
645        let mut drop_flags = vec![false; self.records.len()];
646        for idx in indices {
647            if *idx < drop_flags.len() {
648                drop_flags[*idx] = true;
649            }
650        }
651        let mut cursor = 0usize;
652        let removed = drop_flags.iter().filter(|flag| **flag).count();
653        self.records.retain(|_| {
654            let keep = !drop_flags.get(cursor).copied().unwrap_or(false);
655            cursor += 1;
656            keep
657        });
658        self.trim_to_capacity();
659        if let Err(err) = self.write_all() {
660            return Err(err.into());
661        }
662        Ok(removed)
663    }
664
665    fn write_all(&self) -> std::io::Result<()> {
666        if !self.config.persist_enabled() {
667            return Ok(());
668        }
669        let Some(path) = &self.config.path else {
670            return Ok(());
671        };
672        if let Some(parent) = path.parent() {
673            std::fs::create_dir_all(parent)?;
674        }
675        let mut payload = Vec::new();
676        for record in &self.records {
677            serde_json::to_writer(&mut payload, record).map_err(std::io::Error::other)?;
678            payload.push(b'\n');
679        }
680        crate::config::write_text_atomic(path, &payload, false)
681    }
682
683    fn should_skip_command(&self, command: &str) -> bool {
684        is_excluded_command(command, &self.config.exclude_patterns)
685    }
686
687    fn command_list_for_expansion(&self) -> Vec<String> {
688        self.recent_commands()
689    }
690
691    fn expand_if_needed(&self, command: &str, shell_prefix: Option<&str>) -> Option<String> {
692        if !command.starts_with('!') {
693            return Some(command.to_string());
694        }
695        let history = self.command_list_for_expansion();
696        expand_history(command, &history, shell_prefix, false)
697    }
698
699    fn record_matches_filter(
700        &self,
701        record: &HistoryRecord,
702        filter: &SearchFilter,
703        shell_prefix: Option<&str>,
704    ) -> bool {
705        if !self.profile_allows(record) {
706            return false;
707        }
708        if !self.shell_allows(record, shell_prefix) {
709            return false;
710        }
711        let view_command = self.view_command_line(&record.command_line, shell_prefix);
712        if self.is_command_excluded(&view_command) {
713            return false;
714        }
715        if let Some(search) = &filter.command_line {
716            let matches = match search {
717                CommandLineSearch::Prefix(prefix) => view_command.starts_with(prefix),
718                CommandLineSearch::Substring(substr) => view_command.contains(substr),
719                CommandLineSearch::Exact(exact) => view_command == *exact,
720            };
721            if !matches {
722                return false;
723            }
724        }
725        if let Some(hostname) = &filter.hostname
726            && record.hostname.as_deref() != Some(hostname.as_str())
727        {
728            return false;
729        }
730        if let Some(cwd) = &filter.cwd_exact
731            && record.cwd.as_deref() != Some(cwd.as_str())
732        {
733            return false;
734        }
735        if let Some(prefix) = &filter.cwd_prefix {
736            match record.cwd.as_deref() {
737                Some(value) if value.starts_with(prefix) => {}
738                _ => return false,
739            }
740        }
741        if let Some(exit_successful) = filter.exit_successful {
742            let is_success = record.exit_status == Some(0);
743            if exit_successful != is_success {
744                return false;
745            }
746        }
747        if let Some(session) = filter.session
748            && record.session_id != Some(i64::from(session))
749        {
750            return false;
751        }
752        true
753    }
754
755    fn record_from_item(&self, item: &HistoryItem, command_line: String) -> HistoryRecord {
756        HistoryRecord {
757            id: -1,
758            command_line,
759            timestamp_ms: item.start_timestamp.map(|ts| ts.timestamp_millis()),
760            duration_ms: item.duration.map(|value| value.as_millis() as i64),
761            exit_status: item.exit_status,
762            cwd: item.cwd.clone(),
763            hostname: item.hostname.clone(),
764            session_id: item.session_id.map(i64::from),
765            profile: self.config.profile.clone(),
766            terminal: self.config.terminal.clone(),
767        }
768    }
769
770    fn history_item_from_record(
771        &self,
772        record: &HistoryRecord,
773        shell_prefix: Option<&str>,
774    ) -> HistoryItem {
775        let command_line = self.view_command_line(&record.command_line, shell_prefix);
776        HistoryItem {
777            id: Some(HistoryItemId::new(record.id)),
778            start_timestamp: None,
779            command_line,
780            session_id: None,
781            hostname: record.hostname.clone(),
782            cwd: record.cwd.clone(),
783            duration: record
784                .duration_ms
785                .map(|value| Duration::from_millis(value as u64)),
786            exit_status: record.exit_status,
787            more_info: None,
788        }
789    }
790
791    fn reedline_error(message: &'static str) -> ReedlineError {
792        ReedlineError(ReedlineErrorVariants::OtherHistoryError(message))
793    }
794
795    fn record_matches_query(
796        &self,
797        record: &HistoryRecord,
798        filter: &SearchFilter,
799        start_time_ms: Option<i64>,
800        end_time_ms: Option<i64>,
801        shell_prefix: Option<&str>,
802        skip_command_line: Option<&str>,
803    ) -> bool {
804        if !self.record_matches_filter(record, filter, shell_prefix) {
805            return false;
806        }
807        if let Some(skip) = skip_command_line {
808            let view_command = self.view_command_line(&record.command_line, shell_prefix);
809            if view_command == skip {
810                return false;
811            }
812        }
813        if let Some(start) = start_time_ms {
814            match record.timestamp_ms {
815                Some(value) if value >= start => {}
816                _ => return false,
817            }
818        }
819        if let Some(end) = end_time_ms {
820            match record.timestamp_ms {
821                Some(value) if value <= end => {}
822                _ => return false,
823            }
824        }
825        true
826    }
827}
828
829impl History for OspHistoryStore {
830    fn save(&mut self, h: HistoryItem) -> ReedlineResult<HistoryItem> {
831        if !self.config.enabled || self.config.max_entries == 0 {
832            return Ok(h);
833        }
834
835        let raw = h.command_line.trim();
836        if raw.is_empty() {
837            return Ok(h);
838        }
839
840        let shell_prefix = self.shell_prefix();
841        let Some(expanded) = self.expand_if_needed(raw, shell_prefix.as_deref()) else {
842            return Ok(h);
843        };
844        if self.should_skip_command(&expanded) {
845            return Ok(h);
846        }
847        let expanded_full = apply_shell_prefix(&expanded, shell_prefix.as_deref());
848
849        if self.config.dedupe {
850            let last_match = self.records.iter().rev().find(|record| {
851                self.profile_allows(record) && self.shell_allows(record, shell_prefix.as_deref())
852            });
853            if let Some(last) = last_match
854                && last.command_line == expanded_full
855            {
856                return Ok(h);
857            }
858        }
859
860        let mut record = self.record_from_item(&h, expanded_full);
861        if record.timestamp_ms.is_none() {
862            record.timestamp_ms = Some(now_ms());
863        }
864        let id = self.append_record(record);
865
866        if let Err(err) = self.write_all() {
867            return Err(ReedlineError(ReedlineErrorVariants::IOError(err)));
868        }
869
870        Ok(HistoryItem {
871            id: Some(id),
872            command_line: self.records[id.0 as usize].command_line.clone(),
873            ..h
874        })
875    }
876
877    fn load(&self, id: HistoryItemId) -> ReedlineResult<HistoryItem> {
878        let idx = id.0 as usize;
879        let shell_prefix = self.shell_prefix();
880        let record = self
881            .records
882            .get(idx)
883            .ok_or_else(|| Self::reedline_error("history item not found"))?;
884        Ok(self.history_item_from_record(record, shell_prefix.as_deref()))
885    }
886
887    fn count(&self, query: SearchQuery) -> ReedlineResult<i64> {
888        Ok(self.search(query)?.len() as i64)
889    }
890
891    fn search(&self, query: SearchQuery) -> ReedlineResult<Vec<HistoryItem>> {
892        let (min_id, max_id) = {
893            let start = query.start_id.map(|value| value.0);
894            let end = query.end_id.map(|value| value.0);
895            if let SearchDirection::Backward = query.direction {
896                (end, start)
897            } else {
898                (start, end)
899            }
900        };
901        let min_id = min_id.map(|value| value + 1).unwrap_or(0);
902        let max_id = max_id
903            .map(|value| value - 1)
904            .unwrap_or(self.records.len().saturating_sub(1) as i64);
905
906        if self.records.is_empty() || max_id < 0 || min_id > max_id {
907            return Ok(Vec::new());
908        }
909
910        let intrinsic_limit = max_id - min_id + 1;
911        let limit = query
912            .limit
913            .map(|value| std::cmp::min(intrinsic_limit, value) as usize)
914            .unwrap_or(intrinsic_limit as usize);
915
916        let start_time_ms = query.start_time.map(|ts| ts.timestamp_millis());
917        let end_time_ms = query.end_time.map(|ts| ts.timestamp_millis());
918        let shell_prefix = self.shell_prefix();
919
920        let mut results = Vec::new();
921        let iter = self
922            .records
923            .iter()
924            .enumerate()
925            .skip(min_id as usize)
926            .take(intrinsic_limit as usize);
927        let skip_command_line = query
928            .start_id
929            .and_then(|id| self.records.get(id.0 as usize))
930            .map(|record| self.view_command_line(&record.command_line, shell_prefix.as_deref()));
931
932        if let SearchDirection::Backward = query.direction {
933            for (idx, record) in iter.rev() {
934                if results.len() >= limit {
935                    break;
936                }
937                if !self.record_matches_query(
938                    record,
939                    &query.filter,
940                    start_time_ms,
941                    end_time_ms,
942                    shell_prefix.as_deref(),
943                    skip_command_line.as_deref(),
944                ) {
945                    continue;
946                }
947                let mut item = self.history_item_from_record(record, shell_prefix.as_deref());
948                item.id = Some(HistoryItemId::new(idx as i64));
949                results.push(item);
950            }
951        } else {
952            for (idx, record) in iter {
953                if results.len() >= limit {
954                    break;
955                }
956                if !self.record_matches_query(
957                    record,
958                    &query.filter,
959                    start_time_ms,
960                    end_time_ms,
961                    shell_prefix.as_deref(),
962                    skip_command_line.as_deref(),
963                ) {
964                    continue;
965                }
966                let mut item = self.history_item_from_record(record, shell_prefix.as_deref());
967                item.id = Some(HistoryItemId::new(idx as i64));
968                results.push(item);
969            }
970        }
971
972        Ok(results)
973    }
974
975    fn update(
976        &mut self,
977        _id: HistoryItemId,
978        _updater: &dyn Fn(HistoryItem) -> HistoryItem,
979    ) -> ReedlineResult<()> {
980        Err(ReedlineError(
981            ReedlineErrorVariants::HistoryFeatureUnsupported {
982                history: "OspHistoryStore",
983                feature: "updating entries",
984            },
985        ))
986    }
987
988    fn clear(&mut self) -> ReedlineResult<()> {
989        self.records.clear();
990        if let Some(path) = &self.config.path {
991            let _ = std::fs::remove_file(path);
992        }
993        Ok(())
994    }
995
996    fn delete(&mut self, _h: HistoryItemId) -> ReedlineResult<()> {
997        Err(ReedlineError(
998            ReedlineErrorVariants::HistoryFeatureUnsupported {
999                history: "OspHistoryStore",
1000                feature: "removing entries",
1001            },
1002        ))
1003    }
1004
1005    fn sync(&mut self) -> std::io::Result<()> {
1006        self.write_all()
1007    }
1008
1009    fn session(&self) -> Option<HistorySessionId> {
1010        None
1011    }
1012}
1013
1014impl History for SharedHistory {
1015    fn save(&mut self, h: HistoryItem) -> ReedlineResult<HistoryItem> {
1016        let mut guard = self
1017            .inner
1018            .lock()
1019            .map_err(|_| OspHistoryStore::reedline_error("history lock poisoned"))?;
1020        History::save(&mut *guard, h)
1021    }
1022
1023    fn load(&self, id: HistoryItemId) -> ReedlineResult<HistoryItem> {
1024        let guard = self
1025            .inner
1026            .lock()
1027            .map_err(|_| OspHistoryStore::reedline_error("history lock poisoned"))?;
1028        History::load(&*guard, id)
1029    }
1030
1031    fn count(&self, query: SearchQuery) -> ReedlineResult<i64> {
1032        let guard = self
1033            .inner
1034            .lock()
1035            .map_err(|_| OspHistoryStore::reedline_error("history lock poisoned"))?;
1036        History::count(&*guard, query)
1037    }
1038
1039    fn search(&self, query: SearchQuery) -> ReedlineResult<Vec<HistoryItem>> {
1040        let guard = self
1041            .inner
1042            .lock()
1043            .map_err(|_| OspHistoryStore::reedline_error("history lock poisoned"))?;
1044        History::search(&*guard, query)
1045    }
1046
1047    fn update(
1048        &mut self,
1049        id: HistoryItemId,
1050        updater: &dyn Fn(HistoryItem) -> HistoryItem,
1051    ) -> ReedlineResult<()> {
1052        let mut guard = self
1053            .inner
1054            .lock()
1055            .map_err(|_| OspHistoryStore::reedline_error("history lock poisoned"))?;
1056        History::update(&mut *guard, id, updater)
1057    }
1058
1059    fn clear(&mut self) -> ReedlineResult<()> {
1060        let mut guard = self
1061            .inner
1062            .lock()
1063            .map_err(|_| OspHistoryStore::reedline_error("history lock poisoned"))?;
1064        History::clear(&mut *guard)
1065    }
1066
1067    fn delete(&mut self, h: HistoryItemId) -> ReedlineResult<()> {
1068        let mut guard = self
1069            .inner
1070            .lock()
1071            .map_err(|_| OspHistoryStore::reedline_error("history lock poisoned"))?;
1072        History::delete(&mut *guard, h)
1073    }
1074
1075    fn sync(&mut self) -> std::io::Result<()> {
1076        let mut guard = self
1077            .inner
1078            .lock()
1079            .map_err(|_| std::io::Error::other("history lock poisoned"))?;
1080        History::sync(&mut *guard)
1081    }
1082
1083    fn session(&self) -> Option<HistorySessionId> {
1084        let guard = self.inner.lock().ok()?;
1085        History::session(&*guard)
1086    }
1087}
1088
1089fn load_records(path: &Path) -> Vec<HistoryRecord> {
1090    if !path.exists() {
1091        return Vec::new();
1092    }
1093    let file = match File::open(path) {
1094        Ok(file) => file,
1095        Err(_) => return Vec::new(),
1096    };
1097    let reader = BufReader::new(file);
1098    let mut records = Vec::new();
1099    for line in reader.lines().map_while(Result::ok) {
1100        let trimmed = line.trim();
1101        if trimmed.is_empty() {
1102            continue;
1103        }
1104        let record: HistoryRecord = match serde_json::from_str(trimmed) {
1105            Ok(record) => record,
1106            Err(_) => continue,
1107        };
1108        if record.command_line.trim().is_empty() {
1109            continue;
1110        }
1111        records.push(record);
1112    }
1113    records
1114}
1115
1116fn normalize_identifier(value: Option<String>) -> Option<String> {
1117    value
1118        .map(|value| value.trim().to_ascii_lowercase())
1119        .filter(|value| !value.is_empty())
1120}
1121
1122fn normalize_exclude_patterns(patterns: Vec<String>) -> Vec<String> {
1123    patterns
1124        .into_iter()
1125        .map(|pattern| pattern.trim().to_string())
1126        .filter(|pattern| !pattern.is_empty())
1127        .collect()
1128}
1129
1130fn normalize_shell_prefix(value: String) -> Option<String> {
1131    let trimmed = value.trim();
1132    if trimmed.is_empty() {
1133        return None;
1134    }
1135    let mut out = trimmed.to_string();
1136    if !out.ends_with(' ') {
1137        out.push(' ');
1138    }
1139    Some(out)
1140}
1141
1142fn normalize_scope_prefix(shell_prefix: Option<&str>) -> Option<String> {
1143    shell_prefix.and_then(|value| normalize_shell_prefix(value.to_string()))
1144}
1145
1146fn command_matches_shell_prefix(command: &str, shell_prefix: Option<&str>) -> bool {
1147    match shell_prefix {
1148        Some(prefix) => command.starts_with(prefix),
1149        None => true,
1150    }
1151}
1152
1153pub(crate) fn apply_shell_prefix(command: &str, shell_prefix: Option<&str>) -> String {
1154    let trimmed = command.trim();
1155    if trimmed.is_empty() {
1156        return String::new();
1157    }
1158    match shell_prefix {
1159        Some(prefix) => {
1160            let prefix_trimmed = prefix.trim_end();
1161            if trimmed == prefix_trimmed || trimmed.starts_with(prefix) {
1162                return trimmed.to_string();
1163            }
1164            let mut out = String::with_capacity(prefix.len() + trimmed.len());
1165            out.push_str(prefix);
1166            out.push_str(trimmed);
1167            out
1168        }
1169        _ => trimmed.to_string(),
1170    }
1171}
1172
1173fn strip_shell_prefix(command: &str, shell_prefix: Option<&str>) -> String {
1174    let trimmed = command.trim();
1175    if trimmed.is_empty() {
1176        return String::new();
1177    }
1178    match shell_prefix {
1179        Some(prefix) => trimmed
1180            .strip_prefix(prefix)
1181            .map(|rest| rest.trim_start().to_string())
1182            .unwrap_or_else(|| trimmed.to_string()),
1183        None => trimmed.to_string(),
1184    }
1185}
1186
1187fn now_ms() -> i64 {
1188    let now = SystemTime::now()
1189        .duration_since(UNIX_EPOCH)
1190        .unwrap_or_else(|_| Duration::from_secs(0));
1191    now.as_millis() as i64
1192}
1193
1194/// Expands shell-style history references against the provided command list.
1195///
1196/// Supports `!!`, `!-N`, `!N`, and prefix search forms such as `!osp`.
1197pub(crate) fn expand_history(
1198    input: &str,
1199    history: &[String],
1200    shell_prefix: Option<&str>,
1201    strip_prefix: bool,
1202) -> Option<String> {
1203    if !input.starts_with('!') {
1204        return Some(input.to_string());
1205    }
1206
1207    let entries: Vec<(&str, String)> = history
1208        .iter()
1209        .filter(|cmd| command_matches_shell_prefix(cmd, shell_prefix))
1210        .map(|cmd| {
1211            let view = strip_shell_prefix(cmd, shell_prefix);
1212            (cmd.as_str(), view)
1213        })
1214        .collect();
1215
1216    if entries.is_empty() {
1217        return None;
1218    }
1219
1220    let select = |full: &str, view: &str, strip: bool| -> String {
1221        if strip {
1222            view.to_string()
1223        } else {
1224            full.to_string()
1225        }
1226    };
1227
1228    if input == "!!" {
1229        let (full, view) = entries.last()?;
1230        return Some(select(full, view, strip_prefix));
1231    }
1232
1233    if let Some(rest) = input.strip_prefix("!-") {
1234        let idx = rest.parse::<usize>().ok()?;
1235        if idx == 0 || idx > entries.len() {
1236            return None;
1237        }
1238        let (full, view) = entries.get(entries.len() - idx)?;
1239        return Some(select(full, view, strip_prefix));
1240    }
1241
1242    let rest = input.strip_prefix('!')?;
1243    if let Ok(abs_id) = rest.parse::<usize>() {
1244        if abs_id == 0 || abs_id > entries.len() {
1245            return None;
1246        }
1247        let (full, view) = entries.get(abs_id - 1)?;
1248        return Some(select(full, view, strip_prefix));
1249    }
1250
1251    for (full, view) in entries.iter().rev() {
1252        if view.starts_with(rest) {
1253            return Some(select(full, view, strip_prefix));
1254        }
1255    }
1256
1257    None
1258}
1259
1260fn is_excluded_command(command: &str, exclude_patterns: &[String]) -> bool {
1261    let trimmed = command.trim();
1262    if trimmed.is_empty() {
1263        return true;
1264    }
1265    if trimmed.starts_with('!') {
1266        return true;
1267    }
1268    if trimmed.contains("--help") {
1269        return true;
1270    }
1271    exclude_patterns
1272        .iter()
1273        .any(|pattern| matches_pattern(pattern, trimmed))
1274}
1275
1276fn matches_pattern(pattern: &str, command: &str) -> bool {
1277    let pattern = pattern.trim();
1278    if pattern.is_empty() {
1279        return false;
1280    }
1281    if pattern == "*" {
1282        return true;
1283    }
1284    if !pattern.contains('*') {
1285        return pattern == command;
1286    }
1287
1288    let parts: Vec<&str> = pattern.split('*').collect();
1289    let mut cursor = 0usize;
1290
1291    let mut first = true;
1292    for part in &parts {
1293        if part.is_empty() {
1294            continue;
1295        }
1296        if first && !pattern.starts_with('*') {
1297            if !command[cursor..].starts_with(part) {
1298                return false;
1299            }
1300            cursor += part.len();
1301        } else if let Some(pos) = command[cursor..].find(part) {
1302            cursor += pos + part.len();
1303        } else {
1304            return false;
1305        }
1306        first = false;
1307    }
1308
1309    if !pattern.ends_with('*')
1310        && let Some(last) = parts.iter().rev().find(|part| !part.is_empty())
1311        && !command.ends_with(last)
1312    {
1313        return false;
1314    }
1315
1316    true
1317}
1318
1319#[cfg(test)]
1320mod tests;