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
61 changes: 56 additions & 5 deletions src/adapter/check_adapter.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
use std::fmt;

use serde::{Deserialize, Serialize};

use crate::config::{ActionKind, Defaults, MergedSandboxPolicy};
use crate::rules::rule_engine::Action;

use super::{ActionResult, Endpoint, SandboxInfo};

/// Output format for `runok check` (generic mode).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum OutputFormat {
Json,
#[default]
Text,
}

/// stdin JSON input for `runok check`.
#[derive(Debug, Deserialize)]
pub struct CheckInput {
Expand Down Expand Up @@ -36,20 +46,31 @@ pub struct CheckSandboxInfo {
/// Generic check endpoint implementing `Endpoint`.
pub struct CheckAdapter {
command: String,
output_format: OutputFormat,
}

impl CheckAdapter {
/// Build from the `--command` CLI argument.
pub fn from_command(command: String) -> Self {
Self { command }
Self {
command,
output_format: OutputFormat::default(),
}
}

/// Build from stdin JSON input.
pub fn from_stdin(input: CheckInput) -> Self {
Self {
command: input.command,
output_format: OutputFormat::default(),
}
}

/// Set the output format.
pub fn with_output_format(mut self, output_format: OutputFormat) -> Self {
self.output_format = output_format;
self
}
}

/// Build a `CheckOutput` from an `ActionResult`.
Expand Down Expand Up @@ -91,22 +112,37 @@ fn build_no_match_output(defaults: &Defaults) -> CheckOutput {
}
}

/// Format a `CheckOutput` as human-readable text.
impl fmt::Display for CheckOutput {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.decision)?;
if let Some(ref reason) = self.reason {
write!(f, ": {reason}")?;
}
if let Some(ref suggestion) = self.fix_suggestion {
write!(f, " (suggestion: {suggestion})")?;
}
if let Some(ref sandbox) = self.sandbox {
write!(f, " [sandbox: {}]", sandbox.preset)?;
}
Ok(())
}
}

impl Endpoint for CheckAdapter {
fn extract_command(&self) -> Result<Option<String>, anyhow::Error> {
Ok(Some(self.command.clone()))
}

fn handle_action(&self, result: ActionResult) -> Result<i32, anyhow::Error> {
let output = build_check_output(&result);
let json = serde_json::to_string(&output)?;
println!("{json}");
self.print_output(&output)?;
Ok(0)
}

fn handle_no_match(&self, defaults: &Defaults) -> Result<i32, anyhow::Error> {
let output = build_no_match_output(defaults);
let json = serde_json::to_string(&output)?;
println!("{json}");
self.print_output(&output)?;
Ok(0)
}

Expand All @@ -116,6 +152,21 @@ impl Endpoint for CheckAdapter {
}
}

impl CheckAdapter {
fn print_output(&self, output: &CheckOutput) -> Result<(), anyhow::Error> {
match self.output_format {
OutputFormat::Json => {
let json = serde_json::to_string(output)?;
println!("{json}");
}
OutputFormat::Text => {
println!("{output}");
}
}
Ok(())
}
}

/// Convert `SandboxInfo` into the informational `CheckSandboxInfo` for the response.
fn build_sandbox_info(info: &SandboxInfo) -> Option<CheckSandboxInfo> {
match info {
Expand Down
30 changes: 22 additions & 8 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,23 @@ pub struct CheckArgs {

/// Input format: "claude-code-hook" or omit for auto-detection
#[arg(long)]
pub format: Option<String>,
pub input_format: Option<String>,

/// Output format
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
pub output_format: OutputFormat,

/// Output detailed rule matching information to stderr
#[arg(long)]
pub verbose: bool,
}

#[derive(clap::ValueEnum, Clone, Debug, PartialEq)]
pub enum OutputFormat {
Json,
Text,
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -87,19 +97,23 @@ mod tests {
)]
#[case::check_with_command(
&["runok", "check", "--command", "git status"],
Commands::Check(CheckArgs { command: Some("git status".into()), format: None, verbose: false }),
Commands::Check(CheckArgs { command: Some("git status".into()), input_format: None, output_format: OutputFormat::Text, verbose: false }),
)]
#[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 }),
)]
#[case::check_with_format(
&["runok", "check", "--format", "claude-code-hook"],
Commands::Check(CheckArgs { command: None, format: Some("claude-code-hook".into()), verbose: false }),
#[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 }),
)]
#[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", "--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 }),
)]
#[case::check_with_verbose(
&["runok", "check", "--verbose", "--command", "git status"],
Commands::Check(CheckArgs { command: Some("git status".into()), format: None, verbose: true }),
Commands::Check(CheckArgs { command: Some("git status".into()), input_format: None, output_format: OutputFormat::Text, verbose: true }),
)]
fn cli_parsing(#[case] argv: &[&str], #[case] expected: Commands) {
let cli = Cli::parse_from(argv);
Expand Down
53 changes: 34 additions & 19 deletions src/cli/route.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
use crate::adapter::Endpoint;
use crate::adapter::check_adapter::{CheckAdapter, CheckInput};
use crate::adapter::check_adapter::{CheckAdapter, CheckInput, OutputFormat};
use crate::adapter::hook_adapter::{ClaudeCodeHookAdapter, HookInput};

use super::CheckArgs;

/// Convert the CLI output format enum to the adapter output format enum.
fn to_adapter_output_format(cli_format: &super::OutputFormat) -> OutputFormat {
match cli_format {
super::OutputFormat::Json => OutputFormat::Json,
super::OutputFormat::Text => OutputFormat::Text,
}
}

/// Result of routing `runok check`: either a single endpoint or multiple commands.
pub enum CheckRoute {
/// Single endpoint (--command, JSON stdin, or single-line plaintext).
Expand All @@ -17,11 +25,13 @@ pub fn route_check(
args: &CheckArgs,
mut stdin: impl std::io::Read,
) -> 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 {
return Ok(CheckRoute::Single(Box::new(CheckAdapter::from_command(
command.clone(),
))));
return Ok(CheckRoute::Single(Box::new(
CheckAdapter::from_command(command.clone()).with_output_format(output_format),
)));
}

// 2. Read stdin once
Expand All @@ -38,9 +48,9 @@ pub fn route_check(
}

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

Expand All @@ -57,15 +67,16 @@ pub fn route_check(
}

if commands.len() == 1 {
return Ok(CheckRoute::Single(Box::new(CheckAdapter::from_command(
commands.into_iter().next().unwrap_or_default(),
))));
return Ok(CheckRoute::Single(Box::new(
CheckAdapter::from_command(commands.into_iter().next().unwrap_or_default())
.with_output_format(output_format),
)));
}

Ok(CheckRoute::Multi(
commands
.into_iter()
.map(CheckAdapter::from_command)
.map(|cmd| CheckAdapter::from_command(cmd).with_output_format(output_format))
.collect(),
))
}
Expand All @@ -76,7 +87,7 @@ fn route_json(
json_value: serde_json::Value,
) -> Result<CheckRoute, anyhow::Error> {
// --format is explicitly specified → use that format
if let Some(format) = &args.format {
if let Some(format) = &args.input_format {
return match format.as_str() {
"claude-code-hook" => {
let hook_input: HookInput = serde_json::from_value(json_value)?;
Expand All @@ -85,7 +96,7 @@ fn route_json(
))))
}
unknown => Err(anyhow::anyhow!(
"Unknown format: '{unknown}'. Valid formats: claude-code-hook"
"Unknown input format: '{unknown}'. Valid formats: claude-code-hook"
)),
};
}
Expand All @@ -98,10 +109,11 @@ fn route_json(
hook_input,
))))
} else if json_value.get("command").is_some() {
let output_format = to_adapter_output_format(&args.output_format);
let check_input: CheckInput = serde_json::from_value(json_value)?;
Ok(CheckRoute::Single(Box::new(CheckAdapter::from_stdin(
check_input,
))))
Ok(CheckRoute::Single(Box::new(
CheckAdapter::from_stdin(check_input).with_output_format(output_format),
)))
} else {
Err(anyhow::anyhow!(
"Unknown input format: expected 'tool_name' (Claude Code hook) or 'command' (generic) field"
Expand All @@ -116,10 +128,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: Option<&str>, input_format: Option<&str>) -> CheckArgs {
CheckArgs {
command: command.map(String::from),
format: format.map(String::from),
input_format: input_format.map(String::from),
output_format: crate::cli::OutputFormat::Text,
verbose: false,
}
}
Expand Down Expand Up @@ -212,7 +225,8 @@ mod tests {
let result = route_check(&args, "not valid json".as_bytes());
match result {
Err(e) => assert!(
e.to_string().contains("JSON parse error") && e.to_string().contains("--format"),
e.to_string().contains("JSON parse error")
&& e.to_string().contains("--input-format"),
"error was: {e}"
),
Ok(_) => panic!("expected an error"),
Expand Down Expand Up @@ -383,7 +397,8 @@ mod tests {
let result = route_check(&args, r#"{"command": "ls"}"#.as_bytes());
match result {
Err(e) => assert!(
e.to_string().contains("Unknown format: 'invalid-format'"),
e.to_string()
.contains("Unknown input format: 'invalid-format'"),
"error was: {e}"
),
Ok(_) => panic!("expected an error"),
Expand Down
15 changes: 10 additions & 5 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,8 @@ mod tests {
fn run_command_check_with_command_returns_zero() {
let cmd = Commands::Check(CheckArgs {
command: Some("echo hello".into()),
format: None,
input_format: None,
output_format: cli::OutputFormat::Text,
verbose: false,
});
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
Expand All @@ -153,7 +154,8 @@ mod tests {
fn run_command_check_with_empty_stdin_returns_two() {
let cmd = Commands::Check(CheckArgs {
command: None,
format: None,
input_format: None,
output_format: cli::OutputFormat::Text,
verbose: false,
});
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
Expand All @@ -165,7 +167,8 @@ mod tests {
fn run_command_check_with_stdin_json_returns_zero() {
let cmd = Commands::Check(CheckArgs {
command: None,
format: None,
input_format: None,
output_format: cli::OutputFormat::Text,
verbose: false,
});
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
Expand All @@ -177,7 +180,8 @@ mod tests {
fn run_command_check_with_plaintext_stdin_returns_zero() {
let cmd = Commands::Check(CheckArgs {
command: None,
format: None,
input_format: None,
output_format: cli::OutputFormat::Text,
verbose: false,
});
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
Expand Down Expand Up @@ -209,7 +213,8 @@ mod tests {
fn run_command_check_with_multiline_plaintext_stdin_returns_zero() {
let cmd = Commands::Check(CheckArgs {
command: None,
format: None,
input_format: None,
output_format: cli::OutputFormat::Text,
verbose: false,
});
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
Expand Down
Loading
Loading