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