Skip to main content

bijux_cli/interface/repl/
history.rs

1use std::fs;
2use std::path::PathBuf;
3
4use crate::infrastructure::fs_store::atomic_write_text;
5
6use super::execution::execute_repl_line;
7use super::types::{
8    ReplError, ReplFrame, ReplSession, REPL_HISTORY_ENTRY_MAX_CHARS, REPL_HISTORY_FILE_MAX_BYTES,
9    REPL_HISTORY_MAX_ENTRIES, REPL_LAST_ERROR_MAX_CHARS,
10};
11
12#[derive(Debug, Default)]
13struct HistoryParseReport {
14    entries: Vec<String>,
15    malformed: bool,
16    dropped_entries: usize,
17    truncated_entries: usize,
18}
19
20fn set_history_warning(session: &mut ReplSession, message: &str) {
21    let bounded = message
22        .chars()
23        .filter(|ch| !ch.is_control())
24        .take(REPL_LAST_ERROR_MAX_CHARS)
25        .collect::<String>();
26    session.last_error = Some(bounded);
27}
28
29fn sanitize_history_command(raw: &str) -> Option<(String, bool)> {
30    let trimmed = raw.trim();
31    if trimmed.is_empty() {
32        return None;
33    }
34    if trimmed.chars().any(char::is_control) {
35        return None;
36    }
37
38    let char_count = trimmed.chars().count();
39    if char_count <= REPL_HISTORY_ENTRY_MAX_CHARS {
40        return Some((trimmed.to_string(), false));
41    }
42
43    let truncated = trimmed.chars().take(REPL_HISTORY_ENTRY_MAX_CHARS).collect::<String>();
44    Some((truncated, true))
45}
46
47fn parse_history_entries(text: &str) -> HistoryParseReport {
48    let trimmed = text.trim_start_matches('\u{feff}').trim();
49    if trimmed.is_empty() {
50        return HistoryParseReport::default();
51    }
52    if trimmed.starts_with('[') {
53        if let Ok(entries) = serde_json::from_str::<Vec<String>>(trimmed) {
54            let mut report = HistoryParseReport::default();
55            for entry in entries {
56                match sanitize_history_command(&entry) {
57                    Some((sanitized, truncated)) => {
58                        report.entries.push(sanitized);
59                        report.truncated_entries += usize::from(truncated);
60                    }
61                    None => report.dropped_entries += 1,
62                }
63            }
64            if report.dropped_entries > 0 {
65                report.malformed = true;
66            }
67            return report;
68        }
69        if let Ok(entries) = serde_json::from_str::<Vec<serde_json::Value>>(trimmed) {
70            let mut report = HistoryParseReport::default();
71            for entry in entries {
72                let normalized = match entry {
73                    serde_json::Value::String(value) => sanitize_history_command(&value),
74                    serde_json::Value::Object(object) => object
75                        .get("command")
76                        .and_then(serde_json::Value::as_str)
77                        .and_then(sanitize_history_command),
78                    _ => None,
79                };
80                match normalized {
81                    Some((sanitized, truncated)) => {
82                        report.entries.push(sanitized);
83                        report.truncated_entries += usize::from(truncated);
84                    }
85                    None => report.dropped_entries += 1,
86                }
87            }
88            if report.dropped_entries > 0 {
89                report.malformed = true;
90            }
91            return report;
92        }
93        return HistoryParseReport {
94            malformed: true,
95            dropped_entries: 1,
96            ..HistoryParseReport::default()
97        };
98    }
99    if matches!(trimmed.chars().next(), Some('{' | '}' | ']')) {
100        return HistoryParseReport {
101            malformed: true,
102            dropped_entries: 1,
103            ..HistoryParseReport::default()
104        };
105    }
106
107    let mut report = HistoryParseReport::default();
108    for line in trimmed.lines() {
109        let line = line.trim();
110        if line.is_empty() {
111            continue;
112        }
113        if let Ok(value) = serde_json::from_str::<serde_json::Value>(line) {
114            let normalized = match value {
115                serde_json::Value::String(value) => sanitize_history_command(&value),
116                serde_json::Value::Object(object) => object
117                    .get("command")
118                    .and_then(serde_json::Value::as_str)
119                    .and_then(sanitize_history_command),
120                _ => None,
121            };
122            match normalized {
123                Some((sanitized, truncated)) => {
124                    report.entries.push(sanitized);
125                    report.truncated_entries += usize::from(truncated);
126                }
127                None => {
128                    report.dropped_entries += 1;
129                    report.malformed = true;
130                }
131            }
132            continue;
133        }
134        if matches!(line.chars().next(), Some('[' | ']' | '{' | '}')) {
135            report.dropped_entries += 1;
136            report.malformed = true;
137            continue;
138        }
139        match sanitize_history_command(line) {
140            Some((sanitized, truncated)) => {
141                report.entries.push(sanitized);
142                report.truncated_entries += usize::from(truncated);
143            }
144            None => {
145                report.dropped_entries += 1;
146                report.malformed = true;
147            }
148        }
149    }
150    if report.entries.is_empty() && !trimmed.is_empty() {
151        report.malformed = true;
152    }
153    report
154}
155
156/// Configure history persistence behavior.
157pub fn configure_history(
158    session: &mut ReplSession,
159    history_file: Option<PathBuf>,
160    enabled: bool,
161    limit: usize,
162) {
163    session.history_file = history_file;
164    session.history_enabled = enabled;
165    session.history_limit = limit.clamp(1, REPL_HISTORY_MAX_ENTRIES);
166}
167
168/// Load history into the current session if enabled.
169pub fn load_history(session: &mut ReplSession) -> Result<(), ReplError> {
170    if !session.history_enabled {
171        return Ok(());
172    }
173    let Some(path) = &session.history_file else {
174        return Ok(());
175    };
176    let metadata = match fs::symlink_metadata(path) {
177        Ok(metadata) => metadata,
178        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
179        Err(err) => return Err(err.into()),
180    };
181    if metadata.file_type().is_symlink() && fs::metadata(path).is_err() {
182        session.history.clear();
183        set_history_warning(session, "history path is a broken symlink; history reset");
184        return Ok(());
185    }
186    let metadata = fs::metadata(path)?;
187    if !metadata.is_file() {
188        session.history.clear();
189        set_history_warning(session, "history path is not a regular file; history reset");
190        return Ok(());
191    }
192    if metadata.len() > REPL_HISTORY_FILE_MAX_BYTES {
193        session.history.clear();
194        set_history_warning(
195            session,
196            &format!("history file exceeds {} bytes and was ignored", REPL_HISTORY_FILE_MAX_BYTES),
197        );
198        return Ok(());
199    }
200
201    let bytes = match fs::read(path) {
202        Ok(bytes) => bytes,
203        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
204        Err(err) => {
205            session.history.clear();
206            set_history_warning(session, &format!("history file is unreadable: {err}"));
207            return Ok(());
208        }
209    };
210    if bytes.len() as u64 > REPL_HISTORY_FILE_MAX_BYTES {
211        session.history.clear();
212        set_history_warning(
213            session,
214            &format!("history file exceeds {} bytes and was ignored", REPL_HISTORY_FILE_MAX_BYTES),
215        );
216        return Ok(());
217    }
218    let text = match String::from_utf8(bytes) {
219        Ok(value) => value,
220        Err(_) => {
221            session.history.clear();
222            set_history_warning(session, "history file is not valid UTF-8; history reset");
223            return Ok(());
224        }
225    };
226    let report = parse_history_entries(&text);
227    let mut entries = report.entries;
228    if entries.len() > session.history_limit {
229        entries = entries.split_off(entries.len() - session.history_limit);
230    }
231    session.history = entries;
232    if report.malformed && session.history.is_empty() {
233        set_history_warning(session, "history file is malformed; history reset");
234    } else if report.dropped_entries > 0 || report.truncated_entries > 0 {
235        set_history_warning(
236            session,
237            &format!(
238                "history normalized: dropped={}, truncated={}",
239                report.dropped_entries, report.truncated_entries
240            ),
241        );
242    } else {
243        session.last_error = None;
244    }
245    Ok(())
246}
247
248/// Flush history to persistent storage if enabled.
249pub fn flush_history(session: &ReplSession) -> Result<(), ReplError> {
250    if !session.history_enabled {
251        return Ok(());
252    }
253    let Some(path) = &session.history_file else {
254        return Ok(());
255    };
256    if let Some(parent) = path.parent() {
257        fs::create_dir_all(parent)?;
258    }
259
260    let mut persisted = session
261        .history
262        .iter()
263        .filter_map(|entry| sanitize_history_command(entry).map(|(sanitized, _)| sanitized))
264        .collect::<Vec<_>>();
265    if persisted.len() > session.history_limit {
266        persisted = persisted.split_off(persisted.len() - session.history_limit);
267    }
268
269    let data = {
270        let full = serde_json::to_string_pretty(&persisted)?;
271        if (full.len() as u64 + 1) <= REPL_HISTORY_FILE_MAX_BYTES {
272            full
273        } else {
274            let mut low = 0usize;
275            let mut high = persisted.len();
276            while low < high {
277                let mid = low + (high - low) / 2;
278                let candidate = serde_json::to_string_pretty(&persisted[mid..])?;
279                if (candidate.len() as u64 + 1) <= REPL_HISTORY_FILE_MAX_BYTES {
280                    high = mid;
281                } else {
282                    low = mid + 1;
283                }
284            }
285            serde_json::to_string_pretty(&persisted[low..])?
286        }
287    };
288    atomic_write_text(path, &(data + "\n"))
289        .map_err(|err| std::io::Error::other(err.to_string()))?;
290    Ok(())
291}
292
293pub(crate) fn push_history(session: &mut ReplSession, command: &str) {
294    if !session.history_enabled || command.is_empty() {
295        return;
296    }
297    let Some((sanitized, truncated)) = sanitize_history_command(command) else {
298        return;
299    };
300    session.history.push(sanitized);
301    if truncated {
302        set_history_warning(
303            session,
304            &format!(
305                "history command exceeded {} characters and was truncated",
306                REPL_HISTORY_ENTRY_MAX_CHARS
307            ),
308        );
309    }
310    if session.history.len() > session.history_limit {
311        let overflow = session.history.len() - session.history_limit;
312        session.history.drain(0..overflow);
313    }
314}
315
316/// Replay a command from history by index.
317pub fn replay_history_command(
318    session: &mut ReplSession,
319    index: usize,
320) -> Result<Option<ReplFrame>, ReplError> {
321    let command =
322        session.history.get(index).cloned().ok_or(ReplError::HistoryIndexOutOfBounds(index))?;
323    execute_repl_line(session, &command)
324}
325
326#[cfg(test)]
327mod tests {
328    use std::time::{SystemTime, UNIX_EPOCH};
329
330    use super::{
331        configure_history, load_history, parse_history_entries, push_history,
332        REPL_HISTORY_ENTRY_MAX_CHARS, REPL_HISTORY_FILE_MAX_BYTES, REPL_HISTORY_MAX_ENTRIES,
333    };
334    use crate::interface::repl::session::startup_repl;
335
336    fn temp_history_file(name: &str) -> std::path::PathBuf {
337        let nanos = SystemTime::now()
338            .duration_since(UNIX_EPOCH)
339            .expect("clock should be monotonic after epoch")
340            .as_nanos();
341        std::env::temp_dir().join(format!("bijux-repl-history-{name}-{nanos}.txt"))
342    }
343
344    #[test]
345    fn parse_history_marks_control_char_lines_as_malformed_and_dropped() {
346        let report = parse_history_entries("status\nbad\u{0007}\n");
347        assert_eq!(report.entries, vec!["status".to_string()]);
348        assert!(report.malformed);
349        assert_eq!(report.dropped_entries, 1);
350    }
351
352    #[test]
353    fn parse_history_truncates_oversized_entries() {
354        let long_entry = "x".repeat(REPL_HISTORY_ENTRY_MAX_CHARS + 64);
355        let payload = serde_json::to_string(&vec![long_entry]).expect("json serialization");
356        let report = parse_history_entries(&payload);
357
358        assert_eq!(report.entries.len(), 1);
359        assert_eq!(report.entries[0].chars().count(), REPL_HISTORY_ENTRY_MAX_CHARS);
360        assert_eq!(report.truncated_entries, 1);
361    }
362
363    #[test]
364    fn parse_history_marks_json_entries_with_invalid_commands_as_malformed() {
365        let payload = serde_json::to_string(&vec!["status".to_string(), "bad\u{0001}".to_string()])
366            .expect("json serialization");
367        let report = parse_history_entries(&payload);
368        assert_eq!(report.entries, vec!["status".to_string()]);
369        assert!(report.malformed);
370        assert_eq!(report.dropped_entries, 1);
371    }
372
373    #[test]
374    fn parse_history_supports_mixed_json_string_and_object_entries() {
375        let payload = serde_json::json!([
376            "status",
377            {"command": "doctor"},
378            {"command": "bad\u{0002}"},
379            42
380        ])
381        .to_string();
382        let report = parse_history_entries(&payload);
383        assert_eq!(report.entries, vec!["status".to_string(), "doctor".to_string()]);
384        assert!(report.malformed);
385        assert_eq!(report.dropped_entries, 2);
386    }
387
388    #[test]
389    fn parse_history_fail_closes_on_malformed_structured_payloads() {
390        let report = parse_history_entries("{\"command\":\"status\"");
391        assert!(report.entries.is_empty());
392        assert!(report.malformed);
393        assert!(report.dropped_entries > 0);
394    }
395
396    #[test]
397    fn parse_history_drops_json_shaped_noise_in_line_layout() {
398        let report = parse_history_entries("status\n{oops:true}\nplugins list\n");
399        assert_eq!(report.entries, vec!["status".to_string(), "plugins list".to_string()]);
400        assert!(report.malformed);
401        assert_eq!(report.dropped_entries, 1);
402    }
403
404    #[test]
405    fn parse_history_accepts_utf8_bom_prefixed_json_arrays() {
406        let report = parse_history_entries("\u{feff}[\"status\",\"doctor\"]");
407        assert_eq!(report.entries, vec!["status".to_string(), "doctor".to_string()]);
408        assert!(!report.malformed);
409    }
410
411    #[test]
412    fn load_history_reports_normalization_diagnostics() {
413        let path = temp_history_file("normalize");
414        std::fs::write(&path, "status\nbad\u{0007}\n").expect("history write should succeed");
415
416        let (mut session, _) = startup_repl("", None);
417        configure_history(&mut session, Some(path.clone()), true, 50);
418        load_history(&mut session).expect("history load should succeed");
419
420        assert_eq!(session.history, vec!["status".to_string()]);
421        assert!(session.last_error.as_deref().unwrap_or_default().contains("history normalized"));
422
423        let _ = std::fs::remove_file(path);
424    }
425
426    #[test]
427    fn push_history_truncates_entries_to_bounded_size() {
428        let (mut session, _) = startup_repl("", None);
429        let long_entry = "x".repeat(REPL_HISTORY_ENTRY_MAX_CHARS + 64);
430
431        push_history(&mut session, &long_entry);
432
433        assert_eq!(session.history.len(), 1);
434        assert_eq!(session.history[0].chars().count(), REPL_HISTORY_ENTRY_MAX_CHARS);
435        assert!(session
436            .last_error
437            .as_deref()
438            .unwrap_or_default()
439            .contains("history command exceeded"));
440    }
441
442    #[test]
443    fn load_history_ignores_oversized_files() {
444        let path = temp_history_file("oversized");
445        let oversized = vec![b'x'; (REPL_HISTORY_FILE_MAX_BYTES + 1024) as usize];
446        std::fs::write(&path, oversized).expect("history write should succeed");
447
448        let (mut session, _) = startup_repl("", None);
449        session.last_error = Some("stale".to_string());
450        configure_history(&mut session, Some(path.clone()), true, 50);
451        load_history(&mut session).expect("history load should succeed");
452
453        assert!(session.history.is_empty());
454        assert!(session.last_error.as_deref().unwrap_or_default().contains("exceeds"));
455
456        let _ = std::fs::remove_file(path);
457    }
458
459    #[test]
460    fn load_history_normalizes_invalid_utf8_files() {
461        let path = temp_history_file("invalid-utf8");
462        std::fs::write(&path, [0xff, 0xfe, 0xfd]).expect("history write should succeed");
463
464        let (mut session, _) = startup_repl("", None);
465        configure_history(&mut session, Some(path.clone()), true, 50);
466        load_history(&mut session).expect("invalid utf8 should be normalized");
467
468        assert!(session.history.is_empty());
469        assert!(session.last_error.as_deref().unwrap_or_default().contains("not valid UTF-8"));
470
471        let _ = std::fs::remove_file(path);
472    }
473
474    #[cfg(unix)]
475    #[test]
476    fn load_history_resets_broken_symlink_paths() {
477        use std::os::unix::fs as unix_fs;
478
479        let target = temp_history_file("broken-symlink-target");
480        let path = temp_history_file("broken-symlink-link");
481        let _ = std::fs::remove_file(&target);
482        let _ = std::fs::remove_file(&path);
483        unix_fs::symlink(&target, &path).expect("symlink should be created");
484
485        let (mut session, _) = startup_repl("", None);
486        configure_history(&mut session, Some(path.clone()), true, 50);
487        load_history(&mut session).expect("broken symlink should be normalized");
488
489        assert!(session.history.is_empty());
490        assert!(session.last_error.as_deref().unwrap_or_default().contains("broken symlink"));
491
492        let _ = std::fs::remove_file(path);
493    }
494
495    #[test]
496    fn load_history_clears_previous_error_after_clean_read() {
497        let path = temp_history_file("clean-read");
498        std::fs::write(&path, "status\n").expect("history write should succeed");
499
500        let (mut session, _) = startup_repl("", None);
501        session.last_error = Some("stale".to_string());
502        configure_history(&mut session, Some(path.clone()), true, 50);
503        load_history(&mut session).expect("history load should succeed");
504
505        assert_eq!(session.history, vec!["status".to_string()]);
506        assert!(session.last_error.is_none());
507
508        let _ = std::fs::remove_file(path);
509    }
510
511    #[test]
512    fn configure_history_clamps_limit_to_max_bound() {
513        let (mut session, _) = startup_repl("", None);
514        configure_history(
515            &mut session,
516            Some(temp_history_file("clamp")),
517            true,
518            REPL_HISTORY_MAX_ENTRIES + 10_000,
519        );
520        assert_eq!(session.history_limit, REPL_HISTORY_MAX_ENTRIES);
521    }
522
523    #[test]
524    fn flush_history_never_exceeds_history_file_size_budget() {
525        let path = temp_history_file("flush-size-budget");
526        let (mut session, _) = startup_repl("", None);
527        configure_history(&mut session, Some(path.clone()), true, REPL_HISTORY_MAX_ENTRIES);
528        session.history =
529            (0..20_000).map(|idx| format!("status {idx} {}", "x".repeat(512))).collect();
530
531        super::flush_history(&session).expect("flush should succeed");
532        let metadata = std::fs::metadata(&path).expect("flushed file should exist");
533        assert!(metadata.len() <= REPL_HISTORY_FILE_MAX_BYTES);
534
535        let _ = std::fs::remove_file(path);
536    }
537}