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