bijux_cli/interface/repl/
session.rs1use 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#[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#[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#[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}