Skip to main content

bijux_cli/interface/repl/
session.rs

1use std::collections::BTreeMap;
2use std::sync::atomic::{AtomicU64, Ordering};
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use crate::contracts::{ColorMode, GlobalFlags, LogLevel, OutputFormat, PrettyMode};
6use crate::kernel::{build_intent_from_argv, resolve_policy, PolicyInputs};
7
8use super::types::{
9    ReplSession, ReplShutdownContract, ReplStartupContract, REPL_PROFILE_MAX_CHARS,
10    REPL_PROMPT_MAX_CHARS, REPL_STARTUP_DIAGNOSTIC_MAX_ENTRIES,
11};
12
13static REPL_SESSION_COUNTER: AtomicU64 = AtomicU64::new(1);
14
15fn next_session_id() -> String {
16    let seq = REPL_SESSION_COUNTER.fetch_add(1, Ordering::Relaxed);
17    let millis = match SystemTime::now().duration_since(UNIX_EPOCH) {
18        Ok(duration) => duration.as_millis(),
19        Err(_) => 0,
20    };
21    format!("repl-{}-{millis}-{seq}", std::process::id())
22}
23
24fn sanitize_text(value: &str, max_chars: usize) -> String {
25    value.chars().filter(|ch| !ch.is_control()).take(max_chars).collect()
26}
27
28fn normalize_profile(profile: &str) -> String {
29    sanitize_text(profile.trim(), REPL_PROFILE_MAX_CHARS)
30}
31
32fn default_prompt(profile: &str) -> String {
33    if profile.is_empty() {
34        "bijux> ".to_string()
35    } else {
36        format!("bijux[{profile}]> ")
37    }
38}
39
40fn resolve_prompt(profile: &str, prompt: Option<&str>) -> (String, bool) {
41    if let Some(value) = prompt {
42        let mut rendered = sanitize_text(value, REPL_PROMPT_MAX_CHARS);
43        if rendered.trim().is_empty() {
44            rendered = default_prompt(profile);
45        } else if !rendered.ends_with(char::is_whitespace) {
46            rendered.push(' ');
47        }
48        let include_profile_context = !profile.is_empty() && rendered.contains(profile);
49        return (rendered, include_profile_context);
50    }
51
52    (default_prompt(profile), !profile.is_empty())
53}
54
55/// Startup REPL session using the same policy precedence and routing registry as CLI.
56#[must_use]
57pub fn startup_repl(profile: &str, prompt: Option<&str>) -> (ReplSession, ReplStartupContract) {
58    let defaults = GlobalFlags {
59        output_format: Some(OutputFormat::Json),
60        pretty_mode: Some(PrettyMode::Pretty),
61        color_mode: Some(ColorMode::Never),
62        log_level: Some(LogLevel::Info),
63        quiet: false,
64        include_runtime: false,
65    };
66
67    let policy = resolve_policy(
68        &build_intent_from_argv(&["bijux".to_string(), "repl".to_string()]),
69        &PolicyInputs { env: defaults.clone(), config: defaults.clone(), defaults },
70    );
71
72    let profile = normalize_profile(profile);
73    let (prompt, include_profile_context) = resolve_prompt(&profile, prompt);
74    let session = ReplSession {
75        session_id: next_session_id(),
76        prompt: prompt.clone(),
77        profile,
78        policy: policy.clone(),
79        commands_executed: 0,
80        last_exit_code: 0,
81        trace_mode: false,
82        history: Vec::new(),
83        history_limit: 500,
84        history_enabled: true,
85        history_file: None,
86        config_path: None,
87        pending_multiline: None,
88        last_error: None,
89        plugin_completion_hooks: BTreeMap::new(),
90        completion_registries: BTreeMap::new(),
91    };
92
93    let startup = ReplStartupContract { prompt, include_profile_context, policy };
94
95    (session, startup)
96}
97
98/// Startup REPL with startup diagnostics for preflight issues.
99#[must_use]
100pub fn startup_repl_with_diagnostics(
101    profile: &str,
102    prompt: Option<&str>,
103    broken_plugins: &[&str],
104) -> (ReplSession, ReplStartupContract, Vec<String>) {
105    let (session, startup) = startup_repl(profile, prompt);
106    let mut namespaces = broken_plugins
107        .iter()
108        .map(|namespace| namespace.trim())
109        .filter(|namespace| !namespace.is_empty())
110        .map(|namespace| sanitize_text(namespace, REPL_PROFILE_MAX_CHARS))
111        .filter(|namespace| !namespace.is_empty())
112        .collect::<Vec<_>>();
113    namespaces.sort();
114    namespaces.dedup();
115    let omitted = namespaces.len().saturating_sub(REPL_STARTUP_DIAGNOSTIC_MAX_ENTRIES);
116    namespaces.truncate(REPL_STARTUP_DIAGNOSTIC_MAX_ENTRIES);
117    let mut diagnostics = namespaces
118        .into_iter()
119        .map(|namespace| format!("plugin {namespace} is broken and will be skipped"))
120        .collect::<Vec<_>>();
121    if omitted > 0 {
122        diagnostics.push(format!("additional broken plugins omitted: {omitted}"));
123    }
124    (session, startup, diagnostics)
125}
126
127/// Shutdown REPL session and emit stable contract.
128#[must_use]
129pub fn shutdown_repl(session: &ReplSession) -> ReplShutdownContract {
130    ReplShutdownContract {
131        session_id: session.session_id.clone(),
132        commands_executed: session.commands_executed,
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::{startup_repl, startup_repl_with_diagnostics};
139    use crate::interface::repl::types::REPL_STARTUP_DIAGNOSTIC_MAX_ENTRIES;
140
141    #[test]
142    fn startup_sanitizes_profile_and_prompt_fields() {
143        let noisy_profile = "  prod\u{0007}\n";
144        let (session, startup) = startup_repl(noisy_profile, Some(" prompt\u{001b}[31m"));
145        assert_eq!(session.profile, "prod");
146        assert_eq!(startup.prompt, " prompt[31m ");
147        assert!(!startup.include_profile_context);
148    }
149
150    #[test]
151    fn startup_diagnostics_are_sanitized_and_bounded() {
152        let mut broken = Vec::new();
153        for idx in 0..(REPL_STARTUP_DIAGNOSTIC_MAX_ENTRIES + 4) {
154            broken.push(format!("plugin-{idx:03}\u{0007}"));
155        }
156        let broken_refs = broken.iter().map(String::as_str).collect::<Vec<_>>();
157        let (_session, _startup, diagnostics) =
158            startup_repl_with_diagnostics("", None, &broken_refs);
159        assert_eq!(diagnostics.len(), REPL_STARTUP_DIAGNOSTIC_MAX_ENTRIES + 1);
160        assert!(diagnostics.iter().all(|entry| !entry.chars().any(char::is_control)));
161        assert!(diagnostics
162            .last()
163            .is_some_and(|entry| entry.contains("additional broken plugins omitted")));
164    }
165}