Skip to main content

bijux_cli/interface/repl/
diagnostics.rs

1use std::time::{Duration, Instant};
2
3use serde_json::json;
4
5use super::session::startup_repl;
6use super::types::{
7    ReplSession, REPL_MEMORY_BUDGET_BYTES, REPL_STARTUP_DIAGNOSTIC_MAX_ENTRIES,
8    REPL_STARTUP_LATENCY_BUDGET_MS,
9};
10use crate::shared::telemetry::truncate_chars;
11
12const MAX_DIAGNOSTIC_SESSION_ID_CHARS: usize = 128;
13const MAX_DIAGNOSTIC_ERROR_CHARS: usize = 1024;
14
15/// Return last error message captured by REPL session.
16#[must_use]
17pub fn inspect_last_error(session: &ReplSession) -> Option<String> {
18    session.last_error.clone()
19}
20
21/// Dump structured REPL diagnostics.
22#[must_use]
23pub fn session_diagnostics_dump(session: &ReplSession) -> String {
24    let (session_id, session_id_truncated) =
25        truncate_chars(&session.session_id, MAX_DIAGNOSTIC_SESSION_ID_CHARS);
26    let (last_error, last_error_truncated) = match session.last_error.as_deref() {
27        Some(value) => {
28            let (bounded, truncated) = truncate_chars(value, MAX_DIAGNOSTIC_ERROR_CHARS);
29            (Some(bounded), truncated)
30        }
31        None => (None, false),
32    };
33    let plugin_completion_hooks = session
34        .plugin_completion_hooks
35        .keys()
36        .take(REPL_STARTUP_DIAGNOSTIC_MAX_ENTRIES)
37        .collect::<Vec<_>>();
38    let completion_registries = session
39        .completion_registries
40        .keys()
41        .take(REPL_STARTUP_DIAGNOSTIC_MAX_ENTRIES)
42        .collect::<Vec<_>>();
43    let payload = json!({
44        "session_id": session_id,
45        "session_id_truncated": session_id_truncated,
46        "commands_executed": session.commands_executed,
47        "last_exit_code": session.last_exit_code,
48        "trace_mode": session.trace_mode,
49        "history_size": session.history.len(),
50        "history_limit": session.history_limit,
51        "plugin_completion_hooks": plugin_completion_hooks,
52        "plugin_completion_hooks_omitted":
53            session.plugin_completion_hooks.len().saturating_sub(REPL_STARTUP_DIAGNOSTIC_MAX_ENTRIES),
54        "completion_registries": completion_registries,
55        "completion_registries_omitted":
56            session.completion_registries.len().saturating_sub(REPL_STARTUP_DIAGNOSTIC_MAX_ENTRIES),
57        "last_error": last_error,
58        "last_error_truncated": last_error_truncated,
59    });
60    format!("{}\n", serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string()))
61}
62
63/// Approximate REPL session memory use in bytes.
64#[must_use]
65pub fn estimated_session_memory_bytes(session: &ReplSession) -> usize {
66    session.prompt.len()
67        + session.profile.len()
68        + session.history.iter().map(String::len).sum::<usize>()
69        + session
70            .plugin_completion_hooks
71            .iter()
72            .map(|(k, v)| k.len() + v.iter().map(String::len).sum::<usize>())
73            .sum::<usize>()
74        + session
75            .completion_registries
76            .iter()
77            .map(|(k, v)| k.len() + v.iter().map(String::len).sum::<usize>())
78            .sum::<usize>()
79        + 1024
80}
81
82/// Benchmark average startup latency over N iterations.
83#[must_use]
84pub fn benchmark_startup_latency(iterations: usize) -> Duration {
85    let runs = iterations.max(1);
86    let started = Instant::now();
87    for _ in 0..runs {
88        let _ = startup_repl("benchmark", None);
89    }
90    let total = started.elapsed();
91    let avg_nanos = total.as_nanos() / runs as u128;
92    let nanos = u64::try_from(avg_nanos).unwrap_or(u64::MAX);
93    Duration::from_nanos(nanos)
94}
95
96/// Check REPL runtime budgets.
97#[must_use]
98pub fn check_repl_budgets(session: &ReplSession, startup_avg: Duration) -> Vec<String> {
99    let mut warnings = Vec::new();
100    if startup_avg.as_millis() > REPL_STARTUP_LATENCY_BUDGET_MS {
101        warnings.push(format!(
102            "startup latency {}ms exceeded {}ms budget",
103            startup_avg.as_millis(),
104            REPL_STARTUP_LATENCY_BUDGET_MS
105        ));
106    }
107
108    let estimated = estimated_session_memory_bytes(session);
109    if estimated > REPL_MEMORY_BUDGET_BYTES {
110        warnings.push(format!(
111            "estimated memory {} bytes exceeded {} bytes budget",
112            estimated, REPL_MEMORY_BUDGET_BYTES
113        ));
114    }
115    warnings
116}
117
118#[cfg(test)]
119mod tests {
120    use super::session_diagnostics_dump;
121    use crate::interface::repl::session::startup_repl;
122    use crate::interface::repl::types::REPL_STARTUP_DIAGNOSTIC_MAX_ENTRIES;
123
124    #[test]
125    fn diagnostics_dump_bounds_large_fields() {
126        let (mut session, _) = startup_repl("", None);
127        session.session_id = "x".repeat(512);
128        session.last_error = Some("e".repeat(4096));
129        for idx in 0..(REPL_STARTUP_DIAGNOSTIC_MAX_ENTRIES + 5) {
130            session
131                .plugin_completion_hooks
132                .insert(format!("plugin-{idx}"), vec!["status".to_string()]);
133            session
134                .completion_registries
135                .insert(format!("owner-{idx}"), vec!["status".to_string()]);
136        }
137
138        let dump = session_diagnostics_dump(&session);
139        let payload: serde_json::Value =
140            serde_json::from_str(&dump).expect("diagnostics should be valid json");
141        assert_eq!(payload["session_id"].as_str().unwrap_or_default().len(), 128);
142        assert_eq!(payload["last_error"].as_str().unwrap_or_default().len(), 1024);
143        assert_eq!(payload["plugin_completion_hooks_omitted"], 5);
144        assert_eq!(payload["completion_registries_omitted"], 5);
145    }
146}