1use crate::contracts::{ColorMode, LogLevel, OutputFormat, PrettyMode};
2use crate::interface::cli::dispatch::run_app;
3use crate::routing::parser::root_command;
4
5use super::history::push_history;
6use super::types::{
7 ReplError, ReplEvent, ReplFrame, ReplInput, ReplSession, ReplStream, META_PREFIX,
8 REPL_COMMAND_MAX_CHARS, REPL_LAST_ERROR_MAX_CHARS, REPL_MULTILINE_BUFFER_MAX_CHARS,
9};
10
11fn parse_shell_tokens_lossy(input: &str) -> Vec<String> {
12 match shlex::split(input) {
13 Some(tokens) => tokens,
14 None => {
15 let trimmed = input.trim();
16 if trimmed.is_empty() {
17 Vec::new()
18 } else {
19 vec![trimmed.to_string()]
20 }
21 }
22 }
23}
24
25fn bounded_error_message(message: &str) -> String {
26 message.chars().filter(|ch| !ch.is_control()).take(REPL_LAST_ERROR_MAX_CHARS).collect()
27}
28
29fn set_last_error(session: &mut ReplSession, message: &str) {
30 session.last_error = Some(bounded_error_message(message));
31}
32
33fn parse_shell_tokens_strict(input: &str) -> Result<Vec<String>, ReplError> {
34 shlex::split(input).ok_or_else(|| {
35 let snippet = input.chars().filter(|ch| !ch.is_control()).take(256).collect::<String>();
36 ReplError::InvalidCommandInput(format!("shell tokenization failed: {snippet}"))
37 })
38}
39
40fn output_format_from_name(name: &str) -> Option<OutputFormat> {
41 match name {
42 "json" => Some(OutputFormat::Json),
43 "yaml" => Some(OutputFormat::Yaml),
44 "text" => Some(OutputFormat::Text),
45 _ => None,
46 }
47}
48
49fn output_format_name(format: OutputFormat) -> &'static str {
50 match format {
51 OutputFormat::Json => "json",
52 OutputFormat::Yaml => "yaml",
53 OutputFormat::Text => "text",
54 }
55}
56
57fn argv_has_flag(line_argv: &[String], long: &str, short: Option<&str>) -> bool {
58 line_argv.iter().any(|token| {
59 token == long
60 || short.is_some_and(|value| token == value)
61 || token.starts_with(&format!("{long}="))
62 })
63}
64
65fn argv_has_any_flag(line_argv: &[String], flags: &[&str]) -> bool {
66 line_argv.iter().any(|token| flags.iter().any(|flag| token == flag))
67}
68
69#[must_use]
71pub fn repl_argv_from_line(line: &str) -> Vec<String> {
72 let tokenized = parse_shell_tokens_lossy(line);
73 std::iter::once("bijux".to_string()).chain(tokenized).collect()
74}
75
76fn command_exceeds_limit(command: &str) -> bool {
77 command.chars().count() > REPL_COMMAND_MAX_CHARS
78}
79
80fn needs_multiline_continuation(line: &str) -> bool {
81 let trimmed = line.trim_end();
82 let trailing_backslashes = trimmed.chars().rev().take_while(|ch| *ch == '\\').count();
83 trailing_backslashes % 2 == 1
84}
85
86fn strip_single_continuation_backslash(line: &str) -> &str {
87 line.strip_suffix('\\').unwrap_or(line)
88}
89
90fn render_meta_help(path: &[String]) -> Result<String, ReplError> {
91 let mut command = root_command();
92 let mut curr = &mut command;
93 for segment in path {
94 if let Some(next) = curr.find_subcommand_mut(segment) {
95 curr = next;
96 } else {
97 return Err(ReplError::InvalidMetaCommand(format!(
98 "unknown help topic: {}",
99 path.join(" ")
100 )));
101 }
102 }
103
104 let mut bytes = Vec::new();
105 if curr.write_long_help(&mut bytes).is_ok() {
106 Ok(String::from_utf8(bytes).unwrap_or_else(|_| "Unable to render help\n".to_string()))
107 } else {
108 Ok("Unable to render help\n".to_string())
109 }
110}
111
112fn handle_meta_command(session: &mut ReplSession, line: &str) -> Result<ReplEvent, ReplError> {
113 let raw = line.trim_start_matches(META_PREFIX).trim();
114 let tokens = parse_shell_tokens_strict(raw)?;
115 if tokens.is_empty() {
116 return Err(ReplError::InvalidMetaCommand(line.to_string()));
117 }
118
119 match tokens[0].as_str() {
120 "help" => {
121 let body = render_meta_help(&tokens[1..])?;
122 Ok(ReplEvent::Continue(Some(ReplFrame {
123 stream: ReplStream::Stdout,
124 content: if body.ends_with('\n') { body } else { format!("{body}\n") },
125 })))
126 }
127 "set" if tokens.len() == 3 => {
128 match (tokens[1].as_str(), tokens[2].as_str()) {
129 ("trace", "on") => session.trace_mode = true,
130 ("trace", "off") => session.trace_mode = false,
131 ("quiet", "on") => session.policy.quiet = true,
132 ("quiet", "off") => session.policy.quiet = false,
133 ("format", value) => {
134 session.policy.output_format = output_format_from_name(value)
135 .ok_or_else(|| ReplError::InvalidMetaCommand(line.to_string()))?;
136 }
137 _ => return Err(ReplError::InvalidMetaCommand(line.to_string())),
138 }
139
140 Ok(ReplEvent::Continue(Some(ReplFrame {
141 stream: ReplStream::Stdout,
142 content: "ok\n".to_string(),
143 })))
144 }
145 "exit" | "quit" if tokens.len() == 1 => Ok(ReplEvent::Exit(None)),
146 _ => Err(ReplError::InvalidMetaCommand(line.to_string())),
147 }
148}
149
150fn apply_session_policy_to_argv(session: &ReplSession, line_argv: &[String]) -> Vec<String> {
151 let mut argv = vec!["bijux".to_string()];
152
153 let has_output_override = argv_has_any_flag(line_argv, &["--json", "--text"])
154 || argv_has_flag(line_argv, "--format", Some("-f"));
155 if !has_output_override {
156 argv.push("--format".to_string());
157 argv.push(output_format_name(session.policy.output_format).to_string());
158 }
159
160 if !argv_has_any_flag(line_argv, &["--pretty", "--no-pretty"]) {
161 argv.push(
162 match session.policy.pretty_mode {
163 PrettyMode::Pretty => "--pretty",
164 PrettyMode::Compact => "--no-pretty",
165 }
166 .to_string(),
167 );
168 }
169
170 if session.policy.quiet && !argv_has_any_flag(line_argv, &["--quiet", "-q"]) {
171 argv.push("--quiet".to_string());
172 }
173
174 if !argv_has_flag(line_argv, "--color", None) {
175 argv.push("--color".to_string());
176 argv.push(
177 match session.policy.color_mode {
178 ColorMode::Auto => "auto",
179 ColorMode::Always => "always",
180 ColorMode::Never => "never",
181 }
182 .to_string(),
183 );
184 }
185
186 if !argv_has_flag(line_argv, "--log-level", None) {
187 argv.push("--log-level".to_string());
188 argv.push(if session.trace_mode {
189 "trace".to_string()
190 } else {
191 match session.policy.log_level {
192 LogLevel::Trace => "trace",
193 LogLevel::Debug => "debug",
194 LogLevel::Info => "info",
195 LogLevel::Warning => "warning",
196 LogLevel::Error => "error",
197 _ => "info",
198 }
199 .to_string()
200 });
201 }
202
203 if !argv_has_flag(line_argv, "--config-path", None) {
204 if let Some(config_path) = &session.config_path {
205 argv.push("--config-path".to_string());
206 argv.push(config_path.clone());
207 }
208 }
209
210 if line_argv.len() > 1 {
211 argv.extend_from_slice(&line_argv[1..]);
212 }
213 argv
214}
215
216pub fn execute_repl_input(
218 session: &mut ReplSession,
219 input: ReplInput,
220) -> Result<ReplEvent, ReplError> {
221 match input {
222 ReplInput::Interrupt => {
223 session.pending_multiline = None;
224 session.commands_executed += 1;
225 session.last_exit_code = 130;
226 set_last_error(session, "Interrupted");
227 Ok(ReplEvent::Interrupted(ReplFrame {
228 stream: ReplStream::Stderr,
229 content: "Interrupted\n".to_string(),
230 }))
231 }
232 ReplInput::Eof => {
233 if session.pending_multiline.take().is_some() {
234 session.commands_executed += 1;
235 session.last_exit_code = 2;
236 set_last_error(session, "EOF received with pending multiline command");
237 }
238 Ok(ReplEvent::Exit(None))
239 }
240 ReplInput::Line(line) => {
241 let trimmed = line.trim();
242 if trimmed.is_empty() {
243 return Ok(ReplEvent::Continue(None));
244 }
245 if command_exceeds_limit(trimmed) {
246 session.commands_executed += 1;
247 session.last_exit_code = 2;
248 set_last_error(
249 session,
250 &format!("command exceeded {} characters", REPL_COMMAND_MAX_CHARS),
251 );
252 return Err(ReplError::InvalidCommandInput(
253 "command length limit exceeded".to_string(),
254 ));
255 }
256
257 if needs_multiline_continuation(trimmed) {
258 let chunk = strip_single_continuation_backslash(trimmed).trim_end();
259 let pending = match session.pending_multiline.take() {
260 Some(existing) => format!("{existing}\n{chunk}"),
261 None => chunk.to_string(),
262 };
263 if pending.chars().count() > REPL_MULTILINE_BUFFER_MAX_CHARS {
264 session.commands_executed += 1;
265 session.last_exit_code = 2;
266 set_last_error(
267 session,
268 &format!(
269 "multiline command exceeded {} characters",
270 REPL_MULTILINE_BUFFER_MAX_CHARS
271 ),
272 );
273 return Err(ReplError::InvalidCommandInput(
274 "multiline command buffer limit exceeded".to_string(),
275 ));
276 }
277 session.pending_multiline = Some(pending);
278 return Ok(ReplEvent::Continue(None));
279 }
280
281 let final_line = if let Some(existing) = session.pending_multiline.take() {
282 format!("{existing}\n{trimmed}")
283 } else {
284 trimmed.to_string()
285 };
286 if command_exceeds_limit(&final_line) {
287 session.commands_executed += 1;
288 session.last_exit_code = 2;
289 set_last_error(
290 session,
291 &format!("command exceeded {} characters", REPL_COMMAND_MAX_CHARS),
292 );
293 return Err(ReplError::InvalidCommandInput(
294 "command length limit exceeded".to_string(),
295 ));
296 }
297
298 if final_line.starts_with(META_PREFIX) {
299 let outcome = handle_meta_command(session, &final_line);
300 match &outcome {
301 Ok(ReplEvent::Continue(_)) | Ok(ReplEvent::Exit(_)) => {
302 session.commands_executed += 1;
303 session.last_exit_code = 0;
304 session.last_error = None;
305 }
306 Ok(ReplEvent::Interrupted(_)) => {
307 session.commands_executed += 1;
308 session.last_exit_code = 130;
309 set_last_error(session, "Interrupted");
310 }
311 Err(error) => {
312 session.commands_executed += 1;
313 session.last_exit_code = 2;
314 set_last_error(session, &error.to_string());
315 }
316 }
317 return outcome;
318 }
319
320 let tokenized = match parse_shell_tokens_strict(&final_line) {
321 Ok(value) => value,
322 Err(error) => {
323 session.commands_executed += 1;
324 session.last_exit_code = 2;
325 set_last_error(session, &error.to_string());
326 return Err(error);
327 }
328 };
329 let argv = std::iter::once("bijux".to_string()).chain(tokenized).collect::<Vec<_>>();
330 let history_line = final_line.replace('\n', " ");
331 push_history(session, &history_line);
332
333 let effective_argv = apply_session_policy_to_argv(session, &argv);
334 let result = match run_app(&effective_argv) {
335 Ok(value) => value,
336 Err(error) => {
337 session.commands_executed += 1;
338 session.last_exit_code = 1;
339 set_last_error(session, &error.to_string());
340 return Err(ReplError::Core(error.to_string()));
341 }
342 };
343
344 session.commands_executed += 1;
345 session.last_exit_code = result.exit_code;
346
347 let frame = if result.exit_code != 0 && !result.stderr.is_empty() {
348 set_last_error(session, &result.stderr);
349 Some(ReplFrame { stream: ReplStream::Stderr, content: result.stderr })
350 } else if !result.stdout.is_empty() {
351 if result.exit_code == 0 {
352 session.last_error = None;
353 } else {
354 set_last_error(
355 session,
356 &format!("command failed with exit code {}", result.exit_code),
357 );
358 }
359 Some(ReplFrame { stream: ReplStream::Stdout, content: result.stdout })
360 } else if !result.stderr.is_empty() {
361 if result.exit_code == 0 {
362 session.last_error = None;
363 } else {
364 set_last_error(session, &result.stderr);
365 }
366 Some(ReplFrame { stream: ReplStream::Stderr, content: result.stderr })
367 } else {
368 if result.exit_code == 0 {
369 session.last_error = None;
370 } else {
371 set_last_error(
372 session,
373 &format!("command failed with exit code {}", result.exit_code),
374 );
375 }
376 None
377 };
378
379 Ok(ReplEvent::Continue(frame))
380 }
381 }
382}
383
384pub fn execute_repl_line(
386 session: &mut ReplSession,
387 line: &str,
388) -> Result<Option<ReplFrame>, ReplError> {
389 match execute_repl_input(session, ReplInput::Line(line.to_string()))? {
390 ReplEvent::Continue(frame) => Ok(frame),
391 ReplEvent::Exit(frame) => Ok(frame),
392 ReplEvent::Interrupted(frame) => Ok(Some(frame)),
393 }
394}
395
396#[cfg(test)]
397mod tests {
398 use super::{
399 execute_repl_input, execute_repl_line, needs_multiline_continuation, repl_argv_from_line,
400 };
401 use crate::interface::repl::session::startup_repl;
402 use crate::interface::repl::types::{
403 ReplError, ReplInput, REPL_COMMAND_MAX_CHARS, REPL_MULTILINE_BUFFER_MAX_CHARS,
404 };
405
406 #[test]
407 fn malformed_shell_input_returns_deterministic_invalid_input_error() {
408 let (mut session, _) = startup_repl("", None);
409 let result = execute_repl_line(&mut session, "status --config-path \"unterminated");
410 assert!(matches!(result, Err(ReplError::InvalidCommandInput(_))));
411 assert_eq!(session.last_exit_code, 2);
412 assert_eq!(session.commands_executed, 1);
413 assert!(session.last_error.is_some());
414 }
415
416 #[test]
417 fn meta_set_requires_exact_arity_and_sets_usage_exit_code() {
418 let (mut session, _) = startup_repl("", None);
419 let result = execute_repl_line(&mut session, ":set format json extra");
420 assert!(matches!(result, Err(ReplError::InvalidMetaCommand(_))));
421 assert_eq!(session.last_exit_code, 2);
422 assert!(session.last_error.as_deref().unwrap_or_default().contains("invalid repl command"));
423 }
424
425 #[test]
426 fn successful_command_clears_previous_error_state() {
427 let (mut session, _) = startup_repl("", None);
428
429 let _ = execute_repl_line(&mut session, "config get");
430 assert!(session.last_error.is_some());
431
432 let result = execute_repl_line(&mut session, "status --format json --no-pretty")
433 .expect("status command should execute");
434 assert!(result.is_some());
435 assert_eq!(session.last_exit_code, 0);
436 assert!(session.last_error.is_none());
437 }
438
439 #[test]
440 fn meta_help_unknown_topic_is_usage_error() {
441 let (mut session, _) = startup_repl("", None);
442 let result = execute_repl_line(&mut session, ":help definitely-missing-command");
443 assert!(matches!(result, Err(ReplError::InvalidMetaCommand(_))));
444 assert_eq!(session.last_exit_code, 2);
445 assert_eq!(session.commands_executed, 1);
446 }
447
448 #[test]
449 fn interrupt_updates_last_error_and_counter() {
450 let (mut session, _) = startup_repl("", None);
451 let event = execute_repl_input(&mut session, ReplInput::Interrupt)
452 .expect("interrupt should return event");
453 assert!(matches!(event, crate::interface::repl::types::ReplEvent::Interrupted(_)));
454 assert_eq!(session.last_exit_code, 130);
455 assert_eq!(session.commands_executed, 1);
456 assert_eq!(session.last_error.as_deref(), Some("Interrupted"));
457 }
458
459 #[test]
460 fn continuation_requires_odd_trailing_backslash_count() {
461 assert!(needs_multiline_continuation("status \\"));
462 assert!(!needs_multiline_continuation("status \\\\"));
463 }
464
465 #[test]
466 fn eof_with_pending_multiline_sets_usage_error_state() {
467 let (mut session, _) = startup_repl("", None);
468 let _ = execute_repl_input(&mut session, ReplInput::Line("status \\".to_string()))
469 .expect("line should set multiline pending");
470 let _ = execute_repl_input(&mut session, ReplInput::Eof).expect("eof should exit cleanly");
471 assert_eq!(session.last_exit_code, 2);
472 assert_eq!(session.commands_executed, 1);
473 assert!(session.last_error.as_deref().unwrap_or_default().contains("pending multiline"));
474 }
475
476 #[test]
477 fn meta_exit_with_extra_args_is_invalid() {
478 let (mut session, _) = startup_repl("", None);
479 let result = execute_repl_line(&mut session, ":exit now");
480 assert!(matches!(result, Err(ReplError::InvalidMetaCommand(_))));
481 assert_eq!(session.last_exit_code, 2);
482 }
483
484 #[test]
485 fn multiline_buffer_has_deterministic_upper_bound() {
486 let (mut session, _) = startup_repl("", None);
487 let oversized = format!("{}\\", "x".repeat(REPL_MULTILINE_BUFFER_MAX_CHARS + 1));
488 let result = execute_repl_line(&mut session, &oversized);
489 assert!(matches!(result, Err(ReplError::InvalidCommandInput(_))));
490 assert_eq!(session.last_exit_code, 2);
491 }
492
493 #[test]
494 fn single_line_command_length_limit_is_enforced() {
495 let (mut session, _) = startup_repl("", None);
496 let oversized = format!("status {}", "x".repeat(REPL_COMMAND_MAX_CHARS + 1));
497 let result = execute_repl_line(&mut session, &oversized);
498 assert!(matches!(result, Err(ReplError::InvalidCommandInput(_))));
499 assert_eq!(session.last_exit_code, 2);
500 assert_eq!(session.commands_executed, 1);
501 }
502
503 #[test]
504 fn argv_helper_keeps_unmatched_quote_input_atomic() {
505 let argv = repl_argv_from_line("status --config-path \"unterminated");
506 assert_eq!(
507 argv,
508 vec!["bijux".to_string(), "status --config-path \"unterminated".to_string()]
509 );
510 }
511}