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]
36#[must_use]
37pub struct HistoryConfig {
38 pub path: Option<PathBuf>,
40 pub max_entries: usize,
42 pub enabled: bool,
44 pub dedupe: bool,
46 pub profile_scoped: bool,
48 pub exclude_patterns: Vec<String>,
50 pub profile: Option<String>,
52 pub terminal: Option<String>,
54 pub shell_context: HistoryShellContext,
56}
57
58impl Default for HistoryConfig {
59 fn default() -> Self {
60 Self {
61 path: None,
62 max_entries: 1_000,
63 enabled: true,
64 dedupe: true,
65 profile_scoped: true,
66 exclude_patterns: Vec::new(),
67 profile: None,
68 terminal: None,
69 shell_context: HistoryShellContext::default(),
70 }
71 }
72}
73
74impl HistoryConfig {
75 pub fn builder() -> HistoryConfigBuilder {
94 HistoryConfigBuilder::new()
95 }
96
97 pub fn normalized(mut self) -> Self {
99 self.exclude_patterns =
100 normalize_exclude_patterns(std::mem::take(&mut self.exclude_patterns));
101 self.profile = normalize_identifier(self.profile.take());
102 self.terminal = normalize_identifier(self.terminal.take());
103 self
104 }
105
106 fn persist_enabled(&self) -> bool {
107 self.enabled && self.path.is_some() && self.max_entries > 0
108 }
109}
110
111#[derive(Debug, Clone, Default)]
113#[must_use]
114pub struct HistoryConfigBuilder {
115 config: HistoryConfig,
116}
117
118impl HistoryConfigBuilder {
119 pub fn new() -> Self {
121 Self {
122 config: HistoryConfig::default(),
123 }
124 }
125
126 pub fn with_path(mut self, path: Option<PathBuf>) -> Self {
130 self.config.path = path;
131 self
132 }
133
134 pub fn with_max_entries(mut self, max_entries: usize) -> Self {
138 self.config.max_entries = max_entries;
139 self
140 }
141
142 pub fn with_enabled(mut self, enabled: bool) -> Self {
146 self.config.enabled = enabled;
147 self
148 }
149
150 pub fn with_dedupe(mut self, dedupe: bool) -> Self {
154 self.config.dedupe = dedupe;
155 self
156 }
157
158 pub fn with_profile_scoped(mut self, profile_scoped: bool) -> Self {
162 self.config.profile_scoped = profile_scoped;
163 self
164 }
165
166 pub fn with_exclude_patterns<I, S>(mut self, exclude_patterns: I) -> Self
170 where
171 I: IntoIterator<Item = S>,
172 S: Into<String>,
173 {
174 self.config.exclude_patterns = exclude_patterns.into_iter().map(Into::into).collect();
175 self
176 }
177
178 pub fn with_profile(mut self, profile: Option<String>) -> Self {
182 self.config.profile = profile;
183 self
184 }
185
186 pub fn with_terminal(mut self, terminal: Option<String>) -> Self {
190 self.config.terminal = terminal;
191 self
192 }
193
194 pub fn with_shell_context(mut self, shell_context: HistoryShellContext) -> Self {
198 self.config.shell_context = shell_context;
199 self
200 }
201
202 pub fn build(self) -> HistoryConfig {
204 self.config.normalized()
205 }
206}
207
208#[derive(Clone, Default, Debug)]
210pub struct HistoryShellContext {
211 inner: Arc<RwLock<Option<String>>>,
212}
213
214impl HistoryShellContext {
215 pub fn new(prefix: impl Into<String>) -> Self {
217 let context = Self::default();
218 context.set_prefix(prefix);
219 context
220 }
221
222 pub fn set_prefix(&self, prefix: impl Into<String>) {
224 if let Ok(mut guard) = self.inner.write() {
225 *guard = normalize_shell_prefix(prefix.into());
226 }
227 }
228
229 pub fn clear(&self) {
231 if let Ok(mut guard) = self.inner.write() {
232 *guard = None;
233 }
234 }
235
236 pub fn prefix(&self) -> Option<String> {
238 self.inner.read().map(|value| value.clone()).unwrap_or(None)
239 }
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize)]
243struct HistoryRecord {
244 id: i64,
245 command_line: String,
246 #[serde(default)]
247 timestamp_ms: Option<i64>,
248 #[serde(default)]
249 duration_ms: Option<i64>,
250 #[serde(default)]
251 exit_status: Option<i64>,
252 #[serde(default)]
253 cwd: Option<String>,
254 #[serde(default)]
255 hostname: Option<String>,
256 #[serde(default)]
257 session_id: Option<i64>,
258 #[serde(default)]
259 profile: Option<String>,
260 #[serde(default)]
261 terminal: Option<String>,
262}
263
264#[derive(Debug, Clone)]
266pub struct HistoryEntry {
267 pub id: i64,
269 pub timestamp_ms: Option<i64>,
271 pub command: String,
273}
274
275#[derive(Clone)]
280pub struct SharedHistory {
281 inner: Arc<Mutex<OspHistoryStore>>,
282}
283
284impl SharedHistory {
285 pub fn new(config: HistoryConfig) -> Self {
290 Self {
291 inner: Arc::new(Mutex::new(OspHistoryStore::new(config))),
292 }
293 }
294
295 pub fn enabled(&self) -> bool {
297 self.inner
298 .lock()
299 .map(|store| store.history_enabled())
300 .unwrap_or(false)
301 }
302
303 pub fn recent_commands(&self) -> Vec<String> {
306 self.inner
307 .lock()
308 .map(|store| store.recent_commands())
309 .unwrap_or_default()
310 }
311
312 pub fn recent_commands_for(&self, shell_prefix: Option<&str>) -> Vec<String> {
318 self.inner
319 .lock()
320 .map(|store| store.recent_commands_for(shell_prefix))
321 .unwrap_or_default()
322 }
323
324 pub fn list_entries(&self) -> Vec<HistoryEntry> {
327 self.inner
328 .lock()
329 .map(|store| store.list_entries())
330 .unwrap_or_default()
331 }
332
333 pub fn list_entries_for(&self, shell_prefix: Option<&str>) -> Vec<HistoryEntry> {
336 self.inner
337 .lock()
338 .map(|store| store.list_entries_for(shell_prefix))
339 .unwrap_or_default()
340 }
341
342 pub fn prune(&self, keep: usize) -> Result<usize> {
352 let mut guard = self
353 .inner
354 .lock()
355 .map_err(|_| anyhow::anyhow!("history lock poisoned"))?;
356 guard.prune(keep)
357 }
358
359 pub fn prune_for(&self, keep: usize, shell_prefix: Option<&str>) -> Result<usize> {
369 let mut guard = self
370 .inner
371 .lock()
372 .map_err(|_| anyhow::anyhow!("history lock poisoned"))?;
373 guard.prune_for(keep, shell_prefix)
374 }
375
376 pub fn clear_scoped(&self) -> Result<usize> {
385 let mut guard = self
386 .inner
387 .lock()
388 .map_err(|_| anyhow::anyhow!("history lock poisoned"))?;
389 guard.clear_scoped()
390 }
391
392 pub fn clear_for(&self, shell_prefix: Option<&str>) -> Result<usize> {
401 let mut guard = self
402 .inner
403 .lock()
404 .map_err(|_| anyhow::anyhow!("history lock poisoned"))?;
405 guard.clear_for(shell_prefix)
406 }
407
408 pub fn save_command_line(&self, command_line: &str) -> Result<()> {
416 let mut guard = self
417 .inner
418 .lock()
419 .map_err(|_| anyhow::anyhow!("history lock poisoned"))?;
420 let item = HistoryItem::from_command_line(command_line);
421 History::save(&mut *guard, item).map(|_| ())?;
422 Ok(())
423 }
424}
425
426pub(crate) struct OspHistoryStore {
432 config: HistoryConfig,
433 records: Vec<HistoryRecord>,
434}
435
436impl OspHistoryStore {
437 pub fn new(config: HistoryConfig) -> Self {
440 let config = config.normalized();
441 let mut records = Vec::new();
442 if config.persist_enabled()
443 && let Some(path) = &config.path
444 {
445 records = load_records(path);
446 }
447 let mut store = Self { config, records };
448 store.trim_to_capacity();
449 store
450 }
451
452 pub fn history_enabled(&self) -> bool {
457 self.config.enabled && self.config.max_entries > 0
458 }
459
460 pub fn recent_commands(&self) -> Vec<String> {
463 self.recent_commands_for(self.shell_prefix().as_deref())
464 }
465
466 pub fn recent_commands_for(&self, shell_prefix: Option<&str>) -> Vec<String> {
471 let shell_prefix = normalize_scope_prefix(shell_prefix);
472 self.records
473 .iter()
474 .filter_map(|record| {
475 self.record_view_if_allowed(record, shell_prefix.as_deref(), true)
476 .map(|_| record.command_line.clone())
477 })
478 .collect()
479 }
480
481 pub fn list_entries(&self) -> Vec<HistoryEntry> {
484 self.list_entries_for(self.shell_prefix().as_deref())
485 }
486
487 pub fn list_entries_for(&self, shell_prefix: Option<&str>) -> Vec<HistoryEntry> {
490 if !self.history_enabled() {
491 return Vec::new();
492 }
493 let shell_prefix = normalize_scope_prefix(shell_prefix);
494 let mut out = Vec::new();
495 let mut id = 0i64;
496 for record in &self.records {
497 let Some(view) = self.record_view_if_allowed(record, shell_prefix.as_deref(), true)
498 else {
499 continue;
500 };
501 id += 1;
502 out.push(HistoryEntry {
503 id,
504 timestamp_ms: record.timestamp_ms,
505 command: view,
506 });
507 }
508 out
509 }
510
511 pub fn prune(&mut self, keep: usize) -> Result<usize> {
516 let shell_prefix = self.shell_prefix();
517 self.prune_for(keep, shell_prefix.as_deref())
518 }
519
520 pub fn prune_for(&mut self, keep: usize, shell_prefix: Option<&str>) -> Result<usize> {
525 if !self.history_enabled() {
526 return Ok(0);
527 }
528 let shell_prefix = normalize_scope_prefix(shell_prefix);
529 let mut eligible = Vec::new();
530 for (idx, record) in self.records.iter().enumerate() {
531 if self
532 .record_view_if_allowed(record, shell_prefix.as_deref(), true)
533 .is_some()
534 {
535 eligible.push(idx);
536 }
537 }
538
539 if keep == 0 {
540 return self.remove_records(&eligible);
541 }
542
543 if eligible.len() <= keep {
544 return Ok(0);
545 }
546
547 let remove_count = eligible.len() - keep;
548 let to_remove = eligible.into_iter().take(remove_count).collect::<Vec<_>>();
549 self.remove_records(&to_remove)
550 }
551
552 pub fn clear_scoped(&mut self) -> Result<usize> {
558 self.prune(0)
559 }
560
561 pub fn clear_for(&mut self, shell_prefix: Option<&str>) -> Result<usize> {
567 self.prune_for(0, shell_prefix)
568 }
569
570 fn profile_allows(&self, record: &HistoryRecord) -> bool {
571 if !self.config.profile_scoped {
572 return true;
573 }
574 match (self.config.profile.as_deref(), record.profile.as_deref()) {
575 (Some(active), Some(profile)) => active == profile,
576 (Some(_), None) => false,
577 _ => true,
578 }
579 }
580
581 fn shell_prefix(&self) -> Option<String> {
582 self.config.shell_context.prefix()
583 }
584
585 fn shell_allows(&self, record: &HistoryRecord, shell_prefix: Option<&str>) -> bool {
586 command_matches_shell_prefix(&record.command_line, shell_prefix)
587 }
588
589 fn view_command_line(&self, command: &str, shell_prefix: Option<&str>) -> String {
590 strip_shell_prefix(command, shell_prefix)
591 }
592
593 fn record_view_if_allowed(
594 &self,
595 record: &HistoryRecord,
596 shell_prefix: Option<&str>,
597 require_shell: bool,
598 ) -> Option<String> {
599 if !self.profile_allows(record) {
600 return None;
601 }
602 if require_shell && !self.shell_allows(record, shell_prefix) {
603 return None;
604 }
605 let view_command = self.view_command_line(&record.command_line, shell_prefix);
606 if self.is_command_excluded(&view_command) {
607 return None;
608 }
609 Some(view_command)
610 }
611
612 fn is_command_excluded(&self, command: &str) -> bool {
613 is_excluded_command(command, &self.config.exclude_patterns)
614 }
615
616 fn next_id(&self) -> i64 {
617 self.records.len() as i64
618 }
619
620 fn trim_to_capacity(&mut self) {
621 if self.config.max_entries == 0 {
622 self.records.clear();
623 return;
624 }
625 if self.records.len() > self.config.max_entries {
626 let start = self.records.len() - self.config.max_entries;
627 self.records = self.records.split_off(start);
628 }
629 for (idx, record) in self.records.iter_mut().enumerate() {
630 record.id = idx as i64;
631 }
632 }
633
634 fn append_record(&mut self, mut record: HistoryRecord) -> HistoryItemId {
635 record.id = self.next_id();
636 self.records.push(record);
637 self.trim_to_capacity();
638 HistoryItemId::new(self.records.len() as i64 - 1)
639 }
640
641 fn remove_records(&mut self, indices: &[usize]) -> Result<usize> {
642 if indices.is_empty() {
643 return Ok(0);
644 }
645 let mut drop_flags = vec![false; self.records.len()];
646 for idx in indices {
647 if *idx < drop_flags.len() {
648 drop_flags[*idx] = true;
649 }
650 }
651 let mut cursor = 0usize;
652 let removed = drop_flags.iter().filter(|flag| **flag).count();
653 self.records.retain(|_| {
654 let keep = !drop_flags.get(cursor).copied().unwrap_or(false);
655 cursor += 1;
656 keep
657 });
658 self.trim_to_capacity();
659 if let Err(err) = self.write_all() {
660 return Err(err.into());
661 }
662 Ok(removed)
663 }
664
665 fn write_all(&self) -> std::io::Result<()> {
666 if !self.config.persist_enabled() {
667 return Ok(());
668 }
669 let Some(path) = &self.config.path else {
670 return Ok(());
671 };
672 if let Some(parent) = path.parent() {
673 std::fs::create_dir_all(parent)?;
674 }
675 let mut payload = Vec::new();
676 for record in &self.records {
677 serde_json::to_writer(&mut payload, record).map_err(std::io::Error::other)?;
678 payload.push(b'\n');
679 }
680 crate::config::write_text_atomic(path, &payload, false)
681 }
682
683 fn should_skip_command(&self, command: &str) -> bool {
684 is_excluded_command(command, &self.config.exclude_patterns)
685 }
686
687 fn command_list_for_expansion(&self) -> Vec<String> {
688 self.recent_commands()
689 }
690
691 fn expand_if_needed(&self, command: &str, shell_prefix: Option<&str>) -> Option<String> {
692 if !command.starts_with('!') {
693 return Some(command.to_string());
694 }
695 let history = self.command_list_for_expansion();
696 expand_history(command, &history, shell_prefix, false)
697 }
698
699 fn record_matches_filter(
700 &self,
701 record: &HistoryRecord,
702 filter: &SearchFilter,
703 shell_prefix: Option<&str>,
704 ) -> bool {
705 if !self.profile_allows(record) {
706 return false;
707 }
708 if !self.shell_allows(record, shell_prefix) {
709 return false;
710 }
711 let view_command = self.view_command_line(&record.command_line, shell_prefix);
712 if self.is_command_excluded(&view_command) {
713 return false;
714 }
715 if let Some(search) = &filter.command_line {
716 let matches = match search {
717 CommandLineSearch::Prefix(prefix) => view_command.starts_with(prefix),
718 CommandLineSearch::Substring(substr) => view_command.contains(substr),
719 CommandLineSearch::Exact(exact) => view_command == *exact,
720 };
721 if !matches {
722 return false;
723 }
724 }
725 if let Some(hostname) = &filter.hostname
726 && record.hostname.as_deref() != Some(hostname.as_str())
727 {
728 return false;
729 }
730 if let Some(cwd) = &filter.cwd_exact
731 && record.cwd.as_deref() != Some(cwd.as_str())
732 {
733 return false;
734 }
735 if let Some(prefix) = &filter.cwd_prefix {
736 match record.cwd.as_deref() {
737 Some(value) if value.starts_with(prefix) => {}
738 _ => return false,
739 }
740 }
741 if let Some(exit_successful) = filter.exit_successful {
742 let is_success = record.exit_status == Some(0);
743 if exit_successful != is_success {
744 return false;
745 }
746 }
747 if let Some(session) = filter.session
748 && record.session_id != Some(i64::from(session))
749 {
750 return false;
751 }
752 true
753 }
754
755 fn record_from_item(&self, item: &HistoryItem, command_line: String) -> HistoryRecord {
756 HistoryRecord {
757 id: -1,
758 command_line,
759 timestamp_ms: item.start_timestamp.map(|ts| ts.timestamp_millis()),
760 duration_ms: item.duration.map(|value| value.as_millis() as i64),
761 exit_status: item.exit_status,
762 cwd: item.cwd.clone(),
763 hostname: item.hostname.clone(),
764 session_id: item.session_id.map(i64::from),
765 profile: self.config.profile.clone(),
766 terminal: self.config.terminal.clone(),
767 }
768 }
769
770 fn history_item_from_record(
771 &self,
772 record: &HistoryRecord,
773 shell_prefix: Option<&str>,
774 ) -> HistoryItem {
775 let command_line = self.view_command_line(&record.command_line, shell_prefix);
776 HistoryItem {
777 id: Some(HistoryItemId::new(record.id)),
778 start_timestamp: None,
779 command_line,
780 session_id: None,
781 hostname: record.hostname.clone(),
782 cwd: record.cwd.clone(),
783 duration: record
784 .duration_ms
785 .map(|value| Duration::from_millis(value as u64)),
786 exit_status: record.exit_status,
787 more_info: None,
788 }
789 }
790
791 fn reedline_error(message: &'static str) -> ReedlineError {
792 ReedlineError(ReedlineErrorVariants::OtherHistoryError(message))
793 }
794
795 fn record_matches_query(
796 &self,
797 record: &HistoryRecord,
798 filter: &SearchFilter,
799 start_time_ms: Option<i64>,
800 end_time_ms: Option<i64>,
801 shell_prefix: Option<&str>,
802 skip_command_line: Option<&str>,
803 ) -> bool {
804 if !self.record_matches_filter(record, filter, shell_prefix) {
805 return false;
806 }
807 if let Some(skip) = skip_command_line {
808 let view_command = self.view_command_line(&record.command_line, shell_prefix);
809 if view_command == skip {
810 return false;
811 }
812 }
813 if let Some(start) = start_time_ms {
814 match record.timestamp_ms {
815 Some(value) if value >= start => {}
816 _ => return false,
817 }
818 }
819 if let Some(end) = end_time_ms {
820 match record.timestamp_ms {
821 Some(value) if value <= end => {}
822 _ => return false,
823 }
824 }
825 true
826 }
827}
828
829impl History for OspHistoryStore {
830 fn save(&mut self, h: HistoryItem) -> ReedlineResult<HistoryItem> {
831 if !self.config.enabled || self.config.max_entries == 0 {
832 return Ok(h);
833 }
834
835 let raw = h.command_line.trim();
836 if raw.is_empty() {
837 return Ok(h);
838 }
839
840 let shell_prefix = self.shell_prefix();
841 let Some(expanded) = self.expand_if_needed(raw, shell_prefix.as_deref()) else {
842 return Ok(h);
843 };
844 if self.should_skip_command(&expanded) {
845 return Ok(h);
846 }
847 let expanded_full = apply_shell_prefix(&expanded, shell_prefix.as_deref());
848
849 if self.config.dedupe {
850 let last_match = self.records.iter().rev().find(|record| {
851 self.profile_allows(record) && self.shell_allows(record, shell_prefix.as_deref())
852 });
853 if let Some(last) = last_match
854 && last.command_line == expanded_full
855 {
856 return Ok(h);
857 }
858 }
859
860 let mut record = self.record_from_item(&h, expanded_full);
861 if record.timestamp_ms.is_none() {
862 record.timestamp_ms = Some(now_ms());
863 }
864 let id = self.append_record(record);
865
866 if let Err(err) = self.write_all() {
867 return Err(ReedlineError(ReedlineErrorVariants::IOError(err)));
868 }
869
870 Ok(HistoryItem {
871 id: Some(id),
872 command_line: self.records[id.0 as usize].command_line.clone(),
873 ..h
874 })
875 }
876
877 fn load(&self, id: HistoryItemId) -> ReedlineResult<HistoryItem> {
878 let idx = id.0 as usize;
879 let shell_prefix = self.shell_prefix();
880 let record = self
881 .records
882 .get(idx)
883 .ok_or_else(|| Self::reedline_error("history item not found"))?;
884 Ok(self.history_item_from_record(record, shell_prefix.as_deref()))
885 }
886
887 fn count(&self, query: SearchQuery) -> ReedlineResult<i64> {
888 Ok(self.search(query)?.len() as i64)
889 }
890
891 fn search(&self, query: SearchQuery) -> ReedlineResult<Vec<HistoryItem>> {
892 let (min_id, max_id) = {
893 let start = query.start_id.map(|value| value.0);
894 let end = query.end_id.map(|value| value.0);
895 if let SearchDirection::Backward = query.direction {
896 (end, start)
897 } else {
898 (start, end)
899 }
900 };
901 let min_id = min_id.map(|value| value + 1).unwrap_or(0);
902 let max_id = max_id
903 .map(|value| value - 1)
904 .unwrap_or(self.records.len().saturating_sub(1) as i64);
905
906 if self.records.is_empty() || max_id < 0 || min_id > max_id {
907 return Ok(Vec::new());
908 }
909
910 let intrinsic_limit = max_id - min_id + 1;
911 let limit = query
912 .limit
913 .map(|value| std::cmp::min(intrinsic_limit, value) as usize)
914 .unwrap_or(intrinsic_limit as usize);
915
916 let start_time_ms = query.start_time.map(|ts| ts.timestamp_millis());
917 let end_time_ms = query.end_time.map(|ts| ts.timestamp_millis());
918 let shell_prefix = self.shell_prefix();
919
920 let mut results = Vec::new();
921 let iter = self
922 .records
923 .iter()
924 .enumerate()
925 .skip(min_id as usize)
926 .take(intrinsic_limit as usize);
927 let skip_command_line = query
928 .start_id
929 .and_then(|id| self.records.get(id.0 as usize))
930 .map(|record| self.view_command_line(&record.command_line, shell_prefix.as_deref()));
931
932 if let SearchDirection::Backward = query.direction {
933 for (idx, record) in iter.rev() {
934 if results.len() >= limit {
935 break;
936 }
937 if !self.record_matches_query(
938 record,
939 &query.filter,
940 start_time_ms,
941 end_time_ms,
942 shell_prefix.as_deref(),
943 skip_command_line.as_deref(),
944 ) {
945 continue;
946 }
947 let mut item = self.history_item_from_record(record, shell_prefix.as_deref());
948 item.id = Some(HistoryItemId::new(idx as i64));
949 results.push(item);
950 }
951 } else {
952 for (idx, record) in iter {
953 if results.len() >= limit {
954 break;
955 }
956 if !self.record_matches_query(
957 record,
958 &query.filter,
959 start_time_ms,
960 end_time_ms,
961 shell_prefix.as_deref(),
962 skip_command_line.as_deref(),
963 ) {
964 continue;
965 }
966 let mut item = self.history_item_from_record(record, shell_prefix.as_deref());
967 item.id = Some(HistoryItemId::new(idx as i64));
968 results.push(item);
969 }
970 }
971
972 Ok(results)
973 }
974
975 fn update(
976 &mut self,
977 _id: HistoryItemId,
978 _updater: &dyn Fn(HistoryItem) -> HistoryItem,
979 ) -> ReedlineResult<()> {
980 Err(ReedlineError(
981 ReedlineErrorVariants::HistoryFeatureUnsupported {
982 history: "OspHistoryStore",
983 feature: "updating entries",
984 },
985 ))
986 }
987
988 fn clear(&mut self) -> ReedlineResult<()> {
989 self.records.clear();
990 if let Some(path) = &self.config.path {
991 let _ = std::fs::remove_file(path);
992 }
993 Ok(())
994 }
995
996 fn delete(&mut self, _h: HistoryItemId) -> ReedlineResult<()> {
997 Err(ReedlineError(
998 ReedlineErrorVariants::HistoryFeatureUnsupported {
999 history: "OspHistoryStore",
1000 feature: "removing entries",
1001 },
1002 ))
1003 }
1004
1005 fn sync(&mut self) -> std::io::Result<()> {
1006 self.write_all()
1007 }
1008
1009 fn session(&self) -> Option<HistorySessionId> {
1010 None
1011 }
1012}
1013
1014impl History for SharedHistory {
1015 fn save(&mut self, h: HistoryItem) -> ReedlineResult<HistoryItem> {
1016 let mut guard = self
1017 .inner
1018 .lock()
1019 .map_err(|_| OspHistoryStore::reedline_error("history lock poisoned"))?;
1020 History::save(&mut *guard, h)
1021 }
1022
1023 fn load(&self, id: HistoryItemId) -> ReedlineResult<HistoryItem> {
1024 let guard = self
1025 .inner
1026 .lock()
1027 .map_err(|_| OspHistoryStore::reedline_error("history lock poisoned"))?;
1028 History::load(&*guard, id)
1029 }
1030
1031 fn count(&self, query: SearchQuery) -> ReedlineResult<i64> {
1032 let guard = self
1033 .inner
1034 .lock()
1035 .map_err(|_| OspHistoryStore::reedline_error("history lock poisoned"))?;
1036 History::count(&*guard, query)
1037 }
1038
1039 fn search(&self, query: SearchQuery) -> ReedlineResult<Vec<HistoryItem>> {
1040 let guard = self
1041 .inner
1042 .lock()
1043 .map_err(|_| OspHistoryStore::reedline_error("history lock poisoned"))?;
1044 History::search(&*guard, query)
1045 }
1046
1047 fn update(
1048 &mut self,
1049 id: HistoryItemId,
1050 updater: &dyn Fn(HistoryItem) -> HistoryItem,
1051 ) -> ReedlineResult<()> {
1052 let mut guard = self
1053 .inner
1054 .lock()
1055 .map_err(|_| OspHistoryStore::reedline_error("history lock poisoned"))?;
1056 History::update(&mut *guard, id, updater)
1057 }
1058
1059 fn clear(&mut self) -> ReedlineResult<()> {
1060 let mut guard = self
1061 .inner
1062 .lock()
1063 .map_err(|_| OspHistoryStore::reedline_error("history lock poisoned"))?;
1064 History::clear(&mut *guard)
1065 }
1066
1067 fn delete(&mut self, h: HistoryItemId) -> ReedlineResult<()> {
1068 let mut guard = self
1069 .inner
1070 .lock()
1071 .map_err(|_| OspHistoryStore::reedline_error("history lock poisoned"))?;
1072 History::delete(&mut *guard, h)
1073 }
1074
1075 fn sync(&mut self) -> std::io::Result<()> {
1076 let mut guard = self
1077 .inner
1078 .lock()
1079 .map_err(|_| std::io::Error::other("history lock poisoned"))?;
1080 History::sync(&mut *guard)
1081 }
1082
1083 fn session(&self) -> Option<HistorySessionId> {
1084 let guard = self.inner.lock().ok()?;
1085 History::session(&*guard)
1086 }
1087}
1088
1089fn load_records(path: &Path) -> Vec<HistoryRecord> {
1090 if !path.exists() {
1091 return Vec::new();
1092 }
1093 let file = match File::open(path) {
1094 Ok(file) => file,
1095 Err(_) => return Vec::new(),
1096 };
1097 let reader = BufReader::new(file);
1098 let mut records = Vec::new();
1099 for line in reader.lines().map_while(Result::ok) {
1100 let trimmed = line.trim();
1101 if trimmed.is_empty() {
1102 continue;
1103 }
1104 let record: HistoryRecord = match serde_json::from_str(trimmed) {
1105 Ok(record) => record,
1106 Err(_) => continue,
1107 };
1108 if record.command_line.trim().is_empty() {
1109 continue;
1110 }
1111 records.push(record);
1112 }
1113 records
1114}
1115
1116fn normalize_identifier(value: Option<String>) -> Option<String> {
1117 value
1118 .map(|value| value.trim().to_ascii_lowercase())
1119 .filter(|value| !value.is_empty())
1120}
1121
1122fn normalize_exclude_patterns(patterns: Vec<String>) -> Vec<String> {
1123 patterns
1124 .into_iter()
1125 .map(|pattern| pattern.trim().to_string())
1126 .filter(|pattern| !pattern.is_empty())
1127 .collect()
1128}
1129
1130fn normalize_shell_prefix(value: String) -> Option<String> {
1131 let trimmed = value.trim();
1132 if trimmed.is_empty() {
1133 return None;
1134 }
1135 let mut out = trimmed.to_string();
1136 if !out.ends_with(' ') {
1137 out.push(' ');
1138 }
1139 Some(out)
1140}
1141
1142fn normalize_scope_prefix(shell_prefix: Option<&str>) -> Option<String> {
1143 shell_prefix.and_then(|value| normalize_shell_prefix(value.to_string()))
1144}
1145
1146fn command_matches_shell_prefix(command: &str, shell_prefix: Option<&str>) -> bool {
1147 match shell_prefix {
1148 Some(prefix) => command.starts_with(prefix),
1149 None => true,
1150 }
1151}
1152
1153pub(crate) fn apply_shell_prefix(command: &str, shell_prefix: Option<&str>) -> String {
1154 let trimmed = command.trim();
1155 if trimmed.is_empty() {
1156 return String::new();
1157 }
1158 match shell_prefix {
1159 Some(prefix) => {
1160 let prefix_trimmed = prefix.trim_end();
1161 if trimmed == prefix_trimmed || trimmed.starts_with(prefix) {
1162 return trimmed.to_string();
1163 }
1164 let mut out = String::with_capacity(prefix.len() + trimmed.len());
1165 out.push_str(prefix);
1166 out.push_str(trimmed);
1167 out
1168 }
1169 _ => trimmed.to_string(),
1170 }
1171}
1172
1173fn strip_shell_prefix(command: &str, shell_prefix: Option<&str>) -> String {
1174 let trimmed = command.trim();
1175 if trimmed.is_empty() {
1176 return String::new();
1177 }
1178 match shell_prefix {
1179 Some(prefix) => trimmed
1180 .strip_prefix(prefix)
1181 .map(|rest| rest.trim_start().to_string())
1182 .unwrap_or_else(|| trimmed.to_string()),
1183 None => trimmed.to_string(),
1184 }
1185}
1186
1187fn now_ms() -> i64 {
1188 let now = SystemTime::now()
1189 .duration_since(UNIX_EPOCH)
1190 .unwrap_or_else(|_| Duration::from_secs(0));
1191 now.as_millis() as i64
1192}
1193
1194pub(crate) fn expand_history(
1198 input: &str,
1199 history: &[String],
1200 shell_prefix: Option<&str>,
1201 strip_prefix: bool,
1202) -> Option<String> {
1203 if !input.starts_with('!') {
1204 return Some(input.to_string());
1205 }
1206
1207 let entries: Vec<(&str, String)> = history
1208 .iter()
1209 .filter(|cmd| command_matches_shell_prefix(cmd, shell_prefix))
1210 .map(|cmd| {
1211 let view = strip_shell_prefix(cmd, shell_prefix);
1212 (cmd.as_str(), view)
1213 })
1214 .collect();
1215
1216 if entries.is_empty() {
1217 return None;
1218 }
1219
1220 let select = |full: &str, view: &str, strip: bool| -> String {
1221 if strip {
1222 view.to_string()
1223 } else {
1224 full.to_string()
1225 }
1226 };
1227
1228 if input == "!!" {
1229 let (full, view) = entries.last()?;
1230 return Some(select(full, view, strip_prefix));
1231 }
1232
1233 if let Some(rest) = input.strip_prefix("!-") {
1234 let idx = rest.parse::<usize>().ok()?;
1235 if idx == 0 || idx > entries.len() {
1236 return None;
1237 }
1238 let (full, view) = entries.get(entries.len() - idx)?;
1239 return Some(select(full, view, strip_prefix));
1240 }
1241
1242 let rest = input.strip_prefix('!')?;
1243 if let Ok(abs_id) = rest.parse::<usize>() {
1244 if abs_id == 0 || abs_id > entries.len() {
1245 return None;
1246 }
1247 let (full, view) = entries.get(abs_id - 1)?;
1248 return Some(select(full, view, strip_prefix));
1249 }
1250
1251 for (full, view) in entries.iter().rev() {
1252 if view.starts_with(rest) {
1253 return Some(select(full, view, strip_prefix));
1254 }
1255 }
1256
1257 None
1258}
1259
1260fn is_excluded_command(command: &str, exclude_patterns: &[String]) -> bool {
1261 let trimmed = command.trim();
1262 if trimmed.is_empty() {
1263 return true;
1264 }
1265 if trimmed.starts_with('!') {
1266 return true;
1267 }
1268 if trimmed.contains("--help") {
1269 return true;
1270 }
1271 exclude_patterns
1272 .iter()
1273 .any(|pattern| matches_pattern(pattern, trimmed))
1274}
1275
1276fn matches_pattern(pattern: &str, command: &str) -> bool {
1277 let pattern = pattern.trim();
1278 if pattern.is_empty() {
1279 return false;
1280 }
1281 if pattern == "*" {
1282 return true;
1283 }
1284 if !pattern.contains('*') {
1285 return pattern == command;
1286 }
1287
1288 let parts: Vec<&str> = pattern.split('*').collect();
1289 let mut cursor = 0usize;
1290
1291 let mut first = true;
1292 for part in &parts {
1293 if part.is_empty() {
1294 continue;
1295 }
1296 if first && !pattern.starts_with('*') {
1297 if !command[cursor..].starts_with(part) {
1298 return false;
1299 }
1300 cursor += part.len();
1301 } else if let Some(pos) = command[cursor..].find(part) {
1302 cursor += pos + part.len();
1303 } else {
1304 return false;
1305 }
1306 first = false;
1307 }
1308
1309 if !pattern.ends_with('*')
1310 && let Some(last) = parts.iter().rev().find(|part| !part.is_empty())
1311 && !command.ends_with(last)
1312 {
1313 return false;
1314 }
1315
1316 true
1317}
1318
1319#[cfg(test)]
1320mod tests;