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