Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/adapter/check_adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ pub struct CheckAdapter {
}

impl CheckAdapter {
/// Build from the `--command` CLI argument.
/// Build from positional command arguments.
pub fn from_command(command: String) -> Self {
Self {
command,
Expand Down
26 changes: 13 additions & 13 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,6 @@ pub struct ExecArgs {
#[derive(clap::Args)]
#[cfg_attr(test, derive(Debug, PartialEq))]
pub struct CheckArgs {
/// Command string to check (skips stdin)
#[arg(long)]
pub command: Option<String>,

/// Input format: "claude-code-hook" or omit for auto-detection
#[arg(long)]
pub input_format: Option<String>,
Expand All @@ -61,6 +57,10 @@ pub struct CheckArgs {
/// Output detailed rule matching information to stderr
#[arg(long)]
pub verbose: bool,

/// Command and arguments to check (skips stdin)
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
pub command: Vec<String>,
}

#[derive(clap::ValueEnum, Clone, Debug, PartialEq)]
Expand Down Expand Up @@ -96,24 +96,24 @@ mod tests {
Commands::Exec(ExecArgs { command: vec!["ls".into()], sandbox: None, dry_run: true, verbose: true }),
)]
#[case::check_with_command(
&["runok", "check", "--command", "git status"],
Commands::Check(CheckArgs { command: Some("git status".into()), input_format: None, output_format: OutputFormat::Text, verbose: false }),
&["runok", "check", "--", "git", "status"],
Commands::Check(CheckArgs { input_format: None, output_format: OutputFormat::Text, verbose: false, command: vec!["git".into(), "status".into()] }),
)]
#[case::check_with_input_format(
&["runok", "check", "--input-format", "claude-code-hook"],
Commands::Check(CheckArgs { command: None, input_format: Some("claude-code-hook".into()), output_format: OutputFormat::Text, verbose: false }),
Commands::Check(CheckArgs { input_format: Some("claude-code-hook".into()), output_format: OutputFormat::Text, verbose: false, command: vec![] }),
)]
#[case::check_with_output_format_json(
&["runok", "check", "--output-format", "json", "--command", "ls"],
Commands::Check(CheckArgs { command: Some("ls".into()), input_format: None, output_format: OutputFormat::Json, verbose: false }),
&["runok", "check", "--output-format", "json", "--", "ls"],
Commands::Check(CheckArgs { input_format: None, output_format: OutputFormat::Json, verbose: false, command: vec!["ls".into()] }),
)]
#[case::check_with_both(
&["runok", "check", "--command", "ls", "--input-format", "claude-code-hook"],
Commands::Check(CheckArgs { command: Some("ls".into()), input_format: Some("claude-code-hook".into()), output_format: OutputFormat::Text, verbose: false }),
&["runok", "check", "--input-format", "claude-code-hook", "--", "ls"],
Commands::Check(CheckArgs { input_format: Some("claude-code-hook".into()), output_format: OutputFormat::Text, verbose: false, command: vec!["ls".into()] }),
)]
#[case::check_with_verbose(
&["runok", "check", "--verbose", "--command", "git status"],
Commands::Check(CheckArgs { command: Some("git status".into()), input_format: None, output_format: OutputFormat::Text, verbose: true }),
&["runok", "check", "--verbose", "--", "git", "status"],
Commands::Check(CheckArgs { input_format: None, output_format: OutputFormat::Text, verbose: true, command: vec!["git".into(), "status".into()] }),
)]
fn cli_parsing(#[case] argv: &[&str], #[case] expected: Commands) {
let cli = Cli::parse_from(argv);
Expand Down
73 changes: 41 additions & 32 deletions src/cli/route.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use crate::adapter::Endpoint;
use crate::adapter::check_adapter::{CheckAdapter, CheckInput, OutputFormat};
use crate::adapter::hook_adapter::{ClaudeCodeHookAdapter, HookInput};
use runok::rules::command_parser::shell_quote_join;

use super::CheckArgs;

Expand All @@ -14,7 +15,7 @@ fn to_adapter_output_format(cli_format: &super::OutputFormat) -> OutputFormat {

/// Result of routing `runok check`: either a single endpoint or multiple commands.
pub enum CheckRoute {
/// Single endpoint (--command, JSON stdin, or single-line plaintext).
/// Single endpoint (positional command, JSON stdin, or single-line plaintext).
Single(Box<dyn Endpoint>),
/// Multiple commands from multi-line plaintext stdin.
Multi(Vec<CheckAdapter>),
Expand All @@ -27,10 +28,17 @@ pub fn route_check(
) -> Result<CheckRoute, anyhow::Error> {
let output_format = to_adapter_output_format(&args.output_format);

// 1. --command CLI argument → always generic mode (no stdin)
if let Some(command) = &args.command {
// 1. Positional command arguments → always generic mode (no stdin)
if !args.command.is_empty() {
// Return single arguments unquoted so the rule engine can detect
// shell metacharacters (&&, ;, |) in compound commands.
let command = if args.command.len() == 1 {
args.command[0].clone()
} else {
shell_quote_join(&args.command)
};
return Ok(CheckRoute::Single(Box::new(
CheckAdapter::from_command(command.clone()).with_output_format(output_format),
CheckAdapter::from_command(command).with_output_format(output_format),
)));
}

Expand All @@ -47,14 +55,14 @@ pub fn route_check(
return route_json(args, json_value);
}

// 4. --format requires JSON; plaintext fallback is not allowed when --format is specified
// 4. --input-format requires JSON; plaintext fallback is not allowed when --input-format is specified
if let Some(format) = &args.input_format {
return Err(anyhow::anyhow!(
"JSON parse error: input must be valid JSON when --input-format '{format}' is specified"
));
}

// 5. JSON parse failed, no --format → treat as plaintext (one command per line, skip empty lines)
// 5. JSON parse failed, no --input-format → treat as plaintext (one command per line, skip empty lines)
let commands: Vec<String> = stdin_input
.lines()
.map(|line| line.trim())
Expand Down Expand Up @@ -86,7 +94,7 @@ fn route_json(
args: &CheckArgs,
json_value: serde_json::Value,
) -> Result<CheckRoute, anyhow::Error> {
// --format is explicitly specified → use that format
// --input-format is explicitly specified → use that format
if let Some(format) = &args.input_format {
return match format.as_str() {
"claude-code-hook" => {
Expand All @@ -101,7 +109,7 @@ fn route_json(
};
}

// --format omitted → auto-detect by JSON field presence
// --input-format omitted → auto-detect by JSON field presence
// HookInput uses #[serde(rename_all = "snake_case")], so the actual JSON key is "tool_name"
if json_value.get("tool_name").is_some() {
let hook_input: HookInput = serde_json::from_value(json_value)?;
Expand All @@ -128,12 +136,12 @@ mod tests {
use rstest::rstest;

/// Helper: build CheckArgs for testing
fn check_args(command: Option<&str>, input_format: Option<&str>) -> CheckArgs {
fn check_args(command: Vec<&str>, input_format: Option<&str>) -> CheckArgs {
CheckArgs {
command: command.map(String::from),
input_format: input_format.map(String::from),
output_format: crate::cli::OutputFormat::Text,
verbose: false,
command: command.into_iter().map(String::from).collect(),
}
}

Expand All @@ -153,20 +161,21 @@ mod tests {
}
}

// === route_check: --command flag ===
// === route_check: positional command args ===

#[rstest]
#[case::simple_command("git status")]
#[case::command_with_flags("ls -la /tmp")]
fn route_check_with_command_arg(#[case] cmd: &str) {
let args = check_args(Some(cmd), None);
#[case::simple_command(&["git", "status"], "git status")]
#[case::command_with_flags(&["ls", "-la", "/tmp"], "ls -la /tmp")]
#[case::arg_with_spaces(&["echo", "hello world"], "echo 'hello world'")]
fn route_check_with_command_arg(#[case] cmd: &[&str], #[case] expected: &str) {
let args = check_args(cmd.to_vec(), None);
let route = route_check(&args, std::io::empty());
let endpoint = unwrap_single(route.unwrap_or_else(|e| panic!("unexpected error: {e}")));
assert_eq!(
endpoint
.extract_command()
.unwrap_or_else(|e| panic!("unexpected error: {e}")),
Some(cmd.to_string())
Some(expected.to_string())
);
}

Expand All @@ -193,7 +202,7 @@ mod tests {
#[case] stdin_json: &str,
#[case] expected_command: Option<&str>,
) {
let args = check_args(None, None);
let args = check_args(vec![], None);
let route = route_check(&args, stdin_json.as_bytes());
let endpoint = unwrap_single(route.unwrap_or_else(|e| panic!("unexpected error: {e}")));
assert_eq!(
Expand All @@ -206,7 +215,7 @@ mod tests {

#[rstest]
fn route_check_stdin_unknown_json_format_returns_error() {
let args = check_args(None, None);
let args = check_args(vec![], None);
let result = route_check(&args, r#"{"unknown_field": "value"}"#.as_bytes());
match result {
Err(e) => assert!(
Expand All @@ -217,11 +226,11 @@ mod tests {
}
}

// === route_check: --format with non-JSON stdin ===
// === route_check: --input-format with non-JSON stdin ===

#[rstest]
fn route_check_format_with_non_json_stdin_returns_error() {
let args = check_args(None, Some("claude-code-hook"));
let args = check_args(vec![], Some("claude-code-hook"));
let result = route_check(&args, "not valid json".as_bytes());
match result {
Err(e) => assert!(
Expand All @@ -243,7 +252,7 @@ mod tests {
#[case] input: &str,
#[case] expected_command: &str,
) {
let args = check_args(None, None);
let args = check_args(vec![], None);
let route = route_check(&args, input.as_bytes());
let endpoint = unwrap_single(route.unwrap_or_else(|e| panic!("unexpected error: {e}")));
assert_eq!(
Expand All @@ -258,7 +267,7 @@ mod tests {

#[rstest]
fn route_check_plaintext_single_line() {
let args = check_args(None, None);
let args = check_args(vec![], None);
let route = route_check(&args, "git status\n".as_bytes());
let endpoint = unwrap_single(route.unwrap_or_else(|e| panic!("unexpected error: {e}")));
assert_eq!(
Expand All @@ -271,7 +280,7 @@ mod tests {

#[rstest]
fn route_check_plaintext_multi_line() {
let args = check_args(None, None);
let args = check_args(vec![], None);
let input = indoc! {"
git status
ls -la
Expand All @@ -291,7 +300,7 @@ mod tests {

#[rstest]
fn route_check_plaintext_skips_empty_lines() {
let args = check_args(None, None);
let args = check_args(vec![], None);
let input = indoc! {"
git status

Expand All @@ -312,7 +321,7 @@ mod tests {

#[rstest]
fn route_check_plaintext_trims_whitespace() {
let args = check_args(None, None);
let args = check_args(vec![], None);
let route = route_check(&args, " git status \n".as_bytes());
let endpoint = unwrap_single(route.unwrap_or_else(|e| panic!("unexpected error: {e}")));
assert_eq!(
Expand All @@ -325,7 +334,7 @@ mod tests {

#[rstest]
fn route_check_empty_stdin_returns_error() {
let args = check_args(None, None);
let args = check_args(vec![], None);
let result = route_check(&args, "".as_bytes());
match result {
Err(e) => assert!(
Expand All @@ -338,7 +347,7 @@ mod tests {

#[rstest]
fn route_check_only_empty_lines_returns_error() {
let args = check_args(None, None);
let args = check_args(vec![], None);
let result = route_check(&args, "\n\n \n".as_bytes());
match result {
Err(e) => assert!(
Expand All @@ -349,11 +358,11 @@ mod tests {
}
}

// === route_check: --command flag takes precedence ===
// === route_check: positional command takes precedence ===

#[rstest]
fn route_check_command_flag_takes_precedence_over_stdin() {
let args = check_args(Some("echo hello"), Some("claude-code-hook"));
let args = check_args(vec!["echo", "hello"], Some("claude-code-hook"));
let route = route_check(&args, std::io::empty());
let endpoint = unwrap_single(route.unwrap_or_else(|e| panic!("unexpected error: {e}")));
assert_eq!(
Expand All @@ -364,11 +373,11 @@ mod tests {
);
}

// === route_check: --format flag ===
// === route_check: --input-format flag ===

#[rstest]
fn route_check_explicit_format_claude_code_hook() {
let args = check_args(None, Some("claude-code-hook"));
let args = check_args(vec![], Some("claude-code-hook"));
let stdin_json = indoc! {r#"
{
"tool_name": "Bash",
Expand All @@ -393,7 +402,7 @@ mod tests {

#[rstest]
fn route_check_unknown_format_returns_error() {
let args = check_args(None, Some("invalid-format"));
let args = check_args(vec![], Some("invalid-format"));
let result = route_check(&args, r#"{"command": "ls"}"#.as_bytes());
match result {
Err(e) => assert!(
Expand Down
10 changes: 5 additions & 5 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,10 +140,10 @@ mod tests {
#[rstest]
fn run_command_check_with_command_returns_zero() {
let cmd = Commands::Check(CheckArgs {
command: Some("echo hello".into()),
input_format: None,
output_format: cli::OutputFormat::Text,
verbose: false,
command: vec!["echo".into(), "hello".into()],
});
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let exit_code = run_command(cmd, &cwd, std::io::empty());
Expand All @@ -153,10 +153,10 @@ mod tests {
#[rstest]
fn run_command_check_with_empty_stdin_returns_two() {
let cmd = Commands::Check(CheckArgs {
command: None,
input_format: None,
output_format: cli::OutputFormat::Text,
verbose: false,
command: vec![],
});
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let exit_code = run_command(cmd, &cwd, "".as_bytes());
Expand All @@ -166,10 +166,10 @@ mod tests {
#[rstest]
fn run_command_check_with_stdin_json_returns_zero() {
let cmd = Commands::Check(CheckArgs {
command: None,
input_format: None,
output_format: cli::OutputFormat::Text,
verbose: false,
command: vec![],
});
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let exit_code = run_command(cmd, &cwd, r#"{"command": "ls"}"#.as_bytes());
Expand All @@ -179,10 +179,10 @@ mod tests {
#[rstest]
fn run_command_check_with_plaintext_stdin_returns_zero() {
let cmd = Commands::Check(CheckArgs {
command: None,
input_format: None,
output_format: cli::OutputFormat::Text,
verbose: false,
command: vec![],
});
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let exit_code = run_command(cmd, &cwd, "echo hello\n".as_bytes());
Expand Down Expand Up @@ -212,10 +212,10 @@ mod tests {
#[rstest]
fn run_command_check_with_multiline_plaintext_stdin_returns_zero() {
let cmd = Commands::Check(CheckArgs {
command: None,
input_format: None,
output_format: cli::OutputFormat::Text,
verbose: false,
command: vec![],
});
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let input = indoc! {"
Expand Down
Loading
Loading