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