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        "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/// Build argv using the same tokenization path REPL execution uses.
70#[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
216/// Execute one REPL input event with interrupt/EOF-safe behavior.
217pub 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
384/// Backward-compatible one-line execution adapter.
385pub 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}