Skip to main content

bijux_cli/interface/repl/
execution.rs

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/// Build argv using the same tokenization path REPL execution uses.
72#[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
218/// Execute one REPL input event with interrupt/EOF-safe behavior.
219pub 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
386/// Backward-compatible one-line execution adapter.
387pub 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}