Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -39,7 +39,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
22 changes: 11 additions & 11 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,17 +46,17 @@ 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 format: Option<String>,

/// 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>,
}

#[cfg(test)]
Expand Down Expand Up @@ -86,20 +86,20 @@ 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()), format: None, verbose: false }),
&["runok", "check", "--", "git", "status"],
Commands::Check(CheckArgs { format: None, verbose: false, command: vec!["git".into(), "status".into()] }),
)]
#[case::check_with_format(
&["runok", "check", "--format", "claude-code-hook"],
Commands::Check(CheckArgs { command: None, format: Some("claude-code-hook".into()), verbose: false }),
Commands::Check(CheckArgs { format: Some("claude-code-hook".into()), verbose: false, command: vec![] }),
)]
#[case::check_with_both(
&["runok", "check", "--command", "ls", "--format", "claude-code-hook"],
Commands::Check(CheckArgs { command: Some("ls".into()), format: Some("claude-code-hook".into()), verbose: false }),
&["runok", "check", "--format", "claude-code-hook", "--", "ls"],
Commands::Check(CheckArgs { format: Some("claude-code-hook".into()), verbose: false, command: vec!["ls".into()] }),
)]
#[case::check_with_verbose(
&["runok", "check", "--verbose", "--command", "git status"],
Commands::Check(CheckArgs { command: Some("git status".into()), format: None, verbose: true }),
&["runok", "check", "--verbose", "--", "git", "status"],
Commands::Check(CheckArgs { format: None, 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
49 changes: 25 additions & 24 deletions src/cli/route.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use super::CheckArgs;

/// 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 @@ -17,10 +17,11 @@ pub fn route_check(
args: &CheckArgs,
mut stdin: impl std::io::Read,
) -> Result<CheckRoute, anyhow::Error> {
// 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() {
let command = args.command.join(" ");
return Ok(CheckRoute::Single(Box::new(CheckAdapter::from_command(
command.clone(),
command,
))));
}

Expand Down Expand Up @@ -116,11 +117,11 @@ mod tests {
use rstest::rstest;

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

Expand All @@ -143,17 +144,17 @@ mod tests {
// === route_check: --command flag ===

#[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")]
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 @@ -180,7 +181,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 @@ -193,7 +194,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 @@ -208,7 +209,7 @@ mod tests {

#[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 @@ -229,7 +230,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 @@ -244,7 +245,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 @@ -257,7 +258,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 @@ -277,7 +278,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 @@ -298,7 +299,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 @@ -311,7 +312,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 @@ -324,7 +325,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 @@ -339,7 +340,7 @@ mod tests {

#[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 @@ -354,7 +355,7 @@ mod tests {

#[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 @@ -379,7 +380,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,9 +140,9 @@ mod tests {
#[rstest]
fn run_command_check_with_command_returns_zero() {
let cmd = Commands::Check(CheckArgs {
command: Some("echo hello".into()),
format: None,
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 @@ -152,9 +152,9 @@ mod tests {
#[rstest]
fn run_command_check_with_empty_stdin_returns_two() {
let cmd = Commands::Check(CheckArgs {
command: None,
format: None,
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 @@ -164,9 +164,9 @@ mod tests {
#[rstest]
fn run_command_check_with_stdin_json_returns_zero() {
let cmd = Commands::Check(CheckArgs {
command: None,
format: None,
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 @@ -176,9 +176,9 @@ mod tests {
#[rstest]
fn run_command_check_with_plaintext_stdin_returns_zero() {
let cmd = Commands::Check(CheckArgs {
command: None,
format: None,
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 @@ -208,9 +208,9 @@ mod tests {
#[rstest]
fn run_command_check_with_multiline_plaintext_stdin_returns_zero() {
let cmd = Commands::Check(CheckArgs {
command: None,
format: None,
verbose: false,
command: vec![],
});
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let input = indoc! {"
Expand Down
18 changes: 10 additions & 8 deletions tests/e2e/check_generic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,21 @@ fn check_env() -> TestEnv {
// --- CLI argument mode ---

#[rstest]
#[case::deny_rm("rm -rf /", 0, "deny")]
#[case::allow_git_status("git status", 0, "allow")]
#[case::comment_before_command("# description\ngit status", 0, "allow")]
#[case::comment_only("# just a comment", 0, "ask")]
#[case::deny_rm(&["rm", "-rf", "/"], 0, "deny")]
#[case::allow_git_status(&["git", "status"], 0, "allow")]
#[case::comment_before_command(&["# description\ngit status"], 0, "allow")]
#[case::comment_only(&["# just a comment"], 0, "ask")]
fn check_command_arg(
check_env: TestEnv,
#[case] command: &str,
#[case] command: &[&str],
#[case] expected_exit: i32,
#[case] expected_decision: &str,
) {
let assert = check_env
.command()
.args(["check", "--command", command])
.arg("check")
.arg("--")
.args(command)
.assert();
let output = assert.code(expected_exit).get_output().stdout.clone();
let json: serde_json::Value =
Expand Down Expand Up @@ -93,7 +95,7 @@ fn check_plaintext_stdin_single_line(check_env: TestEnv) {
fn check_deny_includes_reason(check_env: TestEnv) {
let assert = check_env
.command()
.args(["check", "--command", "rm -rf /"])
.args(["check", "--", "rm", "-rf", "/"])
.assert();
let output = assert.code(0).get_output().stdout.clone();
let json: serde_json::Value =
Expand All @@ -120,7 +122,7 @@ fn check_allow_with_sandbox_info() {
"});
let assert = env
.command()
.args(["check", "--command", "python3 script.py"])
.args(["check", "--", "python3", "script.py"])
.assert();
let output = assert.code(0).get_output().stdout.clone();
let json: serde_json::Value =
Expand Down
Loading