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}