1use 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#[derive(Debug, Clone)]
35#[non_exhaustive]
36pub struct HistoryConfig {
37 pub path: Option<PathBuf>,
39 pub max_entries: usize,
41 pub enabled: bool,
43 pub dedupe: bool,
45 pub profile_scoped: bool,
47 pub exclude_patterns: Vec<String>,
49 pub profile: Option<String>,
51 pub terminal: Option<String>,
53 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 pub fn builder() -> HistoryConfigBuilder {
93 HistoryConfigBuilder::new()
94 }
95
96 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#[derive(Debug, Clone, Default)]
112pub struct HistoryConfigBuilder {
113 config: HistoryConfig,
114}
115
116impl HistoryConfigBuilder {
117 pub fn new() -> Self {
119 Self {
120 config: HistoryConfig::default(),
121 }
122 }
123
124 pub fn with_path(mut self, path: Option<PathBuf>) -> Self {
126 self.config.path = path;
127 self
128 }
129
130 pub fn with_max_entries(mut self, max_entries: usize) -> Self {
132 self.config.max_entries = max_entries;
133 self
134 }
135
136 pub fn with_enabled(mut self, enabled: bool) -> Self {
138 self.config.enabled = enabled;
139 self
140 }
141
142 pub fn with_dedupe(mut self, dedupe: bool) -> Self {
144 self.config.dedupe = dedupe;
145 self
146 }
147
148 pub fn with_profile_scoped(mut self, profile_scoped: bool) -> Self {
150 self.config.profile_scoped = profile_scoped;
151 self
152 }
153
154 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 pub fn with_profile(mut self, profile: Option<String>) -> Self {
166 self.config.profile = profile;
167 self
168 }
169
170 pub fn with_terminal(mut self, terminal: Option<String>) -> Self {
172 self.config.terminal = terminal;
173 self
174 }
175
176 pub fn with_shell_context(mut self, shell_context: HistoryShellContext) -> Self {
178 self.config.shell_context = shell_context;
179 self
180 }
181
182 pub fn build(self) -> HistoryConfig {
184 self.config.normalized()
185 }
186}
187
188#[derive(Clone, Default, Debug)]
190pub struct HistoryShellContext {
191 inner: Arc<RwLock<Option<String>>>,
192}
193
194impl HistoryShellContext {
195 pub fn new(prefix: impl Into<String>) -> Self {
197 let context = Self::default();
198 context.set_prefix(prefix);
199 context
200 }
201
202 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 pub fn clear(&self) {
211 if let Ok(mut guard) = self.inner.write() {
212 *guard = None;
213 }
214 }
215
216 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#[derive(Debug, Clone)]
246pub struct HistoryEntry {
247 pub id: i64,
249 pub timestamp_ms: Option<i64>,
251 pub command: String,
253}
254
255#[derive(Clone)]
260pub struct SharedHistory {
261 inner: Arc<Mutex<OspHistoryStore>>,
262}
263
264impl SharedHistory {
265 pub fn new(config: HistoryConfig) -> Result<Self> {
267 Ok(Self {
268 inner: Arc::new(Mutex::new(OspHistoryStore::new(config)?)),
269 })
270 }
271
272 pub fn enabled(&self) -> bool {
274 self.inner
275 .lock()
276 .map(|store| store.history_enabled())
277 .unwrap_or(false)
278 }
279
280 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 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 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 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 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 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 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 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 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
377pub(crate) struct OspHistoryStore {
383 config: HistoryConfig,
384 records: Vec<HistoryRecord>,
385}
386
387impl OspHistoryStore {
388 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 pub fn history_enabled(&self) -> bool {
408 self.config.enabled && self.config.max_entries > 0
409 }
410
411 pub fn recent_commands(&self) -> Vec<String> {
414 self.recent_commands_for(self.shell_prefix().as_deref())
415 }
416
417 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 pub fn list_entries(&self) -> Vec<HistoryEntry> {
435 self.list_entries_for(self.shell_prefix().as_deref())
436 }
437
438 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 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 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 pub fn clear_scoped(&mut self) -> Result<usize> {
509 self.prune(0)
510 }
511
512 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
1145pub(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;