Skip to main content

osp_cli/repl/
history_store.rs

1use std::fs::File;
2use std::io::{BufRead, BufReader, BufWriter, Write};
3use std::path::{Path, PathBuf};
4use std::sync::{Arc, Mutex, RwLock};
5use std::time::{Duration, SystemTime, UNIX_EPOCH};
6
7use anyhow::Result;
8use reedline::{
9    CommandLineSearch, History, HistoryItem, HistoryItemId, HistorySessionId, ReedlineError,
10    ReedlineErrorVariants, Result as ReedlineResult, SearchDirection, SearchFilter, SearchQuery,
11};
12use serde::{Deserialize, Serialize};
13
14#[derive(Debug, Clone)]
15pub struct HistoryConfig {
16    pub path: Option<PathBuf>,
17    pub max_entries: usize,
18    pub enabled: bool,
19    pub dedupe: bool,
20    pub profile_scoped: bool,
21    pub exclude_patterns: Vec<String>,
22    pub profile: Option<String>,
23    pub terminal: Option<String>,
24    pub shell_context: HistoryShellContext,
25}
26
27impl HistoryConfig {
28    pub fn normalized(mut self) -> Self {
29        self.exclude_patterns =
30            normalize_exclude_patterns(std::mem::take(&mut self.exclude_patterns));
31        self.profile = normalize_identifier(self.profile.take());
32        self.terminal = normalize_identifier(self.terminal.take());
33        self
34    }
35
36    fn persist_enabled(&self) -> bool {
37        self.enabled && self.path.is_some() && self.max_entries > 0
38    }
39}
40
41#[derive(Clone, Default, Debug)]
42pub struct HistoryShellContext {
43    inner: Arc<RwLock<Option<String>>>,
44}
45
46impl HistoryShellContext {
47    pub fn new(prefix: impl Into<String>) -> Self {
48        let context = Self::default();
49        context.set_prefix(prefix);
50        context
51    }
52
53    pub fn set_prefix(&self, prefix: impl Into<String>) {
54        if let Ok(mut guard) = self.inner.write() {
55            *guard = normalize_shell_prefix(prefix.into());
56        }
57    }
58
59    pub fn clear(&self) {
60        if let Ok(mut guard) = self.inner.write() {
61            *guard = None;
62        }
63    }
64
65    pub fn prefix(&self) -> Option<String> {
66        self.inner.read().map(|value| value.clone()).unwrap_or(None)
67    }
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71struct HistoryRecord {
72    id: i64,
73    command_line: String,
74    #[serde(default)]
75    timestamp_ms: Option<i64>,
76    #[serde(default)]
77    duration_ms: Option<i64>,
78    #[serde(default)]
79    exit_status: Option<i64>,
80    #[serde(default)]
81    cwd: Option<String>,
82    #[serde(default)]
83    hostname: Option<String>,
84    #[serde(default)]
85    session_id: Option<i64>,
86    #[serde(default)]
87    profile: Option<String>,
88    #[serde(default)]
89    terminal: Option<String>,
90}
91
92#[derive(Debug, Clone)]
93pub struct HistoryEntry {
94    pub id: i64,
95    pub timestamp_ms: Option<i64>,
96    pub command: String,
97}
98
99#[derive(Clone)]
100pub struct SharedHistory {
101    inner: Arc<Mutex<OspHistoryStore>>,
102}
103
104impl SharedHistory {
105    pub fn new(config: HistoryConfig) -> Result<Self> {
106        Ok(Self {
107            inner: Arc::new(Mutex::new(OspHistoryStore::new(config)?)),
108        })
109    }
110
111    pub fn enabled(&self) -> bool {
112        self.inner
113            .lock()
114            .map(|store| store.history_enabled())
115            .unwrap_or(false)
116    }
117
118    pub fn recent_commands(&self) -> Vec<String> {
119        self.inner
120            .lock()
121            .map(|store| store.recent_commands())
122            .unwrap_or_default()
123    }
124
125    pub fn recent_commands_for(&self, shell_prefix: Option<&str>) -> Vec<String> {
126        self.inner
127            .lock()
128            .map(|store| store.recent_commands_for(shell_prefix))
129            .unwrap_or_default()
130    }
131
132    pub fn list_entries(&self) -> Vec<HistoryEntry> {
133        self.inner
134            .lock()
135            .map(|store| store.list_entries())
136            .unwrap_or_default()
137    }
138
139    pub fn list_entries_for(&self, shell_prefix: Option<&str>) -> Vec<HistoryEntry> {
140        self.inner
141            .lock()
142            .map(|store| store.list_entries_for(shell_prefix))
143            .unwrap_or_default()
144    }
145
146    pub fn prune(&self, keep: usize) -> Result<usize> {
147        let mut guard = self
148            .inner
149            .lock()
150            .map_err(|_| anyhow::anyhow!("history lock poisoned"))?;
151        guard.prune(keep)
152    }
153
154    pub fn prune_for(&self, keep: usize, shell_prefix: Option<&str>) -> Result<usize> {
155        let mut guard = self
156            .inner
157            .lock()
158            .map_err(|_| anyhow::anyhow!("history lock poisoned"))?;
159        guard.prune_for(keep, shell_prefix)
160    }
161
162    pub fn clear_scoped(&self) -> Result<usize> {
163        let mut guard = self
164            .inner
165            .lock()
166            .map_err(|_| anyhow::anyhow!("history lock poisoned"))?;
167        guard.clear_scoped()
168    }
169
170    pub fn clear_for(&self, shell_prefix: Option<&str>) -> Result<usize> {
171        let mut guard = self
172            .inner
173            .lock()
174            .map_err(|_| anyhow::anyhow!("history lock poisoned"))?;
175        guard.clear_for(shell_prefix)
176    }
177
178    pub fn save_command_line(&self, command_line: &str) -> Result<()> {
179        let mut guard = self
180            .inner
181            .lock()
182            .map_err(|_| anyhow::anyhow!("history lock poisoned"))?;
183        let item = HistoryItem::from_command_line(command_line);
184        History::save(&mut *guard, item).map(|_| ())?;
185        Ok(())
186    }
187}
188
189pub struct OspHistoryStore {
190    config: HistoryConfig,
191    records: Vec<HistoryRecord>,
192}
193
194impl OspHistoryStore {
195    pub fn new(config: HistoryConfig) -> Result<Self> {
196        let mut records = Vec::new();
197        if config.persist_enabled()
198            && let Some(path) = &config.path
199        {
200            records = load_records(path);
201        }
202        let mut store = Self { config, records };
203        store.trim_to_capacity();
204        Ok(store)
205    }
206
207    pub fn history_enabled(&self) -> bool {
208        self.config.enabled && self.config.max_entries > 0
209    }
210
211    pub fn recent_commands(&self) -> Vec<String> {
212        self.recent_commands_for(self.shell_prefix().as_deref())
213    }
214
215    pub fn recent_commands_for(&self, shell_prefix: Option<&str>) -> Vec<String> {
216        let shell_prefix = normalize_scope_prefix(shell_prefix);
217        self.records
218            .iter()
219            .filter_map(|record| {
220                self.record_view_if_allowed(record, shell_prefix.as_deref(), true)
221                    .map(|_| record.command_line.clone())
222            })
223            .collect()
224    }
225
226    pub fn list_entries(&self) -> Vec<HistoryEntry> {
227        self.list_entries_for(self.shell_prefix().as_deref())
228    }
229
230    pub fn list_entries_for(&self, shell_prefix: Option<&str>) -> Vec<HistoryEntry> {
231        if !self.history_enabled() {
232            return Vec::new();
233        }
234        let shell_prefix = normalize_scope_prefix(shell_prefix);
235        let mut out = Vec::new();
236        let mut id = 0i64;
237        for record in &self.records {
238            let Some(view) = self.record_view_if_allowed(record, shell_prefix.as_deref(), true)
239            else {
240                continue;
241            };
242            id += 1;
243            out.push(HistoryEntry {
244                id,
245                timestamp_ms: record.timestamp_ms,
246                command: view,
247            });
248        }
249        out
250    }
251
252    pub fn prune(&mut self, keep: usize) -> Result<usize> {
253        let shell_prefix = self.shell_prefix();
254        self.prune_for(keep, shell_prefix.as_deref())
255    }
256
257    pub fn prune_for(&mut self, keep: usize, shell_prefix: Option<&str>) -> Result<usize> {
258        if !self.history_enabled() {
259            return Ok(0);
260        }
261        let shell_prefix = normalize_scope_prefix(shell_prefix);
262        let mut eligible = Vec::new();
263        for (idx, record) in self.records.iter().enumerate() {
264            if self
265                .record_view_if_allowed(record, shell_prefix.as_deref(), true)
266                .is_some()
267            {
268                eligible.push(idx);
269            }
270        }
271
272        if keep == 0 {
273            return self.remove_records(&eligible);
274        }
275
276        if eligible.len() <= keep {
277            return Ok(0);
278        }
279
280        let remove_count = eligible.len() - keep;
281        let to_remove = eligible.into_iter().take(remove_count).collect::<Vec<_>>();
282        self.remove_records(&to_remove)
283    }
284
285    pub fn clear_scoped(&mut self) -> Result<usize> {
286        self.prune(0)
287    }
288
289    pub fn clear_for(&mut self, shell_prefix: Option<&str>) -> Result<usize> {
290        self.prune_for(0, shell_prefix)
291    }
292
293    fn profile_allows(&self, record: &HistoryRecord) -> bool {
294        if !self.config.profile_scoped {
295            return true;
296        }
297        match (self.config.profile.as_deref(), record.profile.as_deref()) {
298            (Some(active), Some(profile)) => active == profile,
299            (Some(_), None) => false,
300            _ => true,
301        }
302    }
303
304    fn shell_prefix(&self) -> Option<String> {
305        self.config.shell_context.prefix()
306    }
307
308    fn shell_allows(&self, record: &HistoryRecord, shell_prefix: Option<&str>) -> bool {
309        command_matches_shell_prefix(&record.command_line, shell_prefix)
310    }
311
312    fn view_command_line(&self, command: &str, shell_prefix: Option<&str>) -> String {
313        strip_shell_prefix(command, shell_prefix)
314    }
315
316    fn record_view_if_allowed(
317        &self,
318        record: &HistoryRecord,
319        shell_prefix: Option<&str>,
320        require_shell: bool,
321    ) -> Option<String> {
322        if !self.profile_allows(record) {
323            return None;
324        }
325        if require_shell && !self.shell_allows(record, shell_prefix) {
326            return None;
327        }
328        let view_command = self.view_command_line(&record.command_line, shell_prefix);
329        if self.is_command_excluded(&view_command) {
330            return None;
331        }
332        Some(view_command)
333    }
334
335    fn is_command_excluded(&self, command: &str) -> bool {
336        is_excluded_command(command, &self.config.exclude_patterns)
337    }
338
339    fn next_id(&self) -> i64 {
340        self.records.len() as i64
341    }
342
343    fn trim_to_capacity(&mut self) {
344        if self.config.max_entries == 0 {
345            self.records.clear();
346            return;
347        }
348        if self.records.len() > self.config.max_entries {
349            let start = self.records.len() - self.config.max_entries;
350            self.records = self.records.split_off(start);
351        }
352        for (idx, record) in self.records.iter_mut().enumerate() {
353            record.id = idx as i64;
354        }
355    }
356
357    fn append_record(&mut self, mut record: HistoryRecord) -> HistoryItemId {
358        record.id = self.next_id();
359        self.records.push(record);
360        self.trim_to_capacity();
361        HistoryItemId::new(self.records.len() as i64 - 1)
362    }
363
364    fn remove_records(&mut self, indices: &[usize]) -> Result<usize> {
365        if indices.is_empty() {
366            return Ok(0);
367        }
368        let mut drop_flags = vec![false; self.records.len()];
369        for idx in indices {
370            if *idx < drop_flags.len() {
371                drop_flags[*idx] = true;
372            }
373        }
374        let mut cursor = 0usize;
375        let removed = drop_flags.iter().filter(|flag| **flag).count();
376        self.records.retain(|_| {
377            let keep = !drop_flags.get(cursor).copied().unwrap_or(false);
378            cursor += 1;
379            keep
380        });
381        self.trim_to_capacity();
382        if let Err(err) = self.write_all() {
383            return Err(err.into());
384        }
385        Ok(removed)
386    }
387
388    fn write_all(&self) -> std::io::Result<()> {
389        if !self.config.persist_enabled() {
390            return Ok(());
391        }
392        let Some(path) = &self.config.path else {
393            return Ok(());
394        };
395        if let Some(parent) = path.parent() {
396            std::fs::create_dir_all(parent)?;
397        }
398        let file = File::create(path)?;
399        let mut writer = BufWriter::new(file);
400        for record in &self.records {
401            let payload = serde_json::to_string(record).map_err(std::io::Error::other)?;
402            writer.write_all(payload.as_bytes())?;
403            writer.write_all(b"\n")?;
404        }
405        writer.flush()
406    }
407
408    fn should_skip_command(&self, command: &str) -> bool {
409        is_excluded_command(command, &self.config.exclude_patterns)
410    }
411
412    fn command_list_for_expansion(&self) -> Vec<String> {
413        self.recent_commands()
414    }
415
416    fn expand_if_needed(&self, command: &str, shell_prefix: Option<&str>) -> Option<String> {
417        if !command.starts_with('!') {
418            return Some(command.to_string());
419        }
420        let history = self.command_list_for_expansion();
421        expand_history(command, &history, shell_prefix, false)
422    }
423
424    fn record_matches_filter(
425        &self,
426        record: &HistoryRecord,
427        filter: &SearchFilter,
428        shell_prefix: Option<&str>,
429    ) -> bool {
430        if !self.profile_allows(record) {
431            return false;
432        }
433        if !self.shell_allows(record, shell_prefix) {
434            return false;
435        }
436        let view_command = self.view_command_line(&record.command_line, shell_prefix);
437        if self.is_command_excluded(&view_command) {
438            return false;
439        }
440        if let Some(search) = &filter.command_line {
441            let matches = match search {
442                CommandLineSearch::Prefix(prefix) => view_command.starts_with(prefix),
443                CommandLineSearch::Substring(substr) => view_command.contains(substr),
444                CommandLineSearch::Exact(exact) => view_command == *exact,
445            };
446            if !matches {
447                return false;
448            }
449        }
450        if let Some(hostname) = &filter.hostname
451            && record.hostname.as_deref() != Some(hostname.as_str())
452        {
453            return false;
454        }
455        if let Some(cwd) = &filter.cwd_exact
456            && record.cwd.as_deref() != Some(cwd.as_str())
457        {
458            return false;
459        }
460        if let Some(prefix) = &filter.cwd_prefix {
461            match record.cwd.as_deref() {
462                Some(value) if value.starts_with(prefix) => {}
463                _ => return false,
464            }
465        }
466        if let Some(exit_successful) = filter.exit_successful {
467            let is_success = record.exit_status == Some(0);
468            if exit_successful != is_success {
469                return false;
470            }
471        }
472        if let Some(session) = filter.session
473            && record.session_id != Some(i64::from(session))
474        {
475            return false;
476        }
477        true
478    }
479
480    fn record_from_item(&self, item: &HistoryItem, command_line: String) -> HistoryRecord {
481        HistoryRecord {
482            id: -1,
483            command_line,
484            timestamp_ms: item.start_timestamp.map(|ts| ts.timestamp_millis()),
485            duration_ms: item.duration.map(|value| value.as_millis() as i64),
486            exit_status: item.exit_status,
487            cwd: item.cwd.clone(),
488            hostname: item.hostname.clone(),
489            session_id: item.session_id.map(i64::from),
490            profile: self.config.profile.clone(),
491            terminal: self.config.terminal.clone(),
492        }
493    }
494
495    fn history_item_from_record(
496        &self,
497        record: &HistoryRecord,
498        shell_prefix: Option<&str>,
499    ) -> HistoryItem {
500        let command_line = self.view_command_line(&record.command_line, shell_prefix);
501        HistoryItem {
502            id: Some(HistoryItemId::new(record.id)),
503            start_timestamp: None,
504            command_line,
505            session_id: None,
506            hostname: record.hostname.clone(),
507            cwd: record.cwd.clone(),
508            duration: record
509                .duration_ms
510                .map(|value| Duration::from_millis(value as u64)),
511            exit_status: record.exit_status,
512            more_info: None,
513        }
514    }
515
516    fn reedline_error(message: &'static str) -> ReedlineError {
517        ReedlineError(ReedlineErrorVariants::OtherHistoryError(message))
518    }
519
520    fn record_matches_query(
521        &self,
522        record: &HistoryRecord,
523        filter: &SearchFilter,
524        start_time_ms: Option<i64>,
525        end_time_ms: Option<i64>,
526        shell_prefix: Option<&str>,
527        skip_command_line: Option<&str>,
528    ) -> bool {
529        if !self.record_matches_filter(record, filter, shell_prefix) {
530            return false;
531        }
532        if let Some(skip) = skip_command_line {
533            let view_command = self.view_command_line(&record.command_line, shell_prefix);
534            if view_command == skip {
535                return false;
536            }
537        }
538        if let Some(start) = start_time_ms {
539            match record.timestamp_ms {
540                Some(value) if value >= start => {}
541                _ => return false,
542            }
543        }
544        if let Some(end) = end_time_ms {
545            match record.timestamp_ms {
546                Some(value) if value <= end => {}
547                _ => return false,
548            }
549        }
550        true
551    }
552}
553
554impl History for OspHistoryStore {
555    fn save(&mut self, h: HistoryItem) -> ReedlineResult<HistoryItem> {
556        if !self.config.enabled || self.config.max_entries == 0 {
557            return Ok(h);
558        }
559
560        let raw = h.command_line.trim();
561        if raw.is_empty() {
562            return Ok(h);
563        }
564
565        let shell_prefix = self.shell_prefix();
566        let Some(expanded) = self.expand_if_needed(raw, shell_prefix.as_deref()) else {
567            return Ok(h);
568        };
569        if self.should_skip_command(&expanded) {
570            return Ok(h);
571        }
572        let expanded_full = apply_shell_prefix(&expanded, shell_prefix.as_deref());
573
574        if self.config.dedupe {
575            let last_match = self.records.iter().rev().find(|record| {
576                self.profile_allows(record) && self.shell_allows(record, shell_prefix.as_deref())
577            });
578            if let Some(last) = last_match
579                && last.command_line == expanded_full
580            {
581                return Ok(h);
582            }
583        }
584
585        let mut record = self.record_from_item(&h, expanded_full);
586        if record.timestamp_ms.is_none() {
587            record.timestamp_ms = Some(now_ms());
588        }
589        let id = self.append_record(record);
590
591        if let Err(err) = self.write_all() {
592            return Err(ReedlineError(ReedlineErrorVariants::IOError(err)));
593        }
594
595        Ok(HistoryItem {
596            id: Some(id),
597            command_line: self.records[id.0 as usize].command_line.clone(),
598            ..h
599        })
600    }
601
602    fn load(&self, id: HistoryItemId) -> ReedlineResult<HistoryItem> {
603        let idx = id.0 as usize;
604        let shell_prefix = self.shell_prefix();
605        let record = self
606            .records
607            .get(idx)
608            .ok_or_else(|| Self::reedline_error("history item not found"))?;
609        Ok(self.history_item_from_record(record, shell_prefix.as_deref()))
610    }
611
612    fn count(&self, query: SearchQuery) -> ReedlineResult<i64> {
613        Ok(self.search(query)?.len() as i64)
614    }
615
616    fn search(&self, query: SearchQuery) -> ReedlineResult<Vec<HistoryItem>> {
617        let (min_id, max_id) = {
618            let start = query.start_id.map(|value| value.0);
619            let end = query.end_id.map(|value| value.0);
620            if let SearchDirection::Backward = query.direction {
621                (end, start)
622            } else {
623                (start, end)
624            }
625        };
626        let min_id = min_id.map(|value| value + 1).unwrap_or(0);
627        let max_id = max_id
628            .map(|value| value - 1)
629            .unwrap_or(self.records.len().saturating_sub(1) as i64);
630
631        if self.records.is_empty() || max_id < 0 || min_id > max_id {
632            return Ok(Vec::new());
633        }
634
635        let intrinsic_limit = max_id - min_id + 1;
636        let limit = query
637            .limit
638            .map(|value| std::cmp::min(intrinsic_limit, value) as usize)
639            .unwrap_or(intrinsic_limit as usize);
640
641        let start_time_ms = query.start_time.map(|ts| ts.timestamp_millis());
642        let end_time_ms = query.end_time.map(|ts| ts.timestamp_millis());
643        let shell_prefix = self.shell_prefix();
644
645        let mut results = Vec::new();
646        let iter = self
647            .records
648            .iter()
649            .enumerate()
650            .skip(min_id as usize)
651            .take(intrinsic_limit as usize);
652        let skip_command_line = query
653            .start_id
654            .and_then(|id| self.records.get(id.0 as usize))
655            .map(|record| self.view_command_line(&record.command_line, shell_prefix.as_deref()));
656
657        if let SearchDirection::Backward = query.direction {
658            for (idx, record) in iter.rev() {
659                if results.len() >= limit {
660                    break;
661                }
662                if !self.record_matches_query(
663                    record,
664                    &query.filter,
665                    start_time_ms,
666                    end_time_ms,
667                    shell_prefix.as_deref(),
668                    skip_command_line.as_deref(),
669                ) {
670                    continue;
671                }
672                let mut item = self.history_item_from_record(record, shell_prefix.as_deref());
673                item.id = Some(HistoryItemId::new(idx as i64));
674                results.push(item);
675            }
676        } else {
677            for (idx, record) in iter {
678                if results.len() >= limit {
679                    break;
680                }
681                if !self.record_matches_query(
682                    record,
683                    &query.filter,
684                    start_time_ms,
685                    end_time_ms,
686                    shell_prefix.as_deref(),
687                    skip_command_line.as_deref(),
688                ) {
689                    continue;
690                }
691                let mut item = self.history_item_from_record(record, shell_prefix.as_deref());
692                item.id = Some(HistoryItemId::new(idx as i64));
693                results.push(item);
694            }
695        }
696
697        Ok(results)
698    }
699
700    fn update(
701        &mut self,
702        _id: HistoryItemId,
703        _updater: &dyn Fn(HistoryItem) -> HistoryItem,
704    ) -> ReedlineResult<()> {
705        Err(ReedlineError(
706            ReedlineErrorVariants::HistoryFeatureUnsupported {
707                history: "OspHistoryStore",
708                feature: "updating entries",
709            },
710        ))
711    }
712
713    fn clear(&mut self) -> ReedlineResult<()> {
714        self.records.clear();
715        if let Some(path) = &self.config.path {
716            let _ = std::fs::remove_file(path);
717        }
718        Ok(())
719    }
720
721    fn delete(&mut self, _h: HistoryItemId) -> ReedlineResult<()> {
722        Err(ReedlineError(
723            ReedlineErrorVariants::HistoryFeatureUnsupported {
724                history: "OspHistoryStore",
725                feature: "removing entries",
726            },
727        ))
728    }
729
730    fn sync(&mut self) -> std::io::Result<()> {
731        self.write_all()
732    }
733
734    fn session(&self) -> Option<HistorySessionId> {
735        None
736    }
737}
738
739impl History for SharedHistory {
740    fn save(&mut self, h: HistoryItem) -> ReedlineResult<HistoryItem> {
741        let mut guard = self
742            .inner
743            .lock()
744            .map_err(|_| OspHistoryStore::reedline_error("history lock poisoned"))?;
745        History::save(&mut *guard, h)
746    }
747
748    fn load(&self, id: HistoryItemId) -> ReedlineResult<HistoryItem> {
749        let guard = self
750            .inner
751            .lock()
752            .map_err(|_| OspHistoryStore::reedline_error("history lock poisoned"))?;
753        History::load(&*guard, id)
754    }
755
756    fn count(&self, query: SearchQuery) -> ReedlineResult<i64> {
757        let guard = self
758            .inner
759            .lock()
760            .map_err(|_| OspHistoryStore::reedline_error("history lock poisoned"))?;
761        History::count(&*guard, query)
762    }
763
764    fn search(&self, query: SearchQuery) -> ReedlineResult<Vec<HistoryItem>> {
765        let guard = self
766            .inner
767            .lock()
768            .map_err(|_| OspHistoryStore::reedline_error("history lock poisoned"))?;
769        History::search(&*guard, query)
770    }
771
772    fn update(
773        &mut self,
774        id: HistoryItemId,
775        updater: &dyn Fn(HistoryItem) -> HistoryItem,
776    ) -> ReedlineResult<()> {
777        let mut guard = self
778            .inner
779            .lock()
780            .map_err(|_| OspHistoryStore::reedline_error("history lock poisoned"))?;
781        History::update(&mut *guard, id, updater)
782    }
783
784    fn clear(&mut self) -> ReedlineResult<()> {
785        let mut guard = self
786            .inner
787            .lock()
788            .map_err(|_| OspHistoryStore::reedline_error("history lock poisoned"))?;
789        History::clear(&mut *guard)
790    }
791
792    fn delete(&mut self, h: HistoryItemId) -> ReedlineResult<()> {
793        let mut guard = self
794            .inner
795            .lock()
796            .map_err(|_| OspHistoryStore::reedline_error("history lock poisoned"))?;
797        History::delete(&mut *guard, h)
798    }
799
800    fn sync(&mut self) -> std::io::Result<()> {
801        let mut guard = self
802            .inner
803            .lock()
804            .map_err(|_| std::io::Error::other("history lock poisoned"))?;
805        History::sync(&mut *guard)
806    }
807
808    fn session(&self) -> Option<HistorySessionId> {
809        let guard = self.inner.lock().ok()?;
810        History::session(&*guard)
811    }
812}
813
814fn load_records(path: &Path) -> Vec<HistoryRecord> {
815    if !path.exists() {
816        return Vec::new();
817    }
818    let file = match File::open(path) {
819        Ok(file) => file,
820        Err(_) => return Vec::new(),
821    };
822    let reader = BufReader::new(file);
823    let mut records = Vec::new();
824    for line in reader.lines().map_while(Result::ok) {
825        let trimmed = line.trim();
826        if trimmed.is_empty() {
827            continue;
828        }
829        let record: HistoryRecord = match serde_json::from_str(trimmed) {
830            Ok(record) => record,
831            Err(_) => continue,
832        };
833        if record.command_line.trim().is_empty() {
834            continue;
835        }
836        records.push(record);
837    }
838    records
839}
840
841fn normalize_identifier(value: Option<String>) -> Option<String> {
842    value
843        .map(|value| value.trim().to_ascii_lowercase())
844        .filter(|value| !value.is_empty())
845}
846
847fn normalize_exclude_patterns(patterns: Vec<String>) -> Vec<String> {
848    patterns
849        .into_iter()
850        .map(|pattern| pattern.trim().to_string())
851        .filter(|pattern| !pattern.is_empty())
852        .collect()
853}
854
855fn normalize_shell_prefix(value: String) -> Option<String> {
856    let trimmed = value.trim();
857    if trimmed.is_empty() {
858        return None;
859    }
860    let mut out = trimmed.to_string();
861    if !out.ends_with(' ') {
862        out.push(' ');
863    }
864    Some(out)
865}
866
867fn normalize_scope_prefix(shell_prefix: Option<&str>) -> Option<String> {
868    shell_prefix.and_then(|value| normalize_shell_prefix(value.to_string()))
869}
870
871fn command_matches_shell_prefix(command: &str, shell_prefix: Option<&str>) -> bool {
872    match shell_prefix {
873        Some(prefix) => command.starts_with(prefix),
874        None => true,
875    }
876}
877
878pub(crate) fn apply_shell_prefix(command: &str, shell_prefix: Option<&str>) -> String {
879    let trimmed = command.trim();
880    if trimmed.is_empty() {
881        return String::new();
882    }
883    match shell_prefix {
884        Some(prefix) => {
885            let prefix_trimmed = prefix.trim_end();
886            if trimmed == prefix_trimmed || trimmed.starts_with(prefix) {
887                return trimmed.to_string();
888            }
889            let mut out = String::with_capacity(prefix.len() + trimmed.len());
890            out.push_str(prefix);
891            out.push_str(trimmed);
892            out
893        }
894        _ => trimmed.to_string(),
895    }
896}
897
898fn strip_shell_prefix(command: &str, shell_prefix: Option<&str>) -> String {
899    let trimmed = command.trim();
900    if trimmed.is_empty() {
901        return String::new();
902    }
903    match shell_prefix {
904        Some(prefix) => trimmed
905            .strip_prefix(prefix)
906            .map(|rest| rest.trim_start().to_string())
907            .unwrap_or_else(|| trimmed.to_string()),
908        None => trimmed.to_string(),
909    }
910}
911
912fn now_ms() -> i64 {
913    let now = SystemTime::now()
914        .duration_since(UNIX_EPOCH)
915        .unwrap_or_else(|_| Duration::from_secs(0));
916    now.as_millis() as i64
917}
918
919pub fn expand_history(
920    input: &str,
921    history: &[String],
922    shell_prefix: Option<&str>,
923    strip_prefix: bool,
924) -> Option<String> {
925    if !input.starts_with('!') {
926        return Some(input.to_string());
927    }
928
929    let entries: Vec<(&str, String)> = history
930        .iter()
931        .filter(|cmd| command_matches_shell_prefix(cmd, shell_prefix))
932        .map(|cmd| {
933            let view = strip_shell_prefix(cmd, shell_prefix);
934            (cmd.as_str(), view)
935        })
936        .collect();
937
938    if entries.is_empty() {
939        return None;
940    }
941
942    let select = |full: &str, view: &str, strip: bool| -> String {
943        if strip {
944            view.to_string()
945        } else {
946            full.to_string()
947        }
948    };
949
950    if input == "!!" {
951        let (full, view) = entries.last()?;
952        return Some(select(full, view, strip_prefix));
953    }
954
955    if let Some(rest) = input.strip_prefix("!-") {
956        let idx = rest.parse::<usize>().ok()?;
957        if idx == 0 || idx > entries.len() {
958            return None;
959        }
960        let (full, view) = entries.get(entries.len() - idx)?;
961        return Some(select(full, view, strip_prefix));
962    }
963
964    let rest = input.strip_prefix('!')?;
965    if let Ok(abs_id) = rest.parse::<usize>() {
966        if abs_id == 0 || abs_id > entries.len() {
967            return None;
968        }
969        let (full, view) = entries.get(abs_id - 1)?;
970        return Some(select(full, view, strip_prefix));
971    }
972
973    for (full, view) in entries.iter().rev() {
974        if view.starts_with(rest) {
975            return Some(select(full, view, strip_prefix));
976        }
977    }
978
979    None
980}
981
982fn is_excluded_command(command: &str, exclude_patterns: &[String]) -> bool {
983    let trimmed = command.trim();
984    if trimmed.is_empty() {
985        return true;
986    }
987    if trimmed.starts_with('!') {
988        return true;
989    }
990    if trimmed.contains("--help") {
991        return true;
992    }
993    exclude_patterns
994        .iter()
995        .any(|pattern| matches_pattern(pattern, trimmed))
996}
997
998fn matches_pattern(pattern: &str, command: &str) -> bool {
999    let pattern = pattern.trim();
1000    if pattern.is_empty() {
1001        return false;
1002    }
1003    if pattern == "*" {
1004        return true;
1005    }
1006    if !pattern.contains('*') {
1007        return pattern == command;
1008    }
1009
1010    let parts: Vec<&str> = pattern.split('*').collect();
1011    let mut cursor = 0usize;
1012
1013    let mut first = true;
1014    for part in &parts {
1015        if part.is_empty() {
1016            continue;
1017        }
1018        if first && !pattern.starts_with('*') {
1019            if !command[cursor..].starts_with(part) {
1020                return false;
1021            }
1022            cursor += part.len();
1023        } else if let Some(pos) = command[cursor..].find(part) {
1024            cursor += pos + part.len();
1025        } else {
1026            return false;
1027        }
1028        first = false;
1029    }
1030
1031    if !pattern.ends_with('*')
1032        && let Some(last) = parts.iter().rev().find(|part| !part.is_empty())
1033        && !command.ends_with(last)
1034    {
1035        return false;
1036    }
1037
1038    true
1039}
1040
1041#[cfg(test)]
1042mod tests {
1043    use super::*;
1044    use chrono::{TimeZone, Utc};
1045
1046    #[test]
1047    fn wildcard_matching_handles_prefix_and_infix() {
1048        assert!(matches_pattern("ldap user *", "ldap user bob"));
1049        assert!(matches_pattern("*token*", "auth token read"));
1050        assert!(!matches_pattern("auth", "auth token"));
1051        assert!(matches_pattern("auth*", "auth token"));
1052        assert!(matches_pattern("*user", "ldap user"));
1053        assert!(!matches_pattern("*user", "ldap user bob"));
1054    }
1055
1056    #[test]
1057    fn excluded_commands_respect_prefixes_and_patterns() {
1058        let excludes = vec![
1059            "help".to_string(),
1060            "exit".to_string(),
1061            "quit".to_string(),
1062            "history list".to_string(),
1063        ];
1064        assert!(is_excluded_command("help", &excludes));
1065        assert!(is_excluded_command("history list", &excludes));
1066        assert!(!is_excluded_command("history prune 10", &[]));
1067        assert!(is_excluded_command("ldap user --help", &[]));
1068        assert!(is_excluded_command(
1069            "login oistes",
1070            &[String::from("login *")]
1071        ));
1072    }
1073
1074    #[test]
1075    fn list_entries_filters_shell_and_excludes() {
1076        let shell = HistoryShellContext::new("ldap");
1077        let config = HistoryConfig {
1078            path: None,
1079            max_entries: 10,
1080            enabled: true,
1081            dedupe: false,
1082            profile_scoped: false,
1083            exclude_patterns: vec!["user *".to_string()],
1084            profile: None,
1085            terminal: None,
1086            shell_context: shell,
1087        }
1088        .normalized();
1089        let mut store = OspHistoryStore::new(config).expect("history store should init");
1090        let _ = History::save(
1091            &mut store,
1092            HistoryItem::from_command_line("ldap user alice"),
1093        )
1094        .expect("save should succeed");
1095        let _ = History::save(
1096            &mut store,
1097            HistoryItem::from_command_line("ldap netgroup ucore"),
1098        )
1099        .expect("save should succeed");
1100        let _ = History::save(&mut store, HistoryItem::from_command_line("mreg host a"))
1101            .expect("save should succeed");
1102
1103        let entries = store.list_entries();
1104        assert_eq!(entries.len(), 2);
1105        assert_eq!(entries[0].command, "netgroup ucore");
1106        assert_eq!(entries[1].command, "mreg host a");
1107    }
1108
1109    #[test]
1110    fn list_entries_tracks_live_shell_context_updates() {
1111        let shell = HistoryShellContext::default();
1112        let config = HistoryConfig {
1113            path: None,
1114            max_entries: 10,
1115            enabled: true,
1116            dedupe: false,
1117            profile_scoped: false,
1118            exclude_patterns: Vec::new(),
1119            profile: None,
1120            terminal: None,
1121            shell_context: shell.clone(),
1122        }
1123        .normalized();
1124        let mut store = OspHistoryStore::new(config).expect("history store should init");
1125        let _ = History::save(
1126            &mut store,
1127            HistoryItem::from_command_line("ldap user alice"),
1128        )
1129        .expect("save should succeed");
1130        let _ = History::save(&mut store, HistoryItem::from_command_line("mreg host a"))
1131            .expect("save should succeed");
1132
1133        shell.set_prefix("ldap");
1134        let ldap_entries = store.list_entries();
1135        assert_eq!(ldap_entries.len(), 1);
1136        assert_eq!(ldap_entries[0].command, "user alice");
1137
1138        shell.set_prefix("mreg");
1139        let mreg_entries = store.list_entries();
1140        assert_eq!(mreg_entries.len(), 1);
1141        assert_eq!(mreg_entries[0].command, "host a");
1142
1143        shell.clear();
1144        let root_entries = store.list_entries();
1145        assert_eq!(root_entries.len(), 2);
1146    }
1147
1148    #[test]
1149    fn explicit_scope_queries_override_live_shell_context() {
1150        let shell = HistoryShellContext::default();
1151        let config = HistoryConfig {
1152            path: None,
1153            max_entries: 10,
1154            enabled: true,
1155            dedupe: false,
1156            profile_scoped: false,
1157            exclude_patterns: Vec::new(),
1158            profile: None,
1159            terminal: None,
1160            shell_context: shell.clone(),
1161        }
1162        .normalized();
1163        let mut store = OspHistoryStore::new(config).expect("history store should init");
1164        let _ = History::save(
1165            &mut store,
1166            HistoryItem::from_command_line("ldap user alice"),
1167        )
1168        .expect("save should succeed");
1169        let _ = History::save(&mut store, HistoryItem::from_command_line("mreg host a"))
1170            .expect("save should succeed");
1171
1172        shell.set_prefix("ldap");
1173        let mreg_entries = store.list_entries_for(Some("mreg"));
1174        assert_eq!(mreg_entries.len(), 1);
1175        assert_eq!(mreg_entries[0].command, "host a");
1176
1177        let removed = store
1178            .prune_for(0, Some("mreg"))
1179            .expect("prune should succeed");
1180        assert_eq!(removed, 1);
1181
1182        let root_entries = store.list_entries_for(None);
1183        assert_eq!(root_entries.len(), 1);
1184        assert_eq!(root_entries[0].command, "ldap user alice");
1185    }
1186
1187    #[test]
1188    fn save_expands_history_and_dedupes_with_shell_scope() {
1189        let shell = HistoryShellContext::new("ldap");
1190        let config = HistoryConfig {
1191            path: None,
1192            max_entries: 10,
1193            enabled: true,
1194            dedupe: true,
1195            profile_scoped: false,
1196            exclude_patterns: Vec::new(),
1197            profile: None,
1198            terminal: None,
1199            shell_context: shell,
1200        }
1201        .normalized();
1202        let mut store = OspHistoryStore::new(config).expect("history store should init");
1203
1204        let first = History::save(&mut store, HistoryItem::from_command_line("user alice"))
1205            .expect("save should succeed");
1206        assert_eq!(first.command_line, "ldap user alice");
1207
1208        let duplicate = History::save(&mut store, HistoryItem::from_command_line("!!"))
1209            .expect("history expansion should succeed");
1210        assert_eq!(duplicate.command_line, "!!");
1211        assert_eq!(store.list_entries().len(), 1);
1212
1213        let second = History::save(&mut store, HistoryItem::from_command_line("netgroup ops"))
1214            .expect("save should succeed");
1215        assert_eq!(second.command_line, "ldap netgroup ops");
1216
1217        let recent = store.recent_commands();
1218        assert_eq!(recent, vec!["ldap user alice", "ldap netgroup ops"]);
1219        let visible = store.list_entries();
1220        assert_eq!(visible[0].command, "user alice");
1221        assert_eq!(visible[1].command, "netgroup ops");
1222    }
1223
1224    #[test]
1225    fn search_respects_filters_direction_bounds_and_skip_logic() {
1226        let config = HistoryConfig {
1227            path: None,
1228            max_entries: 10,
1229            enabled: true,
1230            dedupe: false,
1231            profile_scoped: false,
1232            exclude_patterns: Vec::new(),
1233            profile: None,
1234            terminal: None,
1235            shell_context: HistoryShellContext::default(),
1236        }
1237        .normalized();
1238        let mut store = OspHistoryStore::new(config).expect("history store should init");
1239
1240        let mut first = HistoryItem::from_command_line("ldap user alice");
1241        first.cwd = Some("/srv/ldap".to_string());
1242        first.hostname = Some("ops-a".to_string());
1243        first.exit_status = Some(0);
1244        first.start_timestamp = Some(Utc.timestamp_millis_opt(1_000).single().unwrap());
1245        History::save(&mut store, first).expect("save should succeed");
1246
1247        let mut second = HistoryItem::from_command_line("ldap user bob");
1248        second.cwd = Some("/srv/ldap/cache".to_string());
1249        second.hostname = Some("ops-b".to_string());
1250        second.exit_status = Some(1);
1251        second.start_timestamp = Some(Utc.timestamp_millis_opt(2_000).single().unwrap());
1252        History::save(&mut store, second).expect("save should succeed");
1253
1254        let mut third = HistoryItem::from_command_line("mreg host a");
1255        third.cwd = Some("/srv/mreg".to_string());
1256        third.hostname = Some("ops-a".to_string());
1257        third.exit_status = Some(0);
1258        third.start_timestamp = Some(Utc.timestamp_millis_opt(3_000).single().unwrap());
1259        History::save(&mut store, third).expect("save should succeed");
1260
1261        let mut filter = SearchFilter::anything(None);
1262        filter.command_line = Some(CommandLineSearch::Prefix("ldap".to_string()));
1263        filter.cwd_prefix = Some("/srv/ldap".to_string());
1264        filter.exit_successful = Some(true);
1265        filter.hostname = Some("ops-a".to_string());
1266
1267        let forward = SearchQuery {
1268            direction: SearchDirection::Forward,
1269            start_time: Some(Utc.timestamp_millis_opt(500).single().unwrap()),
1270            end_time: Some(Utc.timestamp_millis_opt(1_500).single().unwrap()),
1271            start_id: None,
1272            end_id: Some(HistoryItemId::new(2)),
1273            limit: Some(5),
1274            filter,
1275        };
1276        let results = store.search(forward).expect("search should succeed");
1277        assert_eq!(results.len(), 1);
1278        assert_eq!(results[0].command_line, "ldap user alice");
1279
1280        let mut backward = SearchQuery::everything(SearchDirection::Backward, None);
1281        backward.start_id = Some(HistoryItemId::new(1));
1282        backward.limit = Some(2);
1283        let results = store.search(backward).expect("search should succeed");
1284        let commands = results
1285            .iter()
1286            .map(|item| item.command_line.as_str())
1287            .collect::<Vec<_>>();
1288        assert_eq!(commands, vec!["ldap user alice"]);
1289        assert_eq!(
1290            store
1291                .count(SearchQuery::everything(SearchDirection::Forward, None))
1292                .expect("count should succeed"),
1293            3
1294        );
1295    }
1296
1297    #[test]
1298    fn persisted_records_skip_invalid_lines_and_trim_to_capacity() {
1299        let temp_dir = make_temp_dir("osp-repl-history-load");
1300        let path = temp_dir.join("history.jsonl");
1301        std::fs::write(
1302            &path,
1303            concat!(
1304                "\n",
1305                "{\"id\":5,\"command_line\":\"first\",\"timestamp_ms\":10}\n",
1306                "not-json\n",
1307                "{\"id\":6,\"command_line\":\"   \",\"timestamp_ms\":20}\n",
1308                "{\"id\":7,\"command_line\":\"second\",\"timestamp_ms\":30}\n"
1309            ),
1310        )
1311        .expect("history fixture should be written");
1312
1313        let store = OspHistoryStore::new(
1314            HistoryConfig {
1315                path: Some(path),
1316                max_entries: 1,
1317                enabled: true,
1318                dedupe: false,
1319                profile_scoped: false,
1320                exclude_patterns: Vec::new(),
1321                profile: None,
1322                terminal: None,
1323                shell_context: HistoryShellContext::default(),
1324            }
1325            .normalized(),
1326        )
1327        .expect("history store should init");
1328
1329        let entries = store.list_entries_for(None);
1330        assert_eq!(entries.len(), 1);
1331        assert_eq!(entries[0].id, 1);
1332        assert_eq!(entries[0].command, "second");
1333    }
1334
1335    #[test]
1336    fn shared_history_supports_save_load_prune_clear_and_sync() {
1337        let temp_dir = make_temp_dir("osp-repl-shared-history");
1338        let path = temp_dir.join("history.jsonl");
1339        let mut history = SharedHistory::new(
1340            HistoryConfig {
1341                path: Some(path.clone()),
1342                max_entries: 8,
1343                enabled: true,
1344                dedupe: false,
1345                profile_scoped: false,
1346                exclude_patterns: Vec::new(),
1347                profile: None,
1348                terminal: None,
1349                shell_context: HistoryShellContext::default(),
1350            }
1351            .normalized(),
1352        )
1353        .expect("shared history should init");
1354
1355        history
1356            .save_command_line("config show")
1357            .expect("save should succeed");
1358        history
1359            .save_command_line("config get ui.format")
1360            .expect("save should succeed");
1361        assert!(history.enabled());
1362        assert_eq!(history.recent_commands().len(), 2);
1363        assert_eq!(
1364            history
1365                .load(HistoryItemId::new(0))
1366                .expect("load should succeed")
1367                .command_line,
1368            "config show"
1369        );
1370
1371        assert_eq!(history.prune(1).expect("prune should succeed"), 1);
1372        assert_eq!(history.list_entries().len(), 1);
1373        history.sync().expect("sync should succeed");
1374        assert!(path.exists());
1375        assert_eq!(history.clear_for(None).expect("clear should succeed"), 1);
1376        assert!(history.list_entries().is_empty());
1377        History::clear(&mut history).expect("clear should succeed");
1378        assert!(!path.exists());
1379    }
1380
1381    #[test]
1382    fn shell_prefix_helpers_normalize_and_round_trip_commands() {
1383        assert_eq!(
1384            normalize_shell_prefix(" ldap ".to_string()),
1385            Some("ldap ".to_string())
1386        );
1387        assert_eq!(
1388            normalize_scope_prefix(Some("ldap")),
1389            Some("ldap ".to_string())
1390        );
1391        assert!(command_matches_shell_prefix(
1392            "ldap user alice",
1393            Some("ldap ")
1394        ));
1395        assert_eq!(
1396            apply_shell_prefix("user alice", Some("ldap ")),
1397            "ldap user alice"
1398        );
1399        assert_eq!(
1400            apply_shell_prefix("ldap user alice", Some("ldap ")),
1401            "ldap user alice"
1402        );
1403        assert_eq!(
1404            strip_shell_prefix("ldap user alice", Some("ldap ")),
1405            "user alice"
1406        );
1407    }
1408
1409    #[test]
1410    fn unsupported_history_mutations_surface_feature_errors() {
1411        let mut store = OspHistoryStore::new(
1412            HistoryConfig {
1413                path: None,
1414                max_entries: 4,
1415                enabled: true,
1416                dedupe: false,
1417                profile_scoped: false,
1418                exclude_patterns: Vec::new(),
1419                profile: None,
1420                terminal: None,
1421                shell_context: HistoryShellContext::default(),
1422            }
1423            .normalized(),
1424        )
1425        .expect("history store should init");
1426
1427        let update_err = store
1428            .update(HistoryItemId::new(0), &|item| item)
1429            .expect_err("update should stay unsupported");
1430        let delete_err = store
1431            .delete(HistoryItemId::new(0))
1432            .expect_err("delete should stay unsupported");
1433
1434        assert!(update_err.to_string().contains("updating entries"));
1435        assert!(delete_err.to_string().contains("removing entries"));
1436        assert_eq!(store.session(), None);
1437    }
1438
1439    #[test]
1440    fn load_missing_history_item_returns_not_found_error() {
1441        let store = OspHistoryStore::new(
1442            HistoryConfig {
1443                path: None,
1444                max_entries: 4,
1445                enabled: true,
1446                dedupe: false,
1447                profile_scoped: false,
1448                exclude_patterns: Vec::new(),
1449                profile: None,
1450                terminal: None,
1451                shell_context: HistoryShellContext::default(),
1452            }
1453            .normalized(),
1454        )
1455        .expect("history store should init");
1456
1457        let err = store
1458            .load(HistoryItemId::new(7))
1459            .expect_err("missing entry should fail");
1460        assert!(err.to_string().contains("history item not found"));
1461    }
1462
1463    #[test]
1464    fn disabled_history_returns_original_item_without_persisting_records() {
1465        let mut store = OspHistoryStore::new(
1466            HistoryConfig {
1467                path: None,
1468                max_entries: 10,
1469                enabled: false,
1470                dedupe: true,
1471                profile_scoped: false,
1472                exclude_patterns: Vec::new(),
1473                profile: None,
1474                terminal: None,
1475                shell_context: HistoryShellContext::default(),
1476            }
1477            .normalized(),
1478        )
1479        .expect("history store should init");
1480
1481        let item = History::save(
1482            &mut store,
1483            HistoryItem::from_command_line("ldap user alice"),
1484        )
1485        .expect("disabled history should be a no-op");
1486
1487        assert_eq!(item.command_line, "ldap user alice");
1488        assert!(store.list_entries().is_empty());
1489        assert!(store.recent_commands().is_empty());
1490    }
1491
1492    fn make_temp_dir(prefix: &str) -> PathBuf {
1493        let mut dir = std::env::temp_dir();
1494        let nonce = SystemTime::now()
1495            .duration_since(UNIX_EPOCH)
1496            .expect("time should be valid")
1497            .as_nanos();
1498        dir.push(format!("{prefix}-{nonce}"));
1499        std::fs::create_dir_all(&dir).expect("temp dir should be created");
1500        dir
1501    }
1502}