bijux_cli/interface/repl/
history.rs1use 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
156pub 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
168pub 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
248pub 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
316pub 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}