diff --git a/Cargo.lock b/Cargo.lock index 70826e2..a50538e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -71,7 +71,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -82,7 +82,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -297,6 +297,19 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -316,6 +329,19 @@ dependencies = [ "typenum", ] +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "tempfile", + "thiserror 1.0.69", + "zeroize", +] + [[package]] name = "difflib" version = "0.4.0" @@ -338,6 +364,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -389,7 +421,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -897,6 +929,7 @@ dependencies = [ "assert_cmd", "cel-interpreter", "clap", + "dialoguer", "glob", "indoc", "landlock", @@ -911,6 +944,7 @@ dependencies = [ "serde_json", "sha2", "shlex", + "similar", "tempfile", "thiserror 2.0.18", "tree-sitter", @@ -936,7 +970,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1088,12 +1122,28 @@ dependencies = [ "digest", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +dependencies = [ + "bstr", + "unicode-segmentation", +] + [[package]] name = "slab" version = "0.4.12" @@ -1139,7 +1189,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1266,6 +1316,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + [[package]] name = "unicode-width" version = "0.2.2" @@ -1412,6 +1468,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1421,6 +1486,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.7.14" @@ -1538,6 +1667,12 @@ dependencies = [ "syn", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index df67198..ec3b9e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,12 +25,14 @@ seccompiler = "=0.5.0" anyhow = "=1.0.102" cel-interpreter = "=0.10.0" clap = { version = "=4.5.60", features = ["derive"] } +dialoguer = "=0.11.0" schemars = { version = "=1.2.1", optional = true } serde = { version = "=1.0.228", features = ["derive"] } serde-saphyr = "=0.0.20" serde_json = "=1.0.149" sha2 = "=0.10.9" shlex = "=1.3.0" +similar = { version = "=2.7.0", features = ["unicode"] } thiserror = "=2.0.18" tree-sitter = "=0.26.5" tree-sitter-bash = "=0.25.1" diff --git a/README.md b/README.md index 9f25a21..4c05154 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,15 @@ Pre-built binaries are also available on [GitHub Releases](https://github.com/fo ### Configure -Create `~/.config/runok/runok.yml`: +The fastest way to get started is with the interactive setup wizard: + +```sh +runok init +``` + +This creates a `runok.yml`, and if you have Claude Code configured, migrates your Bash permissions to runok rules and registers the PreToolUse hook automatically. + +You can also configure manually. Create `~/.config/runok/runok.yml`: ```yaml rules: @@ -96,9 +104,7 @@ defaults: action: ask ``` -### Integrate with Claude Code - -Add runok as a PreToolUse hook in `.claude/settings.json`: +And add runok as a PreToolUse hook in `.claude/settings.json`: ```json { diff --git a/docs/src/content/docs/cli/init.md b/docs/src/content/docs/cli/init.md new file mode 100644 index 0000000..4ce5be9 --- /dev/null +++ b/docs/src/content/docs/cli/init.md @@ -0,0 +1,64 @@ +--- +title: runok init +description: Initialize runok configuration with an interactive setup wizard. +sidebar: + order: 4 +--- + +`runok init` creates a `runok.yml` configuration file through an interactive setup wizard. It can also detect existing Claude Code Bash permissions and migrate them to runok rules. + +## Usage + +```sh +runok init [options] +``` + +## Flags + +### `--scope ` + +Configuration scope. Available values: + +- `user` — Create `~/.config/runok/runok.yml` for global rules that apply to all projects. Also registers the runok PreToolUse hook in `~/.claude/settings.json` if Claude Code is detected. +- `project` — Create `runok.yml` in the current directory for project-specific rules. + +When omitted, the wizard prompts you to choose. + +### `-y`, `--yes` + +Accept all defaults without prompting. Useful for scripted setups. + +## What the wizard does + +1. **Scope selection** — Choose `user` or `project` scope (skipped if `--scope` is given). +2. **Claude Code detection** — If a `.claude/settings.json` exists with Bash permissions or a missing runok hook, the wizard offers to: + - **Migrate Bash permissions** — Convert `permissions.allow` and `permissions.deny` entries for `Bash(...)` patterns into runok rules, and remove them from `settings.json`. + - **Register the hook** — Add the `runok check` PreToolUse hook to `settings.json` (user scope only). +3. **Preview and confirm** — Show a unified diff of all proposed changes and ask for confirmation. +4. **Create `runok.yml`** — Write the configuration file with migrated rules (if any) or a boilerplate template. + +## Examples + +Interactive setup (prompts for scope and options): + +```sh +runok init +``` + +Set up user-global configuration non-interactively: + +```sh +runok init --scope user -y +``` + +Set up project-local configuration: + +```sh +runok init --scope project +``` + +## Related + +- [Quick Start](/getting-started/quickstart/) — Getting started with runok. +- [Claude Code Integration](/getting-started/claude-code/) — Manual hook setup and sandbox configuration. +- [Configuration](/configuration/schema/) — Full configuration reference. diff --git a/docs/src/content/docs/cli/overview.md b/docs/src/content/docs/cli/overview.md index 1723e2b..706d25c 100644 --- a/docs/src/content/docs/cli/overview.md +++ b/docs/src/content/docs/cli/overview.md @@ -5,10 +5,14 @@ sidebar: order: 1 --- -runok provides two main subcommands for evaluating and executing commands against your permission rules. +runok provides subcommands for initializing configuration, evaluating commands, and executing them against your permission rules. ## Commands +### [`runok init`](/cli/init/) + +Initialize runok configuration with an interactive setup wizard. Detects existing Claude Code Bash permissions and offers to migrate them to runok rules. + ### [`runok check`](/cli/check/) Evaluate a command against your rules and report the decision — without executing it. Useful for previewing what runok would do, or for integrating with external tools like [Claude Code hooks](/getting-started/claude-code/). diff --git a/docs/src/content/docs/getting-started/claude-code.md b/docs/src/content/docs/getting-started/claude-code.md index 42e1d8b..649472f 100644 --- a/docs/src/content/docs/getting-started/claude-code.md +++ b/docs/src/content/docs/getting-started/claude-code.md @@ -18,6 +18,10 @@ runok integrates with [Claude Code](https://docs.anthropic.com/en/docs/claude-co If you haven't already, follow the [Quick Start](/getting-started/quickstart/) to install runok and create a `runok.yml`. +:::tip +[`runok init --scope user`](/cli/init/) can automate steps 1 and 2: it creates a `runok.yml`, migrates existing Claude Code Bash permissions, and registers the hook — all in one command. +::: + ## Step 2: Configure the PreToolUse hook Add the runok hook to your Claude Code settings file (`.claude/settings.json`): diff --git a/docs/src/content/docs/getting-started/quickstart.md b/docs/src/content/docs/getting-started/quickstart.md index 8a92f56..d7ff5a7 100644 --- a/docs/src/content/docs/getting-started/quickstart.md +++ b/docs/src/content/docs/getting-started/quickstart.md @@ -13,7 +13,15 @@ Follow the [Installation](/getting-started/installation/) guide to install runok ## 2. Create a configuration file -Create `~/.config/runok/runok.yml` to set up global rules that apply to all projects: +The easiest way to get started is with the interactive setup wizard: + +```sh +runok init +``` + +This creates a `runok.yml` and, if you have Claude Code configured, offers to migrate your existing Bash permissions to runok rules and register the PreToolUse hook. See [`runok init`](/cli/init/) for details. + +Alternatively, create `~/.config/runok/runok.yml` manually: ```sh mkdir -p ~/.config/runok @@ -70,5 +78,5 @@ The decision (`allow`, `deny`, or `ask`) is printed to stdout. Use `--output-for ## Next steps - [Claude Code Integration](/getting-started/claude-code/) -- Set up runok as a Claude Code PreToolUse hook. -- [CLI Reference](/cli/overview/) -- Full reference for `runok check` and `runok exec`. +- [CLI Reference](/cli/overview/) -- Full reference for `runok init`, `runok check`, and `runok exec`. - [Recipes](/recipes/overview/) -- Common configuration patterns and examples. diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 37ad41e..459f8b9 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,6 +1,6 @@ mod route; -use clap::{Parser, Subcommand}; +use clap::{Parser, Subcommand, ValueEnum}; pub use route::{CheckRoute, route_check}; @@ -18,6 +18,8 @@ pub enum Commands { Exec(ExecArgs), /// Check whether a command would be allowed Check(CheckArgs), + /// Initialize runok configuration + Init(InitArgs), /// Print the JSON Schema for runok.yml to stdout #[cfg(feature = "config-schema")] ConfigSchema, @@ -28,6 +30,26 @@ pub enum Commands { SandboxExec(SandboxExecArgs), } +/// Scope for init configuration: user-level or project-level. +#[derive(Clone, ValueEnum)] +#[cfg_attr(test, derive(Debug, PartialEq))] +pub enum InitScope { + User, + Project, +} + +#[derive(clap::Args)] +#[cfg_attr(test, derive(Debug, PartialEq))] +pub struct InitArgs { + /// Configuration scope: "user" for global, "project" for local + #[arg(long, value_enum)] + pub scope: Option, + + /// Accept all defaults without prompting + #[arg(short = 'y', long = "yes")] + pub yes: bool, +} + #[cfg(target_os = "linux")] #[derive(clap::Args)] #[cfg_attr(test, derive(Debug, PartialEq))] @@ -142,8 +164,39 @@ mod tests { &["runok", "check", "--verbose", "--", "git", "status"], Commands::Check(CheckArgs { input_format: None, output_format: OutputFormat::Text, verbose: true, command: vec!["git".into(), "status".into()] }), )] + #[case::init_defaults( + &["runok", "init"], + Commands::Init(InitArgs { scope: None, yes: false }), + )] + #[case::init_with_scope_user( + &["runok", "init", "--scope", "user"], + Commands::Init(InitArgs { scope: Some(InitScope::User), yes: false }), + )] + #[case::init_with_scope_project( + &["runok", "init", "--scope", "project"], + Commands::Init(InitArgs { scope: Some(InitScope::Project), yes: false }), + )] + #[case::init_with_yes( + &["runok", "init", "-y"], + Commands::Init(InitArgs { scope: None, yes: true }), + )] + #[case::init_with_yes_long( + &["runok", "init", "--yes"], + Commands::Init(InitArgs { scope: None, yes: true }), + )] + #[case::init_all_flags( + &["runok", "init", "--scope", "user", "-y"], + Commands::Init(InitArgs { scope: Some(InitScope::User), yes: true }), + )] fn cli_parsing(#[case] argv: &[&str], #[case] expected: Commands) { let cli = Cli::parse_from(argv); assert_eq!(cli.command, expected); } + + #[rstest] + #[case::invalid_scope(&["runok", "init", "--scope", "invalid"])] + fn cli_parsing_errors(#[case] argv: &[&str]) { + let result = Cli::try_parse_from(argv); + assert!(result.is_err()); + } } diff --git a/src/config/mod.rs b/src/config/mod.rs index 3c3f7f5..7fc66e8 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,5 +1,5 @@ pub(crate) mod cache; -mod dirs; +pub mod dirs; mod error; pub(crate) mod git_client; mod loader; diff --git a/src/init/claude_code.rs b/src/init/claude_code.rs new file mode 100644 index 0000000..df933dc --- /dev/null +++ b/src/init/claude_code.rs @@ -0,0 +1,817 @@ +use std::path::Path; + +use super::error::InitError; + +/// Result of converting Claude Code permissions to runok rules. +#[derive(Debug, Default)] +pub struct ConversionResult { + /// YAML-formatted rule lines (each line starts with " - "). + pub rules: String, + /// Tool entries that were skipped because they are not Bash. + pub skipped: Vec, +} + +/// Parse a Claude Code permission entry like `Bash(command)` or `Bash(prefix:*)`. +/// +/// Returns `Some((tool_name, pattern))` if the entry matches the expected format, +/// or `None` if parsing fails. +pub fn parse_permission_entry(entry: &str) -> Option<(&str, &str)> { + let open = entry.find('(')?; + let close = entry.rfind(')')?; + if close <= open { + return None; + } + let tool = &entry[..open]; + let pattern = &entry[open + 1..close]; + Some((tool, pattern)) +} + +/// Convert a Claude Code Bash pattern to a runok pattern. +/// +/// Claude Code uses `:*` as a prefix-match operator (e.g. `npm install:*` +/// matches any command starting with `npm install`). runok has no `:*` +/// syntax, so we convert it to a space + glob: `npm install *`. +fn convert_bash_pattern(pattern: &str) -> String { + if let Some(prefix) = pattern.strip_suffix(":*") { + format!("{prefix} *") + } else { + pattern.to_string() + } +} + +/// Convert Claude Code permission entries to runok rule YAML lines. +/// +/// Processes both `allow` and `deny` entries. Entries that are not `Bash(...)` +/// are collected in `skipped`. +pub fn convert_permissions(allow_entries: &[String], deny_entries: &[String]) -> ConversionResult { + let mut result = ConversionResult::default(); + + for entry in allow_entries { + match parse_permission_entry(entry) { + Some(("Bash", pattern)) => { + let converted = convert_bash_pattern(pattern); + let escaped = converted.replace('\'', "''"); + result.rules.push_str(&format!(" - allow: '{escaped}'\n")); + } + Some((tool, _)) => { + result.skipped.push(format!("{tool}(...)")); + } + None => { + result.skipped.push(entry.clone()); + } + } + } + + for entry in deny_entries { + match parse_permission_entry(entry) { + Some(("Bash", pattern)) => { + let converted = convert_bash_pattern(pattern); + let escaped = converted.replace('\'', "''"); + result.rules.push_str(&format!(" - deny: '{escaped}'\n")); + } + Some((tool, _)) => { + result.skipped.push(format!("{tool}(...)")); + } + None => { + result.skipped.push(entry.clone()); + } + } + } + + result +} + +/// Read Claude Code settings.json and settings.local.json and extract permissions. +/// +/// Returns `(allow_entries, deny_entries)` merged from both files. +pub fn read_permissions(claude_dir: &Path) -> Result<(Vec, Vec), InitError> { + let mut allow_entries = Vec::new(); + let mut deny_entries = Vec::new(); + + for filename in &["settings.json", "settings.local.json"] { + let path = claude_dir.join(filename); + if !path.exists() { + continue; + } + let content = std::fs::read_to_string(&path)?; + let value: serde_json::Value = serde_json::from_str(&content)?; + + if let Some(permissions) = value.get("permissions") { + if let Some(arr) = permissions.get("allow").and_then(|v| v.as_array()) { + for item in arr { + if let Some(s) = item.as_str() { + allow_entries.push(s.to_string()); + } + } + } + if let Some(arr) = permissions.get("deny").and_then(|v| v.as_array()) { + for item in arr { + if let Some(s) = item.as_str() { + deny_entries.push(s.to_string()); + } + } + } + } + } + + Ok((allow_entries, deny_entries)) +} + +/// Check whether a PreToolUse entry already contains the runok hook command. +pub fn entry_has_runok_hook(entry: &serde_json::Value, command: &str) -> bool { + // Plain string format: "runok check --input-format claude-code-hook" + if entry.as_str() == Some(command) { + return true; + } + // Current format: {"matcher": "Bash", "hooks": [{"type": "command", "command": "runok check ..."}]} + // Also handles string hooks: {"hooks": ["runok check --input-format claude-code-hook"]} + if let Some(hooks) = entry.get("hooks").and_then(|h| h.as_array()) + && hooks.iter().any(|h| { + h.get("command").and_then(|c| c.as_str()) == Some(command) + || h.as_str() == Some(command) + }) + { + return true; + } + // Legacy format: {"type": "command", "command": "runok check ..."} (top-level command) + if entry.get("command").and_then(|c| c.as_str()) == Some(command) + && entry.get("hooks").is_none() + { + return true; + } + false +} + +/// Register runok hook in Claude Code settings.json. +/// +/// Adds a PreToolUse hook entry with `"matcher": "Bash"` that runs +/// `runok check --input-format claude-code-hook`. +/// If the hook is already registered, does nothing. +/// Creates the file if it doesn't exist. +pub fn register_hook(claude_dir: &Path) -> Result { + let path = claude_dir.join("settings.json"); + + let mut root = if path.exists() { + let content = std::fs::read_to_string(&path)?; + serde_json::from_str::(&content)? + } else { + serde_json::json!({}) + }; + + let hook_command = "runok check --input-format claude-code-hook"; + + // Check if hook already exists in any format + if let Some(arr) = root + .get("hooks") + .and_then(|h| h.get("PreToolUse")) + .and_then(|p| p.as_array()) + { + for entry in arr { + if entry_has_runok_hook(entry, hook_command) { + return Ok(false); + } + } + } + + // Add the hook in the current Claude Code format + let hook_entry = serde_json::json!({ + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": hook_command + } + ] + }); + + let hooks = root + .as_object_mut() + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "settings.json root is not an object", + ) + })? + .entry("hooks") + .or_insert_with(|| serde_json::json!({})); + + let pre_tool_use = hooks + .as_object_mut() + .ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidData, "hooks is not an object") + })? + .entry("PreToolUse") + .or_insert_with(|| serde_json::json!([])); + + pre_tool_use + .as_array_mut() + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "PreToolUse is not an array", + ) + })? + .push(hook_entry); + + std::fs::create_dir_all(claude_dir)?; + let output = serde_json::to_string_pretty(&root)?; + std::fs::write(&path, output)?; + + Ok(true) +} + +/// Remove Bash permission entries from a single settings file. +/// +/// Returns `true` if the file was modified. +fn remove_permissions_from_file(path: &Path) -> Result { + if !path.exists() { + return Ok(false); + } + + let content = std::fs::read_to_string(path)?; + let mut root: serde_json::Value = serde_json::from_str(&content)?; + + let mut modified = false; + if let Some(obj) = root.get_mut("permissions").and_then(|p| p.as_object_mut()) { + for key in &["allow", "deny"] { + if let Some(arr) = obj.get_mut(*key).and_then(|v| v.as_array_mut()) { + let before_len = arr.len(); + arr.retain(|entry| { + entry + .as_str() + .and_then(parse_permission_entry) + .is_none_or(|(tool, _)| tool != "Bash") + }); + if arr.len() != before_len { + modified = true; + } + if arr.is_empty() { + obj.remove(*key); + } + } + } + } + + if modified { + let output = serde_json::to_string_pretty(&root)?; + std::fs::write(path, output)?; + } + + Ok(modified) +} + +/// Remove permissions.allow and permissions.deny from Claude Code settings files. +/// +/// Processes both `settings.json` and `settings.local.json`. +/// Preserves other keys within the permissions object and other top-level keys. +/// Remove only Bash permission entries from allow/deny arrays. +/// +/// Non-Bash entries (e.g. `Read(...)`, `Skill`, `WebFetch`) are preserved. +/// If an array becomes empty after filtering, the key is removed entirely. +/// Returns `true` if either file was modified. +pub fn remove_permissions(claude_dir: &Path) -> Result { + let mut any_modified = false; + + for filename in &["settings.json", "settings.local.json"] { + let path = claude_dir.join(filename); + if remove_permissions_from_file(&path)? { + any_modified = true; + } + } + + Ok(any_modified) +} + +#[cfg(test)] +mod tests { + use super::*; + use indoc::indoc; + use rstest::rstest; + use tempfile::TempDir; + + // --- parse_permission_entry --- + + #[rstest] + #[case::bash_simple("Bash(git status)", Some(("Bash", "git status")))] + #[case::bash_wildcard("Bash(git *)", Some(("Bash", "git *")))] + #[case::read_tool("Read(/tmp/file)", Some(("Read", "/tmp/file")))] + #[case::no_parens("invalid", None)] + #[case::empty_parens("Bash()", Some(("Bash", "")))] + fn test_parse_permission_entry(#[case] entry: &str, #[case] expected: Option<(&str, &str)>) { + assert_eq!(parse_permission_entry(entry), expected); + } + + // --- convert_bash_pattern --- + + #[rstest] + #[case::plain("git status", "git status")] + #[case::glob("npm install *", "npm install *")] + #[case::prefix_match("runok exec:*", "runok exec *")] + #[case::prefix_match_nested("npm run:*", "npm run *")] + #[case::colon_in_middle("foo:bar", "foo:bar")] + fn test_convert_bash_pattern(#[case] input: &str, #[case] expected: &str) { + assert_eq!(convert_bash_pattern(input), expected); + } + + // --- convert_permissions --- + + #[rstest] + fn convert_permissions_basic() { + let allow = vec![ + "Bash(git status)".to_string(), + "Bash(npm install *)".to_string(), + ]; + let deny = vec!["Bash(rm -rf /)".to_string()]; + + let result = convert_permissions(&allow, &deny); + + assert_eq!( + result.rules, + concat!( + " - allow: 'git status'\n", + " - allow: 'npm install *'\n", + " - deny: 'rm -rf /'\n", + ) + ); + assert!(result.skipped.is_empty()); + } + + #[rstest] + fn convert_permissions_converts_prefix_match() { + let allow = vec![ + "Bash(runok exec:*)".to_string(), + "Bash(npm run:*)".to_string(), + ]; + let deny = vec![]; + + let result = convert_permissions(&allow, &deny); + + assert_eq!( + result.rules, + concat!(" - allow: 'runok exec *'\n", " - allow: 'npm run *'\n",) + ); + } + + #[rstest] + fn convert_permissions_skips_non_bash() { + let allow = vec![ + "Bash(git status)".to_string(), + "Read(/tmp/file)".to_string(), + "Write(/tmp/file)".to_string(), + ]; + let deny = vec![]; + + let result = convert_permissions(&allow, &deny); + + assert_eq!(result.rules, " - allow: 'git status'\n"); + assert_eq!(result.skipped, vec!["Read(...)", "Write(...)"]); + } + + #[rstest] + fn convert_permissions_empty() { + let result = convert_permissions(&[], &[]); + assert!(result.rules.is_empty()); + assert!(result.skipped.is_empty()); + } + + #[rstest] + fn convert_permissions_escapes_single_quotes() { + let allow = vec!["Bash(echo 'hello')".to_string()]; + let deny = vec![]; + + let result = convert_permissions(&allow, &deny); + + assert_eq!(result.rules, " - allow: 'echo ''hello'''\n"); + } + + // --- read_permissions --- + + #[rstest] + fn read_permissions_from_settings_json() { + let tmp = TempDir::new().unwrap(); + let claude_dir = tmp.path().join(".claude"); + std::fs::create_dir_all(&claude_dir).unwrap(); + + std::fs::write( + claude_dir.join("settings.json"), + indoc! {r#" + { + "permissions": { + "allow": ["Bash(git status)", "Bash(npm install *)"], + "deny": ["Bash(rm -rf /)"] + } + } + "#}, + ) + .unwrap(); + + let (allow, deny) = read_permissions(&claude_dir).unwrap(); + assert_eq!(allow, vec!["Bash(git status)", "Bash(npm install *)"]); + assert_eq!(deny, vec!["Bash(rm -rf /)"]); + } + + #[rstest] + fn read_permissions_merges_both_files() { + let tmp = TempDir::new().unwrap(); + let claude_dir = tmp.path().join(".claude"); + std::fs::create_dir_all(&claude_dir).unwrap(); + + std::fs::write( + claude_dir.join("settings.json"), + indoc! {r#" + { + "permissions": { + "allow": ["Bash(git status)"] + } + } + "#}, + ) + .unwrap(); + + std::fs::write( + claude_dir.join("settings.local.json"), + indoc! {r#" + { + "permissions": { + "allow": ["Bash(cargo test)"], + "deny": ["Bash(rm *)"] + } + } + "#}, + ) + .unwrap(); + + let (allow, deny) = read_permissions(&claude_dir).unwrap(); + assert_eq!(allow, vec!["Bash(git status)", "Bash(cargo test)"]); + assert_eq!(deny, vec!["Bash(rm *)"]); + } + + #[rstest] + fn read_permissions_no_files() { + let tmp = TempDir::new().unwrap(); + let claude_dir = tmp.path().join(".claude"); + std::fs::create_dir_all(&claude_dir).unwrap(); + + let (allow, deny) = read_permissions(&claude_dir).unwrap(); + assert!(allow.is_empty()); + assert!(deny.is_empty()); + } + + // --- register_hook --- + + #[rstest] + fn register_hook_creates_new_file() { + let tmp = TempDir::new().unwrap(); + let claude_dir = tmp.path().join(".claude"); + + let registered = register_hook(&claude_dir).unwrap(); + assert!(registered); + + let value: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(claude_dir.join("settings.json")).unwrap(), + ) + .unwrap(); + assert_eq!( + value, + serde_json::json!({ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "runok check --input-format claude-code-hook" + } + ] + } + ] + } + }) + ); + } + + #[rstest] + #[case::current_format(indoc! {r#" + { + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "runok check --input-format claude-code-hook" + } + ] + } + ] + } + } + "#})] + #[case::legacy_format(indoc! {r#" + { + "hooks": { + "PreToolUse": [ + { + "type": "command", + "command": "runok check --input-format claude-code-hook" + } + ] + } + } + "#})] + #[case::string_format(indoc! {r#" + { + "hooks": { + "PreToolUse": [ + "runok check --input-format claude-code-hook" + ] + } + } + "#})] + #[case::string_hooks_in_object(indoc! {r#" + { + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + "runok check --input-format claude-code-hook" + ] + } + ] + } + } + "#})] + fn register_hook_skips_duplicate(#[case] existing: &str) { + let tmp = TempDir::new().unwrap(); + let claude_dir = tmp.path().join(".claude"); + std::fs::create_dir_all(&claude_dir).unwrap(); + std::fs::write(claude_dir.join("settings.json"), existing).unwrap(); + + let registered = register_hook(&claude_dir).unwrap(); + assert!(!registered); + } + + #[rstest] + fn register_hook_preserves_existing_keys() { + let tmp = TempDir::new().unwrap(); + let claude_dir = tmp.path().join(".claude"); + std::fs::create_dir_all(&claude_dir).unwrap(); + + std::fs::write( + claude_dir.join("settings.json"), + indoc! {r#" + { + "someKey": "someValue", + "hooks": { + "PostToolUse": [{"hooks": [{"command": "other-tool", "type": "command"}]}] + } + } + "#}, + ) + .unwrap(); + + let registered = register_hook(&claude_dir).unwrap(); + assert!(registered); + + let value: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(claude_dir.join("settings.json")).unwrap(), + ) + .unwrap(); + assert_eq!(value["someKey"], "someValue"); + assert_eq!( + value["hooks"]["PreToolUse"], + serde_json::json!([ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "runok check --input-format claude-code-hook" + } + ] + } + ]) + ); + } + + // --- remove_permissions --- + + #[rstest] + fn remove_permissions_removes_only_bash_entries() { + let tmp = TempDir::new().unwrap(); + let claude_dir = tmp.path().join(".claude"); + std::fs::create_dir_all(&claude_dir).unwrap(); + + std::fs::write( + claude_dir.join("settings.json"), + indoc! {r#" + { + "permissions": { + "allow": ["Bash(git status)", "Read(/tmp)", "WebFetch"], + "deny": ["Bash(rm *)", "NotebookEdit"], + "scopes": {"project": {}} + }, + "hooks": {} + } + "#}, + ) + .unwrap(); + + let modified = remove_permissions(&claude_dir).unwrap(); + assert!(modified); + + let content = std::fs::read_to_string(claude_dir.join("settings.json")).unwrap(); + let value: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!( + value, + serde_json::json!({ + "permissions": { + "allow": ["Read(/tmp)", "WebFetch"], + "deny": ["NotebookEdit"], + "scopes": {"project": {}} + }, + "hooks": {} + }) + ); + } + + #[rstest] + fn remove_permissions_removes_key_when_only_bash() { + let tmp = TempDir::new().unwrap(); + let claude_dir = tmp.path().join(".claude"); + std::fs::create_dir_all(&claude_dir).unwrap(); + + std::fs::write( + claude_dir.join("settings.json"), + indoc! {r#" + { + "permissions": { + "allow": ["Bash(git status)"], + "deny": ["Bash(rm *)"], + "scopes": {"project": {}} + }, + "hooks": {} + } + "#}, + ) + .unwrap(); + + let modified = remove_permissions(&claude_dir).unwrap(); + assert!(modified); + + let content = std::fs::read_to_string(claude_dir.join("settings.json")).unwrap(); + let value: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!( + value, + serde_json::json!({ + "permissions": { + "scopes": {"project": {}} + }, + "hooks": {} + }) + ); + } + + #[rstest] + fn remove_permissions_noop_when_empty() { + let tmp = TempDir::new().unwrap(); + let claude_dir = tmp.path().join(".claude"); + std::fs::create_dir_all(&claude_dir).unwrap(); + + std::fs::write( + claude_dir.join("settings.json"), + indoc! {r#" + { + "permissions": {}, + "hooks": {} + } + "#}, + ) + .unwrap(); + + let modified = remove_permissions(&claude_dir).unwrap(); + assert!(!modified); + } + + #[rstest] + fn remove_permissions_no_file() { + let tmp = TempDir::new().unwrap(); + let claude_dir = tmp.path().join(".claude"); + std::fs::create_dir_all(&claude_dir).unwrap(); + + let modified = remove_permissions(&claude_dir).unwrap(); + assert!(!modified); + } + + #[rstest] + fn remove_permissions_also_cleans_settings_local() { + let tmp = TempDir::new().unwrap(); + let claude_dir = tmp.path().join(".claude"); + std::fs::create_dir_all(&claude_dir).unwrap(); + + std::fs::write( + claude_dir.join("settings.json"), + indoc! {r#" + { + "permissions": { + "allow": ["Bash(git status)", "Read(/tmp)"], + "deny": ["Bash(rm *)"] + }, + "hooks": {} + } + "#}, + ) + .unwrap(); + + std::fs::write( + claude_dir.join("settings.local.json"), + indoc! {r#" + { + "permissions": { + "allow": ["Bash(cargo test)"], + "deny": ["Bash(sudo *)"] + } + } + "#}, + ) + .unwrap(); + + let modified = remove_permissions(&claude_dir).unwrap(); + assert!(modified); + + // Verify settings.json: Bash entries removed, non-Bash preserved + let settings_content = std::fs::read_to_string(claude_dir.join("settings.json")).unwrap(); + let settings_value: serde_json::Value = serde_json::from_str(&settings_content).unwrap(); + assert_eq!( + settings_value, + serde_json::json!({ + "permissions": { + "allow": ["Read(/tmp)"] + }, + "hooks": {} + }) + ); + + // Verify settings.local.json: all Bash entries removed, keys dropped + let local_content = + std::fs::read_to_string(claude_dir.join("settings.local.json")).unwrap(); + let local_value: serde_json::Value = serde_json::from_str(&local_content).unwrap(); + assert_eq!( + local_value, + serde_json::json!({ + "permissions": {} + }) + ); + } + + // --- entry_has_runok_hook --- + + #[rstest] + #[case::current_format( + serde_json::json!({ + "matcher": "Bash", + "hooks": [{"type": "command", "command": "runok check --input-format claude-code-hook"}] + }), + true, + )] + #[case::legacy_format( + serde_json::json!({ + "type": "command", + "command": "runok check --input-format claude-code-hook" + }), + true, + )] + #[case::plain_string( + serde_json::json!("runok check --input-format claude-code-hook"), + true, + )] + #[case::string_hooks_in_object( + serde_json::json!({ + "matcher": "Bash", + "hooks": ["runok check --input-format claude-code-hook"] + }), + true, + )] + #[case::no_match_different_command( + serde_json::json!({ + "matcher": "Bash", + "hooks": [{"type": "command", "command": "other-tool"}] + }), + false, + )] + #[case::no_match_different_string( + serde_json::json!("some-other-command"), + false, + )] + #[case::no_match_empty_object( + serde_json::json!({}), + false, + )] + fn test_entry_has_runok_hook(#[case] entry: serde_json::Value, #[case] expected: bool) { + let command = "runok check --input-format claude-code-hook"; + assert_eq!(entry_has_runok_hook(&entry, command), expected); + } +} diff --git a/src/init/config_gen.rs b/src/init/config_gen.rs new file mode 100644 index 0000000..2f7b535 --- /dev/null +++ b/src/init/config_gen.rs @@ -0,0 +1,106 @@ +use std::path::Path; + +use super::error::InitError; + +/// Boilerplate template for a new runok.yml configuration file. +const BOILERPLATE_TEMPLATE: &str = "\ +# yaml-language-server: $schema=https://raw.githubusercontent.com/fohte/runok/main/schema/runok.schema.json +"; + +/// Return the boilerplate template string. +#[cfg(test)] +fn boilerplate() -> &'static str { + BOILERPLATE_TEMPLATE +} + +/// Write a configuration file to the given directory. +/// +/// Creates parent directories if they don't exist. +/// Overwrites existing files. +pub fn write_config(dir: &Path, content: &str) -> Result { + std::fs::create_dir_all(dir)?; + + let path = dir.join("runok.yml"); + std::fs::write(&path, content)?; + Ok(path) +} + +/// Build configuration content by combining boilerplate with optional converted rules. +pub fn build_config_content(converted_rules: Option<&str>) -> String { + let mut content = BOILERPLATE_TEMPLATE.to_string(); + if let Some(rules) = converted_rules + && !rules.is_empty() + { + content.push('\n'); + content.push_str("# Converted from Claude Code permissions:\n"); + content.push_str("rules:\n"); + content.push_str(rules); + } + content +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + use tempfile::TempDir; + + #[rstest] + fn boilerplate_has_expected_content() { + let tmpl = boilerplate(); + assert_eq!( + tmpl, + "# yaml-language-server: $schema=https://raw.githubusercontent.com/fohte/runok/main/schema/runok.schema.json\n" + ); + } + + #[rstest] + fn write_config_creates_directory_and_file() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().join("subdir"); + + let path = write_config(&dir, "test content").unwrap(); + assert_eq!(path, dir.join("runok.yml")); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "test content"); + } + + #[rstest] + fn write_config_overwrites_existing() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().join("project"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("runok.yml"), "old content").unwrap(); + + let path = write_config(&dir, "new content").unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "new content"); + } + + #[rstest] + fn build_config_content_without_rules() { + let content = build_config_content(None); + assert_eq!(content, boilerplate()); + } + + #[rstest] + fn build_config_content_with_rules() { + let rules = concat!(" - allow: 'git status'\n", " - deny: 'rm -rf /'\n",); + let content = build_config_content(Some(rules)); + assert_eq!( + content, + indoc::indoc! {"\ + # yaml-language-server: $schema=https://raw.githubusercontent.com/fohte/runok/main/schema/runok.schema.json + + # Converted from Claude Code permissions: + rules: + - allow: 'git status' + - deny: 'rm -rf /' + "} + ); + } + + #[rstest] + fn build_config_content_with_empty_rules() { + let content = build_config_content(Some("")); + assert_eq!(content, boilerplate()); + } +} diff --git a/src/init/error.rs b/src/init/error.rs new file mode 100644 index 0000000..081c768 --- /dev/null +++ b/src/init/error.rs @@ -0,0 +1,35 @@ +#[derive(Debug, thiserror::Error)] +pub enum InitError { + #[error("io error: {0}")] + Io(#[from] std::io::Error), + #[error("json parse error: {0}")] + Json(#[from] serde_json::Error), + #[error("prompt error: {0}")] + Prompt(#[from] dialoguer::Error), +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + #[case::io_error( + InitError::Io(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "permission denied" + )), + "io error: permission denied" + )] + fn error_display(#[case] error: InitError, #[case] expected: &str) { + assert_eq!(error.to_string(), expected); + } + + #[rstest] + fn json_error_display() { + let json_err: serde_json::Error = + serde_json::from_str::("{invalid").unwrap_err(); + let error = InitError::Json(json_err); + assert!(error.to_string().starts_with("json parse error:")); + } +} diff --git a/src/init/mod.rs b/src/init/mod.rs new file mode 100644 index 0000000..feb4e21 --- /dev/null +++ b/src/init/mod.rs @@ -0,0 +1,7 @@ +mod claude_code; +mod config_gen; +pub mod error; +pub mod prompt; +mod wizard; + +pub use wizard::{InitScope, run_wizard, run_wizard_with_paths}; diff --git a/src/init/prompt.rs b/src/init/prompt.rs new file mode 100644 index 0000000..6f811df --- /dev/null +++ b/src/init/prompt.rs @@ -0,0 +1,92 @@ +use super::error::InitError; + +/// Abstraction over interactive prompts. +/// +/// Production code uses `DialoguerPrompter`, which delegates to dialoguer. +/// Tests inject a `Prompter` that returns pre-configured responses. +pub trait Prompter { + fn confirm(&self, message: &str, default: bool) -> Result; + fn select(&self, message: &str, items: &[&str], default: usize) -> Result; +} + +/// Production prompter that uses dialoguer for interactive prompts. +pub struct DialoguerPrompter; + +impl Prompter for DialoguerPrompter { + fn confirm(&self, message: &str, default: bool) -> Result { + let items = ["Yes", "No"]; + let default_idx = if default { 0 } else { 1 }; + let selection = dialoguer::Select::with_theme(&dialoguer::theme::ColorfulTheme::default()) + .with_prompt(message) + .items(&items) + .default(default_idx) + .report(false) + .interact()?; + let accepted = selection == 0; + if accepted { + eprintln!("\x1b[32m✔\x1b[0m {message} · Yes"); + } else { + eprintln!("\x1b[33m✘\x1b[0m {message} · No"); + } + Ok(accepted) + } + + fn select(&self, message: &str, items: &[&str], default: usize) -> Result { + let selection = dialoguer::Select::with_theme(&dialoguer::theme::ColorfulTheme::default()) + .with_prompt(message) + .items(items) + .default(default) + .report(false) + .interact()?; + let label = items.get(selection).copied().unwrap_or("?"); + eprintln!("\x1b[32m✔\x1b[0m {message} · {label}"); + Ok(selection) + } +} + +/// Non-interactive prompter that always answers "yes" to confirmations. +/// +/// Used when the `-y` flag is specified. +pub struct AutoYesPrompter; + +impl Prompter for AutoYesPrompter { + fn confirm(&self, _message: &str, _default: bool) -> Result { + Ok(true) + } + + fn select(&self, _message: &str, _items: &[&str], default: usize) -> Result { + Ok(default) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + #[case::auto_yes_default_true("Continue?", true)] + #[case::auto_yes_default_false("Continue?", false)] + fn auto_yes_confirm_always_returns_true(#[case] message: &str, #[case] default: bool) { + let prompter = AutoYesPrompter; + let result = prompter + .confirm(message, default) + .unwrap_or_else(|e| panic!("unexpected error: {e}")); + assert!(result, "AutoYesPrompter should always return true"); + } + + #[rstest] + #[case::select_default_zero(&["A", "B"], 0, 0)] + #[case::select_default_one(&["A", "B"], 1, 1)] + fn auto_yes_select_returns_default( + #[case] items: &[&str], + #[case] default: usize, + #[case] expected: usize, + ) { + let prompter = AutoYesPrompter; + let result = prompter + .select("Pick one", items, default) + .unwrap_or_else(|e| panic!("unexpected error: {e}")); + assert_eq!(result, expected); + } +} diff --git a/src/init/wizard/mod.rs b/src/init/wizard/mod.rs new file mode 100644 index 0000000..6bbf544 --- /dev/null +++ b/src/init/wizard/mod.rs @@ -0,0 +1,767 @@ +mod preview; +mod setup; + +use std::path::{Path, PathBuf}; + +use setup::{HookPolicy, ScopeResult, setup_scope}; + +use super::error::InitError; +use super::prompt::{AutoYesPrompter, DialoguerPrompter, Prompter}; + +/// Summary of actions performed by the init wizard. +struct Summary { + user_config_created: Option, + project_config_created: Option, + hook_registered: bool, + converted_rules: Option, + permissions_removed: bool, +} + +/// Scope for init configuration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum InitScope { + User, + Project, +} + +/// Paths resolved for the init wizard. +struct ResolvedPaths { + user_config_dir: PathBuf, + home_dir: PathBuf, +} + +/// Resolve user config directory and home directory. +fn resolve_paths() -> Result { + let home_dir = crate::config::dirs::home_dir().ok_or_else(|| { + InitError::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + "HOME not set", + )) + })?; + let user_config_dir = crate::config::dirs::config_dir() + .map(|d| d.join("runok")) + .ok_or_else(|| { + InitError::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + "could not determine user config directory (HOME not set)", + )) + })?; + Ok(ResolvedPaths { + user_config_dir, + home_dir, + }) +} + +fn print_summary(summary: &Summary) { + eprintln!(); + eprintln!("runok init complete:"); + if let Some(ref path) = summary.user_config_created { + eprintln!(" - User config created: {}", path.display()); + } + if let Some(ref path) = summary.project_config_created { + eprintln!(" - Project config created: {}", path.display()); + } + if summary.hook_registered { + eprintln!(" - Claude Code hook registered"); + } + if summary.converted_rules.is_some() { + eprintln!(" - Claude Code permissions converted to runok rules"); + } + if summary.permissions_removed { + eprintln!(" - Claude Code permissions removed from settings.json"); + } +} + +fn claude_dir_if_exists(dir: &Path) -> Option<&Path> { + if dir.exists() { Some(dir) } else { None } +} + +/// Apply a scope result to the summary, replacing fields. +fn apply_scope_result(summary: &mut Summary, result: ScopeResult, is_user: bool) { + if is_user { + summary.user_config_created = result.config_path; + } else { + summary.project_config_created = result.config_path; + } + summary.hook_registered = result.hook_registered; + summary.converted_rules = result.converted_rules; + summary.permissions_removed = result.permissions_removed; +} + +/// Run the init wizard. +/// +/// `scope`: optional scope from `--scope` flag +/// `auto_yes`: whether `-y` was specified +/// `cwd`: current working directory +pub fn run_wizard(scope: Option<&InitScope>, auto_yes: bool, cwd: &Path) -> Result<(), InitError> { + let paths = resolve_paths()?; + let prompter: Box = if auto_yes { + Box::new(AutoYesPrompter) + } else { + Box::new(DialoguerPrompter) + }; + run_wizard_with_paths( + scope, + prompter.as_ref(), + cwd, + &paths.user_config_dir, + &paths.home_dir, + ) +} + +/// Run the init wizard with explicit paths (for testing without relying on env vars). +pub fn run_wizard_with_paths( + scope: Option<&InitScope>, + prompter: &dyn Prompter, + cwd: &Path, + user_config_dir: &Path, + home_dir: &Path, +) -> Result<(), InitError> { + let mut summary = Summary { + user_config_created: None, + project_config_created: None, + hook_registered: false, + converted_rules: None, + permissions_removed: false, + }; + + match scope { + Some(InitScope::User) => { + let claude_dir = home_dir.join(".claude"); + let result = setup_scope( + user_config_dir, + claude_dir_if_exists(&claude_dir), + prompter, + HookPolicy::Register, + true, + )?; + apply_scope_result(&mut summary, result, true); + } + Some(InitScope::Project) => { + let claude_dir = cwd.join(".claude"); + let result = setup_scope( + cwd, + claude_dir_if_exists(&claude_dir), + prompter, + HookPolicy::Skip, + false, + )?; + apply_scope_result(&mut summary, result, false); + } + None => { + let items = ["User (global)", "Project (local)"]; + let selection = prompter.select("Where do you want to set up runok?", &items, 0)?; + + match selection { + 0 => { + let user_claude_dir = home_dir.join(".claude"); + let result = setup_scope( + user_config_dir, + claude_dir_if_exists(&user_claude_dir), + prompter, + HookPolicy::Register, + true, + )?; + apply_scope_result(&mut summary, result, true); + } + _ => { + let project_claude_dir = cwd.join(".claude"); + let result = setup_scope( + cwd, + claude_dir_if_exists(&project_claude_dir), + prompter, + HookPolicy::Skip, + false, + )?; + apply_scope_result(&mut summary, result, false); + } + } + } + } + + print_summary(&summary); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use indoc::indoc; + use rstest::rstest; + use tempfile::TempDir; + + /// Queued response for SequencePrompter: either a confirm (bool) or select (usize). + #[derive(Debug)] + enum Response { + Confirm(bool), + Select(usize), + } + + /// Test prompter that returns pre-configured responses in sequence. + struct SequencePrompter { + responses: std::cell::RefCell>, + } + + impl SequencePrompter { + fn new(responses: Vec) -> Self { + Self { + responses: std::cell::RefCell::new(responses), + } + } + + fn assert_exhausted(&self) { + let remaining = self.responses.borrow(); + assert!( + remaining.is_empty(), + "SequencePrompter has {} unused responses: {:?}", + remaining.len(), + &*remaining, + ); + } + } + + impl Prompter for SequencePrompter { + fn confirm(&self, _message: &str, default: bool) -> Result { + let mut responses = self.responses.borrow_mut(); + if responses.is_empty() { + return Ok(default); + } + match responses.remove(0) { + Response::Confirm(v) => Ok(v), + other => panic!("expected Confirm response, got {other:?}"), + } + } + + fn select( + &self, + _message: &str, + _items: &[&str], + default: usize, + ) -> Result { + let mut responses = self.responses.borrow_mut(); + if responses.is_empty() { + return Ok(default); + } + match responses.remove(0) { + Response::Select(v) => Ok(v), + other => panic!("expected Select response, got {other:?}"), + } + } + } + + /// Create a test environment with isolated home and project directories. + struct TestEnv { + _tmp: TempDir, + home: PathBuf, + cwd: PathBuf, + user_config_dir: PathBuf, + } + + impl TestEnv { + fn new() -> Self { + let tmp = TempDir::new().unwrap(); + let home = tmp.path().join("home"); + let cwd = tmp.path().join("project"); + let user_config_dir = home.join(".config").join("runok"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + Self { + _tmp: tmp, + home, + cwd, + user_config_dir, + } + } + + fn user_claude_dir(&self) -> PathBuf { + self.home.join(".claude") + } + + fn project_claude_dir(&self) -> PathBuf { + self.cwd.join(".claude") + } + + fn setup_user_claude_settings(&self, content: &str) { + let dir = self.user_claude_dir(); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("settings.json"), content).unwrap(); + } + + fn run(&self, scope: Option<&InitScope>, prompter: &dyn Prompter) -> Result<(), InitError> { + run_wizard_with_paths( + scope, + prompter, + &self.cwd, + &self.user_config_dir, + &self.home, + ) + } + } + + fn claude_settings_with_permissions() -> &'static str { + indoc! {r#" + { + "permissions": { + "allow": ["Bash(git status)", "Read(/tmp)"], + "deny": ["Bash(rm -rf /)"] + } + } + "#} + } + + #[rstest] + fn wizard_user_scope_creates_config() { + let env = TestEnv::new(); + env.run(Some(&InitScope::User), &AutoYesPrompter).unwrap(); + + assert!(env.user_config_dir.join("runok.yml").exists()); + } + + #[rstest] + fn wizard_project_scope_creates_config() { + let env = TestEnv::new(); + env.run(Some(&InitScope::Project), &AutoYesPrompter) + .unwrap(); + + assert!(env.cwd.join("runok.yml").exists()); + } + + #[rstest] + fn wizard_user_scope_with_claude_code_integration() { + let env = TestEnv::new(); + env.setup_user_claude_settings(claude_settings_with_permissions()); + + env.run(Some(&InitScope::User), &AutoYesPrompter).unwrap(); + + let config_content = + std::fs::read_to_string(env.user_config_dir.join("runok.yml")).unwrap(); + assert_eq!( + config_content, + indoc! {"\ + # yaml-language-server: $schema=https://raw.githubusercontent.com/fohte/runok/main/schema/runok.schema.json + + # Converted from Claude Code permissions: + rules: + - allow: 'git status' + - deny: 'rm -rf /' + "} + ); + + // Hook registered, Bash permissions removed, non-Bash preserved + let settings: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(env.user_claude_dir().join("settings.json")).unwrap(), + ) + .unwrap(); + assert_eq!( + settings, + serde_json::json!({ + "permissions": { + "allow": ["Read(/tmp)"] + }, + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "runok check --input-format claude-code-hook" + } + ] + } + ] + } + }) + ); + } + + #[rstest] + fn wizard_no_scope_with_auto_yes_selects_user() { + let env = TestEnv::new(); + + // AutoYesPrompter select default is 0 = User + env.run(None, &AutoYesPrompter).unwrap(); + + assert!(env.user_config_dir.join("runok.yml").exists()); + assert!(!env.cwd.join("runok.yml").exists()); + } + + #[rstest] + fn wizard_project_scope_auto_yes_migrates_and_applies() { + let env = TestEnv::new(); + let project_claude = env.project_claude_dir(); + std::fs::create_dir_all(&project_claude).unwrap(); + std::fs::write( + project_claude.join("settings.json"), + indoc! {r#" + { + "permissions": { + "allow": ["Bash(cargo test)"] + } + } + "#}, + ) + .unwrap(); + + // AutoYesPrompter: always returns true → migration Yes, apply Yes + env.run(Some(&InitScope::Project), &AutoYesPrompter) + .unwrap(); + + let config = std::fs::read_to_string(env.cwd.join("runok.yml")).unwrap(); + assert_eq!( + config, + indoc! {"\ + # yaml-language-server: $schema=https://raw.githubusercontent.com/fohte/runok/main/schema/runok.schema.json + + # Converted from Claude Code permissions: + rules: + - allow: 'cargo test' + "} + ); + + // Permissions removed (migration accepted, no hook for project scope) + let settings: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(project_claude.join("settings.json")).unwrap(), + ) + .unwrap(); + assert_eq!( + settings, + serde_json::json!({ + "permissions": {} + }) + ); + } + + #[rstest] + fn wizard_project_scope_migrates_when_opted_in() { + let env = TestEnv::new(); + let project_claude = env.project_claude_dir(); + std::fs::create_dir_all(&project_claude).unwrap(); + std::fs::write( + project_claude.join("settings.json"), + indoc! {r#" + { + "permissions": { + "allow": ["Bash(cargo test)"] + } + } + "#}, + ) + .unwrap(); + + // Confirm(true) for migration ask, Confirm(true) for batch apply + let prompter = + SequencePrompter::new(vec![Response::Confirm(true), Response::Confirm(true)]); + env.run(Some(&InitScope::Project), &prompter).unwrap(); + prompter.assert_exhausted(); + + let config_content = std::fs::read_to_string(env.cwd.join("runok.yml")).unwrap(); + assert_eq!( + config_content, + indoc! {"\ + # yaml-language-server: $schema=https://raw.githubusercontent.com/fohte/runok/main/schema/runok.schema.json + + # Converted from Claude Code permissions: + rules: + - allow: 'cargo test' + "} + ); + + // Permissions removed but no hook registered (project scope) + let settings: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(project_claude.join("settings.json")).unwrap(), + ) + .unwrap(); + assert_eq!( + settings, + serde_json::json!({ + "permissions": {} + }) + ); + } + + #[rstest] + fn wizard_project_scope_never_registers_hook() { + let env = TestEnv::new(); + let project_claude = env.project_claude_dir(); + std::fs::create_dir_all(&project_claude).unwrap(); + std::fs::write(project_claude.join("settings.json"), "{}").unwrap(); + + env.run(Some(&InitScope::Project), &AutoYesPrompter) + .unwrap(); + + // No hook should be added even though .claude exists + let settings: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(project_claude.join("settings.json")).unwrap(), + ) + .unwrap(); + assert_eq!(settings, serde_json::json!({})); + } + + // --- batch confirmation --- + + /// Helper: read and parse settings.json from a claude dir. + fn read_settings(claude_dir: &Path) -> serde_json::Value { + serde_json::from_str(&std::fs::read_to_string(claude_dir.join("settings.json")).unwrap()) + .unwrap() + } + + /// Hook JSON fragment used in expected settings assertions. + fn hook_json() -> serde_json::Value { + serde_json::json!({ + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "runok check --input-format claude-code-hook" + } + ] + } + ] + }) + } + + fn config_with_rules() -> String { + indoc! {"\ + # yaml-language-server: $schema=https://raw.githubusercontent.com/fohte/runok/main/schema/runok.schema.json + + # Converted from Claude Code permissions: + rules: + - allow: 'git status' + - deny: 'rm -rf /' + "} + .to_string() + } + + #[rstest] + fn wizard_batch_accept_applies_all_changes() { + let env = TestEnv::new(); + env.setup_user_claude_settings(claude_settings_with_permissions()); + + // Confirm(true) for migration ask, Confirm(true) for apply + let prompter = + SequencePrompter::new(vec![Response::Confirm(true), Response::Confirm(true)]); + env.run(Some(&InitScope::User), &prompter).unwrap(); + prompter.assert_exhausted(); + + let config_content = + std::fs::read_to_string(env.user_config_dir.join("runok.yml")).unwrap(); + assert_eq!(config_content, config_with_rules()); + + assert_eq!( + read_settings(&env.user_claude_dir()), + serde_json::json!({ + "permissions": { + "allow": ["Read(/tmp)"] + }, + "hooks": hook_json() + }), + ); + } + + #[rstest] + fn wizard_batch_decline_skips_all_changes() { + let env = TestEnv::new(); + env.setup_user_claude_settings(claude_settings_with_permissions()); + + // Confirm(true) for migration ask, Confirm(false) for apply + let prompter = + SequencePrompter::new(vec![Response::Confirm(true), Response::Confirm(false)]); + env.run(Some(&InitScope::User), &prompter).unwrap(); + prompter.assert_exhausted(); + + // runok.yml not created when user declined + assert!( + !env.user_config_dir.join("runok.yml").exists(), + "runok.yml should not be created when user declined all changes" + ); + + // settings.json unchanged + assert_eq!( + read_settings(&env.user_claude_dir()), + serde_json::json!({ + "permissions": { + "allow": ["Bash(git status)", "Read(/tmp)"], + "deny": ["Bash(rm -rf /)"] + } + }), + ); + } + + // --- scope selection --- + + #[rstest] + fn wizard_no_scope_select_user() { + let env = TestEnv::new(); + + // Select user (0) + let prompter = SequencePrompter::new(vec![Response::Select(0)]); + env.run(None, &prompter).unwrap(); + prompter.assert_exhausted(); + + assert!(env.user_config_dir.join("runok.yml").exists()); + assert!(!env.cwd.join("runok.yml").exists()); + } + + #[rstest] + fn wizard_no_scope_select_project() { + let env = TestEnv::new(); + + // Select project (1) + let prompter = SequencePrompter::new(vec![Response::Select(1)]); + env.run(None, &prompter).unwrap(); + prompter.assert_exhausted(); + + assert!(!env.user_config_dir.join("runok.yml").exists()); + assert!(env.cwd.join("runok.yml").exists()); + } + + // --- edge cases --- + + #[rstest] + fn wizard_claude_dir_without_settings_json_registers_hook_only() { + let env = TestEnv::new(); + // Create .claude dir but no settings.json + let claude_dir = env.user_claude_dir(); + std::fs::create_dir_all(&claude_dir).unwrap(); + + env.run(Some(&InitScope::User), &AutoYesPrompter).unwrap(); + + // runok.yml should be created with boilerplate only (no rules) + let config = std::fs::read_to_string(env.user_config_dir.join("runok.yml")).unwrap(); + assert_eq!( + config, + "# yaml-language-server: $schema=https://raw.githubusercontent.com/fohte/runok/main/schema/runok.schema.json\n" + ); + + // Hook should be registered in a newly created settings.json + assert_eq!( + read_settings(&claude_dir), + serde_json::json!({ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "runok check --input-format claude-code-hook" + } + ] + } + ] + } + }) + ); + } + + #[rstest] + fn wizard_non_bash_permissions_only_skips_rule_conversion() { + let env = TestEnv::new(); + env.setup_user_claude_settings(indoc! {r#" + { + "permissions": { + "allow": ["Read(/tmp)", "WebFetch", "Skill"], + "deny": ["Write(/etc/passwd)"] + } + } + "#}); + + env.run(Some(&InitScope::User), &AutoYesPrompter).unwrap(); + + let config = std::fs::read_to_string(env.user_config_dir.join("runok.yml")).unwrap(); + assert_eq!( + config, + "# yaml-language-server: $schema=https://raw.githubusercontent.com/fohte/runok/main/schema/runok.schema.json\n" + ); + + assert_eq!( + read_settings(&env.user_claude_dir()), + serde_json::json!({ + "permissions": { + "allow": ["Read(/tmp)", "WebFetch", "Skill"], + "deny": ["Write(/etc/passwd)"] + }, + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "runok check --input-format claude-code-hook" + } + ] + } + ] + } + }) + ); + } + + #[rstest] + fn wizard_hook_already_registered_no_prompt_needed() { + let env = TestEnv::new(); + env.setup_user_claude_settings(indoc! {r#" + { + "permissions": { + "allow": ["Bash(git status)"] + }, + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "runok check --input-format claude-code-hook" + } + ] + } + ] + } + } + "#}); + + // Has Bash permissions but hook already registered. + // Confirm(true) for migration, Confirm(true) for apply + let prompter = + SequencePrompter::new(vec![Response::Confirm(true), Response::Confirm(true)]); + env.run(Some(&InitScope::User), &prompter).unwrap(); + prompter.assert_exhausted(); + + let config = std::fs::read_to_string(env.user_config_dir.join("runok.yml")).unwrap(); + assert_eq!( + config, + indoc! {"\ + # yaml-language-server: $schema=https://raw.githubusercontent.com/fohte/runok/main/schema/runok.schema.json + + # Converted from Claude Code permissions: + rules: + - allow: 'git status' + "} + ); + + assert_eq!( + read_settings(&env.user_claude_dir()), + serde_json::json!({ + "permissions": {}, + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "runok check --input-format claude-code-hook" + } + ] + } + ] + } + }) + ); + } +} diff --git a/src/init/wizard/preview.rs b/src/init/wizard/preview.rs new file mode 100644 index 0000000..037ff0a --- /dev/null +++ b/src/init/wizard/preview.rs @@ -0,0 +1,297 @@ +use super::super::claude_code; +use super::super::error::InitError; + +/// Simulate removing Bash permission entries from settings.json content. +/// +/// Non-Bash entries are preserved. Mirrors `claude_code::remove_permissions`. +pub(super) fn preview_remove_permissions(content: &str) -> Result { + if content.is_empty() { + return Ok(content.to_string()); + } + let mut root: serde_json::Value = serde_json::from_str(content)?; + if let Some(obj) = root.get_mut("permissions").and_then(|p| p.as_object_mut()) { + for key in &["allow", "deny"] { + if let Some(arr) = obj.get_mut(*key).and_then(|v| v.as_array_mut()) { + arr.retain(|entry| { + entry + .as_str() + .and_then(claude_code::parse_permission_entry) + .is_none_or(|(tool, _)| tool != "Bash") + }); + if arr.is_empty() { + obj.remove(*key); + } + } + } + } + Ok(serde_json::to_string_pretty(&root)?) +} + +/// Simulate registering the hook in settings.json content and return the result. +/// Returns `None` if the hook is already registered. +pub(super) fn preview_register_hook(content: &str) -> Result, InitError> { + let mut root = if content.is_empty() { + serde_json::json!({}) + } else { + serde_json::from_str::(content)? + }; + + let hook_command = "runok check --input-format claude-code-hook"; + + // Check if already registered + if let Some(arr) = root + .get("hooks") + .and_then(|h| h.get("PreToolUse")) + .and_then(|p| p.as_array()) + { + for entry in arr { + if claude_code::entry_has_runok_hook(entry, hook_command) { + return Ok(None); + } + } + } + + let hook_entry = serde_json::json!({ + "matcher": "Bash", + "hooks": [{"type": "command", "command": hook_command}] + }); + + let hooks = root + .as_object_mut() + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "settings.json root is not an object", + ) + })? + .entry("hooks") + .or_insert_with(|| serde_json::json!({})); + + let pre_tool_use = hooks + .as_object_mut() + .ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidData, "hooks is not an object") + })? + .entry("PreToolUse") + .or_insert_with(|| serde_json::json!([])); + + pre_tool_use + .as_array_mut() + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "PreToolUse is not an array", + ) + })? + .push(hook_entry); + + Ok(Some(serde_json::to_string_pretty(&root)?)) +} + +/// Re-format JSON through serde to normalize indentation. +pub(super) fn normalize_json(content: &str) -> Result { + let value: serde_json::Value = serde_json::from_str(content)?; + Ok(serde_json::to_string_pretty(&value)?) +} + +/// Print a colored unified-style diff between two strings. +pub(super) fn print_diff(filename: &str, before: &str, after: &str) { + use similar::ChangeTag; + + let diff = similar::TextDiff::from_lines(before, after); + + // ANSI color codes + const RED: &str = "\x1b[31m"; + const GREEN: &str = "\x1b[32m"; + const CYAN: &str = "\x1b[36m"; + const RESET: &str = "\x1b[0m"; + + let (prefix_a, prefix_b) = if filename.starts_with('/') { + ("--- ", "+++ ") + } else { + ("--- a/", "+++ b/") + }; + eprintln!("{RED}{prefix_a}{filename}{RESET}"); + eprintln!("{GREEN}{prefix_b}{filename}{RESET}"); + + for group in diff.grouped_ops(3) { + let first = &group[0]; + let last = &group[group.len() - 1]; + let old_start = first.old_range().start + 1; + let old_len = last.old_range().end - first.old_range().start; + let new_start = first.new_range().start + 1; + let new_len = last.new_range().end - first.new_range().start; + eprintln!("{CYAN}@@ -{old_start},{old_len} +{new_start},{new_len} @@{RESET}"); + for op in &group { + for change in diff.iter_changes(op) { + let (sign, color) = match change.tag() { + ChangeTag::Delete => ("-", RED), + ChangeTag::Insert => ("+", GREEN), + ChangeTag::Equal => (" ", ""), + }; + eprint!("{color}{sign}{change}{RESET}"); + if change.missing_newline() { + eprintln!(); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use indoc::indoc; + use rstest::rstest; + + #[rstest] + fn normalize_json_reformats_indentation() { + let input = indoc! {r#" + { + "key": "value" + }"#}; + let result = normalize_json(input).unwrap(); + assert_eq!( + result, + indoc! {r#" + { + "key": "value" + }"#} + ); + } + + #[rstest] + fn preview_remove_permissions_strips_only_bash_entries() { + let input = indoc! {r#" + { + "permissions": { + "allow": ["Bash(git status)", "Read(/tmp)", "WebFetch"], + "deny": ["Bash(rm *)", "NotebookEdit"], + "defaultMode": "acceptEdits" + }, + "hooks": {} + }"#}; + let result = preview_remove_permissions(input).unwrap(); + let value: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!( + value, + serde_json::json!({ + "permissions": { + "allow": ["Read(/tmp)", "WebFetch"], + "deny": ["NotebookEdit"], + "defaultMode": "acceptEdits" + }, + "hooks": {} + }) + ); + } + + #[rstest] + fn preview_remove_permissions_empty_input() { + let result = preview_remove_permissions("").unwrap(); + assert_eq!(result, ""); + } + + #[rstest] + fn preview_register_hook_adds_hook_entry() { + let input = indoc! {r#" + { + "permissions": {} + }"#}; + let result = preview_register_hook(input).unwrap().unwrap(); + let value: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!( + value["hooks"]["PreToolUse"], + serde_json::json!([ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "runok check --input-format claude-code-hook" + } + ] + } + ]) + ); + } + + #[rstest] + fn preview_register_hook_returns_none_when_already_registered() { + let input = indoc! {r#" + { + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "runok check --input-format claude-code-hook" + } + ] + } + ] + } + }"#}; + let result = preview_register_hook(input).unwrap(); + assert_eq!(result, None); + } + + #[rstest] + fn preview_register_hook_returns_none_for_legacy_format() { + let input = indoc! {r#" + { + "hooks": { + "PreToolUse": [ + { + "type": "command", + "command": "runok check --input-format claude-code-hook" + } + ] + } + } + "#}; + let result = preview_register_hook(input).unwrap(); + assert_eq!(result, None); + } + + #[rstest] + fn preview_register_hook_returns_none_for_string_format() { + let input = indoc! {r#" + { + "hooks": { + "PreToolUse": [ + "runok check --input-format claude-code-hook" + ] + } + } + "#}; + let result = preview_register_hook(input).unwrap(); + assert_eq!(result, None); + } + + #[rstest] + fn preview_register_hook_empty_input() { + let result = preview_register_hook("").unwrap().unwrap(); + let value: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!( + value, + serde_json::json!({ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "runok check --input-format claude-code-hook" + } + ] + } + ] + } + }) + ); + } +} diff --git a/src/init/wizard/setup.rs b/src/init/wizard/setup.rs new file mode 100644 index 0000000..638b3a1 --- /dev/null +++ b/src/init/wizard/setup.rs @@ -0,0 +1,215 @@ +use std::path::{Path, PathBuf}; + +use super::super::error::InitError; +use super::super::prompt::Prompter; +use super::super::{claude_code, config_gen}; +use super::preview::{ + normalize_json, preview_register_hook, preview_remove_permissions, print_diff, +}; + +/// Result of setting up a single scope. +pub(super) struct ScopeResult { + pub config_path: Option, + pub hook_registered: bool, + pub converted_rules: Option, + pub permissions_removed: bool, +} + +/// Whether to register the runok hook in settings.json. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum HookPolicy { + /// Register hook (user scope). + Register, + /// Never register hook (project scope — shared config should not + /// assume all contributors use runok). + Skip, +} + +/// Set up configuration for a given scope (user or project). +/// +/// `hook_policy` controls whether the runok hook is registered in settings.json. +/// `migration_default` controls the default answer for the migration prompt +/// (true for user scope, false for project scope). +pub(super) fn setup_scope( + config_dir: &Path, + claude_dir: Option<&Path>, + prompter: &dyn Prompter, + hook_policy: HookPolicy, + migration_default: bool, +) -> Result { + let mut converted_rules = None; + let mut approved = false; + let mut detected_claude_config = false; + let mut has_rules = false; + let mut has_hook_change = false; + + if let Some(cd) = claude_dir + && cd.exists() + { + // Read current settings.json + let settings_path = cd.join("settings.json"); + let original_content = if settings_path.exists() { + normalize_json(&std::fs::read_to_string(&settings_path)?)? + } else { + String::new() + }; + + // Determine what changes are available + let (allow, deny) = claude_code::read_permissions(cd)?; + let has_permissions = !allow.is_empty() || !deny.is_empty(); + let has_migratable_rules = if has_permissions { + let conversion = claude_code::convert_permissions(&allow, &deny); + !conversion.rules.is_empty() + } else { + false + }; + + // Check if hook registration would change anything + let would_add_hook = if hook_policy == HookPolicy::Register { + preview_register_hook(&original_content)?.is_some() + } else { + false + }; + + // Only show "Detected" and ask migration if there's something to do + if has_migratable_rules || would_add_hook { + detected_claude_config = true; + let settings_path_display = settings_path.display(); + eprintln!( + "\x1b[1mDetected Claude Code configuration in {settings_path_display}\x1b[0m" + ); + eprintln!(); + + // Ask whether to migrate Bash permissions + if has_migratable_rules { + let conversion = claude_code::convert_permissions(&allow, &deny); + let should_migrate = prompter.confirm( + "Migrate Claude Code Bash permissions to runok rules?", + migration_default, + )?; + if should_migrate { + converted_rules = Some(conversion.rules.clone()); + has_rules = true; + } + } + + // Build the preview + let after_permissions = if has_rules { + preview_remove_permissions(&original_content)? + } else { + original_content.clone() + }; + + if hook_policy == HookPolicy::Register { + let hook_preview = preview_register_hook(&after_permissions)?; + has_hook_change = hook_preview.is_some(); + } + + let config_path = config_dir.join("runok.yml"); + let config_path_display = config_path.display(); + + // Show all diffs together + if has_rules { + eprintln!("\x1b[1mRemove Bash permissions from {settings_path_display}\x1b[0m"); + eprintln!(); + print_diff( + &settings_path_display.to_string(), + &original_content, + &after_permissions, + ); + eprintln!(); + } + + // Always show runok.yml creation/update diff + let config_content = config_gen::build_config_content(converted_rules.as_deref()); + let existing_config = if config_path.exists() { + std::fs::read_to_string(&config_path)? + } else { + String::new() + }; + let verb = if config_path.exists() { + "Update" + } else { + "Create" + }; + eprintln!("\x1b[1m{verb} {config_path_display}\x1b[0m"); + eprintln!(); + print_diff( + &config_path_display.to_string(), + &existing_config, + &config_content, + ); + eprintln!(); + + if has_hook_change { + let hook_preview = preview_register_hook(&after_permissions)?; + eprintln!("\x1b[1mRegister runok hook in {settings_path_display}\x1b[0m"); + eprintln!(); + if let Some(ref after_hook) = hook_preview { + print_diff( + &settings_path_display.to_string(), + &after_permissions, + after_hook, + ); + } + eprintln!(); + } + + approved = prompter.confirm("Apply these changes?", true)?; + if !approved { + converted_rules = None; + } + } + } + + // Apply changes only when the user approved the batch + let permissions_removed = if approved && has_rules { + claude_dir + .map(claude_code::remove_permissions) + .transpose()? + .unwrap_or(false) + } else { + false + }; + + let hook_registered = if approved && has_hook_change { + claude_dir + .map(claude_code::register_hook) + .transpose()? + .unwrap_or(false) + } else { + false + }; + + // Create config file: + // - User approved changes in "Detected" block: create with converted rules + // - No Claude Code config detected: create boilerplate (ask if file exists) + // - User declined all changes: skip (don't create silently) + let config_path = if approved { + let content = config_gen::build_config_content(converted_rules.as_deref()); + Some(config_gen::write_config(config_dir, &content)?) + } else if !detected_claude_config { + let config_path = config_dir.join("runok.yml"); + if config_path.exists() { + let overwrite = prompter.confirm("runok.yml already exists. Overwrite?", false)?; + if overwrite { + let content = config_gen::build_config_content(None); + Some(config_gen::write_config(config_dir, &content)?) + } else { + None + } + } else { + let content = config_gen::build_config_content(None); + Some(config_gen::write_config(config_dir, &content)?) + } + } else { + None + }; + + Ok(ScopeResult { + config_path, + hook_registered, + converted_rules, + permissions_removed, + }) +} diff --git a/src/lib.rs b/src/lib.rs index c5a836f..d8eedec 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod adapter; pub mod config; pub mod exec; +pub mod init; pub mod rules; diff --git a/src/main.rs b/src/main.rs index e34c7c2..43570e4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -61,11 +61,31 @@ fn main() -> ExitCode { return run_sandbox_exec(args); } + // Init runs independently without loading config + if let Commands::Init(ref args) = cli.command { + return run_init(args); + } + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let exit_code = run_command(cli.command, &cwd, std::io::stdin()); ExitCode::from(exit_code as u8) } +fn run_init(args: &cli::InitArgs) -> ExitCode { + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let scope = args.scope.as_ref().map(|s| match s { + cli::InitScope::User => runok::init::InitScope::User, + cli::InitScope::Project => runok::init::InitScope::Project, + }); + match runok::init::run_wizard(scope.as_ref(), args.yes, &cwd) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("runok: {e}"); + ExitCode::FAILURE + } + } +} + fn run_command(command: Commands, cwd: &std::path::Path, stdin: impl std::io::Read) -> i32 { let loader = DefaultConfigLoader::new(); @@ -74,6 +94,7 @@ fn run_command(command: Commands, cwd: &std::path::Path, stdin: impl std::io::Re let config_error_exit_code = match &command { Commands::Exec(_) => 1, Commands::Check(_) => 2, + Commands::Init(_) => unreachable!("handled in main()"), #[cfg(feature = "config-schema")] Commands::ConfigSchema => unreachable!("handled in main()"), #[cfg(target_os = "linux")] @@ -133,6 +154,7 @@ fn run_command(command: Commands, cwd: &std::path::Path, stdin: impl std::io::Re } } } + Commands::Init(_) => unreachable!("handled in main()"), #[cfg(feature = "config-schema")] Commands::ConfigSchema => unreachable!("handled in main()"), #[cfg(target_os = "linux")] diff --git a/tests/e2e/helpers.rs b/tests/e2e/helpers.rs index 41c43fe..c4196c4 100644 --- a/tests/e2e/helpers.rs +++ b/tests/e2e/helpers.rs @@ -15,7 +15,7 @@ pub struct TestEnv { _tmp: TempDir, pub cwd: PathBuf, /// Isolated HOME directory to prevent global config interference. - home: PathBuf, + pub home: PathBuf, } impl TestEnv { @@ -41,6 +41,9 @@ impl TestEnv { let mut cmd = assert_cmd::cargo_bin_cmd!("runok"); cmd.current_dir(&self.cwd); cmd.env("HOME", &self.home); + // Clear XDG dirs so config_dir() falls back to $HOME/.config + cmd.env_remove("XDG_CONFIG_HOME"); + cmd.env_remove("XDG_CACHE_HOME"); cmd } } diff --git a/tests/e2e/init.rs b/tests/e2e/init.rs new file mode 100644 index 0000000..af1aa55 --- /dev/null +++ b/tests/e2e/init.rs @@ -0,0 +1,150 @@ +use helpers::TestEnv; +use indoc::indoc; +use rstest::rstest; + +use crate::helpers; + +/// Extended TestEnv for init tests with an isolated HOME. +struct InitTestEnv { + env: TestEnv, +} + +impl InitTestEnv { + fn new() -> Self { + Self { + env: TestEnv::new(""), + } + } + + fn command(&self) -> assert_cmd::Command { + self.env.command() + } + + fn cwd(&self) -> &std::path::Path { + &self.env.cwd + } + + fn home(&self) -> &std::path::Path { + &self.env.home + } +} + +#[rstest] +fn init_user_scope_creates_config() { + let env = InitTestEnv::new(); + env.command() + .args(["init", "--scope", "user", "-y"]) + .assert() + .success() + .stderr(predicates::str::contains("runok init complete:")); +} + +#[rstest] +fn init_project_scope_creates_config() { + let env = InitTestEnv::new(); + // Remove the default runok.yml that TestEnv creates + let _ = std::fs::remove_file(env.cwd().join("runok.yml")); + + env.command() + .args(["init", "--scope", "project", "-y"]) + .assert() + .success() + .stderr(predicates::str::contains("Project config created")); + + assert!(env.cwd().join("runok.yml").exists()); +} + +#[rstest] +fn init_project_scope_overwrites_existing() { + let env = InitTestEnv::new(); + // TestEnv already creates runok.yml — should be overwritten + + env.command() + .args(["init", "--scope", "project", "-y"]) + .assert() + .success() + .stderr(predicates::str::contains("Project config created")); +} + +#[rstest] +fn init_user_scope_with_claude_code_integration() { + let env = InitTestEnv::new(); + + // Set up ~/.claude/settings.json in isolated HOME + let claude_dir = env.home().join(".claude"); + std::fs::create_dir_all(&claude_dir) + .unwrap_or_else(|e| panic!("failed to create .claude dir: {e}")); + std::fs::write( + claude_dir.join("settings.json"), + indoc! {r#" + { + "permissions": { + "allow": ["Bash(git status)", "Bash(npm install *)"], + "deny": ["Bash(rm -rf /)"] + } + } + "#}, + ) + .unwrap_or_else(|e| panic!("failed to write settings.json: {e}")); + + env.command() + .args(["init", "--scope", "user", "-y"]) + .assert() + .success() + .stderr(predicates::str::contains( + "permissions converted to runok rules", + )) + .stderr(predicates::str::contains("Claude Code hook registered")); + + // Verify config was created with converted rules + let user_config_dir = env.home().join(".config").join("runok"); + let config = std::fs::read_to_string(user_config_dir.join("runok.yml")) + .unwrap_or_else(|e| panic!("failed to read config: {e}")); + assert_eq!( + config, + indoc! {"\ + # yaml-language-server: $schema=https://raw.githubusercontent.com/fohte/runok/main/schema/runok.schema.json + + # Converted from Claude Code permissions: + rules: + - allow: 'git status' + - allow: 'npm install *' + - deny: 'rm -rf /' + "} + ); + + // Verify hook registered and permissions removed + let settings_json: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(claude_dir.join("settings.json")) + .unwrap_or_else(|e| panic!("failed to read settings: {e}")), + ) + .unwrap_or_else(|e| panic!("failed to parse settings: {e}")); + assert_eq!( + settings_json, + serde_json::json!({ + "permissions": {}, + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "runok check --input-format claude-code-hook" + } + ] + } + ] + } + }) + ); +} + +#[rstest] +fn init_invalid_scope() { + let env = InitTestEnv::new(); + env.command() + .args(["init", "--scope", "invalid"]) + .assert() + .failure(); +} diff --git a/tests/e2e/main.rs b/tests/e2e/main.rs index 45f3af3..a42eb9e 100644 --- a/tests/e2e/main.rs +++ b/tests/e2e/main.rs @@ -5,3 +5,4 @@ mod cli; mod error_handling; mod exec; mod helpers; +mod init; diff --git a/tests/integration/init_wizard.rs b/tests/integration/init_wizard.rs new file mode 100644 index 0000000..0e4b82e --- /dev/null +++ b/tests/integration/init_wizard.rs @@ -0,0 +1,867 @@ +use std::cell::RefCell; + +use indoc::indoc; +use rstest::rstest; +use tempfile::TempDir; + +use runok::init::error::InitError; +use runok::init::prompt::Prompter; +use runok::init::{InitScope, run_wizard_with_paths}; + +/// Queued response for SequencePrompter. +#[derive(Debug, Clone)] +enum Response { + Confirm(bool), + Select(usize), +} + +/// Test prompter that returns pre-configured responses in sequence. +struct SequencePrompter { + responses: RefCell>, +} + +impl SequencePrompter { + fn new(responses: Vec) -> Self { + Self { + responses: RefCell::new(responses), + } + } +} + +impl Prompter for SequencePrompter { + fn confirm(&self, _message: &str, default: bool) -> Result { + let mut responses = self.responses.borrow_mut(); + if responses.is_empty() { + return Ok(default); + } + match responses.remove(0) { + Response::Confirm(v) => Ok(v), + other => unreachable!("expected Confirm response, got {other:?}"), + } + } + + fn select(&self, _message: &str, _items: &[&str], default: usize) -> Result { + let mut responses = self.responses.borrow_mut(); + if responses.is_empty() { + return Ok(default); + } + match responses.remove(0) { + Response::Select(v) => Ok(v), + other => unreachable!("expected Select response, got {other:?}"), + } + } +} + +/// Test environment for init wizard integration tests. +/// +/// Uses explicit paths instead of environment variables to avoid data races. +struct InitTestEnv { + _tmp: TempDir, + home: std::path::PathBuf, + cwd: std::path::PathBuf, + user_config_dir: std::path::PathBuf, +} + +/// Content pre-seeded into runok.yml for "existing config" test cases. +const EXISTING_CONFIG: &str = "\ +# existing user config +rules: + - allow: 'echo hello' +"; + +impl InitTestEnv { + fn new() -> Result> { + let tmp = TempDir::new()?; + let home = tmp.path().join("home"); + let cwd = tmp.path().join("project"); + let user_config_dir = home.join(".config").join("runok"); + std::fs::create_dir_all(&home)?; + std::fs::create_dir_all(&cwd)?; + + Ok(Self { + _tmp: tmp, + home, + cwd, + user_config_dir, + }) + } + + fn user_config_path(&self) -> std::path::PathBuf { + self.user_config_dir.join("runok.yml") + } + + fn user_claude_dir(&self) -> std::path::PathBuf { + self.home.join(".claude") + } + + fn project_claude_dir(&self) -> std::path::PathBuf { + self.cwd.join(".claude") + } + + fn claude_dir_for_scope(&self, scope: &InitScope) -> std::path::PathBuf { + match scope { + InitScope::User => self.user_claude_dir(), + InitScope::Project => self.project_claude_dir(), + } + } + + fn config_path_for_scope(&self, scope: &InitScope) -> std::path::PathBuf { + match scope { + InitScope::User => self.user_config_path(), + InitScope::Project => self.cwd.join("runok.yml"), + } + } + + fn setup_claude_settings( + &self, + scope: &InitScope, + content: &str, + ) -> Result<(), Box> { + let dir = self.claude_dir_for_scope(scope); + std::fs::create_dir_all(&dir)?; + std::fs::write(dir.join("settings.json"), content)?; + Ok(()) + } + + fn setup_existing_config(&self, scope: &InitScope) -> Result<(), Box> { + let path = self.config_path_for_scope(scope); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&path, EXISTING_CONFIG)?; + Ok(()) + } + + fn run(&self, scope: Option<&InitScope>, prompter: &dyn Prompter) -> Result<(), InitError> { + run_wizard_with_paths( + scope, + prompter, + &self.cwd, + &self.user_config_dir, + &self.home, + ) + } +} + +// --- constants for expected outputs --- + +const BOILERPLATE: &str = "\ +# yaml-language-server: $schema=https://raw.githubusercontent.com/fohte/runok/main/schema/runok.schema.json +"; + +fn hook_json() -> serde_json::Value { + serde_json::json!({ + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "runok check --input-format claude-code-hook" + } + ] + } + ] + }) +} + +// --- settings.json fixtures --- + +/// Bash permissions only (no non-Bash, no hook) +const SETTINGS_BASH_ONLY: &str = r#" +{ + "permissions": { + "allow": ["Bash(cargo test)", "Bash(cargo build)"], + "deny": ["Bash(rm -rf /)"] + } +} +"#; + +/// Bash permissions only, with hook already registered +fn settings_bash_only_with_hook() -> &'static str { + indoc! {r#" + { + "permissions": { + "allow": ["Bash(cargo test)", "Bash(cargo build)"], + "deny": ["Bash(rm -rf /)"] + }, + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "runok check --input-format claude-code-hook" + } + ] + } + ] + } + } + "#} +} + +/// No Bash permissions, no hook +const SETTINGS_NO_BASH_NO_HOOK: &str = r#" +{ + "permissions": { + "allow": ["Read(/tmp)", "WebFetch"], + "deny": ["Write(/etc/passwd)"] + } +} +"#; + +/// No Bash permissions, with hook already registered +fn settings_no_bash_with_hook() -> &'static str { + indoc! {r#" + { + "permissions": { + "allow": ["Read(/tmp)", "WebFetch"], + "deny": ["Write(/etc/passwd)"] + }, + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "runok check --input-format claude-code-hook" + } + ] + } + ] + } + } + "#} +} + +/// Expected runok.yml with converted rules from SETTINGS_BASH_ONLY +fn config_with_bash_rules() -> String { + indoc! {"\ + # yaml-language-server: $schema=https://raw.githubusercontent.com/fohte/runok/main/schema/runok.schema.json + + # Converted from Claude Code permissions: + rules: + - allow: 'cargo test' + - allow: 'cargo build' + - deny: 'rm -rf /' + "} + .to_string() +} + +// ============================================================ +// Exhaustive 51-pattern test +// ============================================================ +// +// Condition axes, grouped into State / Response / Result: +// +// | | State | Response | Result | +// | # | settings.json | Bash perms | Hook exists | Scope | runok.yml | Migrate? | Apply? | Overwrite? | runok.yml | settings.json change | +// |----|---------------|------------|-------------|---------|-----------|----------|--------|------------|-------------|----------------------| +// | 1 | no | N/A | N/A | user | no | N/A | N/A | N/A | boilerplate | N/A | +// | 2 | no | N/A | N/A | user | yes | N/A | N/A | yes | boilerplate | N/A | +// | 3 | no | N/A | N/A | user | yes | N/A | N/A | no | preserved | N/A | +// | 4 | no | N/A | N/A | project | no | N/A | N/A | N/A | boilerplate | N/A | +// | 5 | no | N/A | N/A | project | yes | N/A | N/A | yes | boilerplate | N/A | +// | 6 | no | N/A | N/A | project | yes | N/A | N/A | no | preserved | N/A | +// | 7 | yes | no | no | user | no | N/A | yes | N/A | boilerplate | hook added | +// | 8 | yes | no | no | user | yes | N/A | yes | N/A | boilerplate | hook added | +// | 9 | yes | no | no | user | no | N/A | no | N/A | none | none | +// | 10 | yes | no | no | user | yes | N/A | no | N/A | preserved | none | +// | 11 | yes | no | yes | user | no | N/A | N/A | N/A | boilerplate | none | +// | 12 | yes | no | yes | user | yes | N/A | N/A | yes | boilerplate | none | +// | 13 | yes | no | yes | user | yes | N/A | N/A | no | preserved | none | +// | 14 | yes | no | no | project | no | N/A | N/A | N/A | boilerplate | none | +// | 15 | yes | no | no | project | yes | N/A | N/A | yes | boilerplate | none | +// | 16 | yes | no | no | project | yes | N/A | N/A | no | preserved | none | +// | 17 | yes | no | yes | project | no | N/A | N/A | N/A | boilerplate | none | +// | 18 | yes | no | yes | project | yes | N/A | N/A | yes | boilerplate | none | +// | 19 | yes | no | yes | project | yes | N/A | N/A | no | preserved | none | +// | 20 | yes | yes | no | user | no | yes | yes | N/A | with rules | perms removed + hook | +// | 21 | yes | yes | no | user | yes | yes | yes | N/A | with rules | perms removed + hook | +// | 22 | yes | yes | no | user | no | yes | no | N/A | none | none | +// | 23 | yes | yes | no | user | yes | yes | no | N/A | preserved | none | +// | 24 | yes | yes | no | user | no | no | yes | N/A | boilerplate | hook added | +// | 25 | yes | yes | no | user | yes | no | yes | N/A | boilerplate | hook added | +// | 26 | yes | yes | no | user | no | no | no | N/A | none | none | +// | 27 | yes | yes | no | user | yes | no | no | N/A | preserved | none | +// | 28 | yes | yes | yes | user | no | yes | yes | N/A | with rules | perms removed | +// | 29 | yes | yes | yes | user | yes | yes | yes | N/A | with rules | perms removed | +// | 30 | yes | yes | yes | user | no | yes | no | N/A | none | none | +// | 31 | yes | yes | yes | user | yes | yes | no | N/A | preserved | none | +// | 32 | yes | yes | yes | user | no | no | yes | N/A | boilerplate | none | +// | 33 | yes | yes | yes | user | yes | no | yes | N/A | boilerplate | none | +// | 34 | yes | yes | yes | user | no | no | no | N/A | none | none | +// | 35 | yes | yes | yes | user | yes | no | no | N/A | preserved | none | +// | 36 | yes | yes | no | project | no | yes | yes | N/A | with rules | perms removed | +// | 37 | yes | yes | no | project | yes | yes | yes | N/A | with rules | perms removed | +// | 38 | yes | yes | no | project | no | yes | no | N/A | none | none | +// | 39 | yes | yes | no | project | yes | yes | no | N/A | preserved | none | +// | 40 | yes | yes | no | project | no | no | yes | N/A | boilerplate | none | +// | 41 | yes | yes | no | project | yes | no | yes | N/A | boilerplate | none | +// | 42 | yes | yes | no | project | no | no | no | N/A | none | none | +// | 43 | yes | yes | no | project | yes | no | no | N/A | preserved | none | +// | 44 | yes | yes | yes | project | no | yes | yes | N/A | with rules | perms removed | +// | 45 | yes | yes | yes | project | yes | yes | yes | N/A | with rules | perms removed | +// | 46 | yes | yes | yes | project | no | yes | no | N/A | none | none | +// | 47 | yes | yes | yes | project | yes | yes | no | N/A | preserved | none | +// | 48 | yes | yes | yes | project | no | no | yes | N/A | boilerplate | none | +// | 49 | yes | yes | yes | project | yes | no | yes | N/A | boilerplate | none | +// | 50 | yes | yes | yes | project | no | no | no | N/A | none | none | +// | 51 | yes | yes | yes | project | yes | no | no | N/A | preserved | none | +// +// "preserved" in Result means the existing runok.yml is left unchanged (wizard does not touch it) +// "none" in Result means runok.yml does not exist after the wizard +// Overwrite? is only asked when there is no "Detected" block and runok.yml already exists + +/// Expected runok.yml content after the wizard runs. +enum ExpectedConfig { + /// runok.yml is created/overwritten with the given content. + Content(&'static str), + /// runok.yml is created/overwritten with a computed String. + ContentOwned(String), + /// runok.yml does not exist (was not created, and none existed before). + None, + /// runok.yml is preserved as-is (wizard did not touch it). + Preserved, +} + +/// Helper to assert the final state after running the wizard. +fn assert_wizard_result( + env: &InitTestEnv, + scope: &InitScope, + expected_config: &ExpectedConfig, + expected_settings: Option, +) -> Result<(), Box> { + let config_path = env.config_path_for_scope(scope); + match expected_config { + ExpectedConfig::Content(expected) => { + let config = std::fs::read_to_string(&config_path)?; + assert_eq!(config, *expected, "runok.yml content mismatch"); + } + ExpectedConfig::ContentOwned(expected) => { + let config = std::fs::read_to_string(&config_path)?; + assert_eq!(config, *expected, "runok.yml content mismatch"); + } + ExpectedConfig::None => { + assert!( + !config_path.exists(), + "runok.yml should not exist but was found at {}", + config_path.display() + ); + } + ExpectedConfig::Preserved => { + let config = std::fs::read_to_string(&config_path)?; + assert_eq!( + config, EXISTING_CONFIG, + "runok.yml should be preserved but was modified" + ); + } + } + + if let Some(expected) = expected_settings { + let settings_path = env.claude_dir_for_scope(scope).join("settings.json"); + let content = std::fs::read_to_string(&settings_path)?; + let actual: serde_json::Value = serde_json::from_str(&content)?; + assert_eq!(actual, expected, "settings.json content mismatch"); + } + + Ok(()) +} + +// --- test case parameter struct --- + +/// All parameters for a single exhaustive wizard test case. +struct Case { + /// Content of settings.json before the wizard runs, or None to skip creating it. + settings: Option<&'static str>, + /// Scope to pass to the wizard. + scope: InitScope, + /// Whether to pre-seed runok.yml with EXISTING_CONFIG. + existing_config: bool, + /// Responses the prompter will return. + responses: Vec, + /// Expected runok.yml state after the wizard. + expected_config: ExpectedConfig, + /// Expected settings.json content after the wizard, or None to skip checking. + expected_settings: Option, + /// Whether to assert that settings.json was NOT created (for no-settings cases). + assert_no_settings_created: bool, +} + +// --- shorthand helpers for expected settings values --- + +fn no_bash_perms() -> serde_json::Value { + serde_json::json!({ + "permissions": { "allow": ["Read(/tmp)", "WebFetch"], "deny": ["Write(/etc/passwd)"] } + }) +} + +fn no_bash_perms_with_hook() -> serde_json::Value { + serde_json::json!({ + "permissions": { "allow": ["Read(/tmp)", "WebFetch"], "deny": ["Write(/etc/passwd)"] }, + "hooks": hook_json() + }) +} + +fn bash_perms_unchanged() -> serde_json::Value { + serde_json::json!({ + "permissions": { "allow": ["Bash(cargo test)", "Bash(cargo build)"], "deny": ["Bash(rm -rf /)"] } + }) +} + +fn bash_perms_with_hook() -> serde_json::Value { + serde_json::json!({ + "permissions": { "allow": ["Bash(cargo test)", "Bash(cargo build)"], "deny": ["Bash(rm -rf /)"] }, + "hooks": hook_json() + }) +} + +fn perms_removed_with_hook() -> serde_json::Value { + serde_json::json!({ "permissions": {}, "hooks": hook_json() }) +} + +/// For project scope: permissions removed but no hook added (hook is user-scope only in migration) +fn perms_removed_no_hook() -> serde_json::Value { + serde_json::json!({ "permissions": {} }) +} + +fn bash_hook_original() -> serde_json::Value { + serde_json::json!({ + "permissions": { + "allow": ["Bash(cargo test)", "Bash(cargo build)"], + "deny": ["Bash(rm -rf /)"] + }, + "hooks": hook_json() + }) +} + +// --- shorthand aliases for Response --- + +fn yes() -> Response { + Response::Confirm(true) +} + +fn no() -> Response { + Response::Confirm(false) +} + +#[rstest] +// --- No settings.json (cases 1-6) --- +#[case::p01_no_settings_user(Case { + settings: None, scope: InitScope::User, existing_config: false, + responses: vec![], + expected_config: ExpectedConfig::Content(BOILERPLATE), + expected_settings: None, + assert_no_settings_created: true, +})] +#[case::p02_no_settings_user_existing_overwrite_yes(Case { + settings: None, scope: InitScope::User, existing_config: true, + responses: vec![yes()], + expected_config: ExpectedConfig::Content(BOILERPLATE), + expected_settings: None, + assert_no_settings_created: false, +})] +#[case::p03_no_settings_user_existing_overwrite_no(Case { + settings: None, scope: InitScope::User, existing_config: true, + responses: vec![no()], + expected_config: ExpectedConfig::Preserved, + expected_settings: None, + assert_no_settings_created: false, +})] +#[case::p04_no_settings_project(Case { + settings: None, scope: InitScope::Project, existing_config: false, + responses: vec![], + expected_config: ExpectedConfig::Content(BOILERPLATE), + expected_settings: None, + assert_no_settings_created: true, +})] +#[case::p05_no_settings_project_existing_overwrite_yes(Case { + settings: None, scope: InitScope::Project, existing_config: true, + responses: vec![yes()], + expected_config: ExpectedConfig::Content(BOILERPLATE), + expected_settings: None, + assert_no_settings_created: false, +})] +#[case::p06_no_settings_project_existing_overwrite_no(Case { + settings: None, scope: InitScope::Project, existing_config: true, + responses: vec![no()], + expected_config: ExpectedConfig::Preserved, + expected_settings: None, + assert_no_settings_created: false, +})] +// --- No Bash perms, no hook (cases 7-10) --- +#[case::p07_no_bash_no_hook_user_apply_yes(Case { + settings: Some(SETTINGS_NO_BASH_NO_HOOK), scope: InitScope::User, existing_config: false, + responses: vec![yes()], + expected_config: ExpectedConfig::Content(BOILERPLATE), + expected_settings: Some(no_bash_perms_with_hook()), + assert_no_settings_created: false, +})] +#[case::p08_no_bash_no_hook_user_existing_apply_yes(Case { + settings: Some(SETTINGS_NO_BASH_NO_HOOK), scope: InitScope::User, existing_config: true, + responses: vec![yes()], + expected_config: ExpectedConfig::Content(BOILERPLATE), + expected_settings: Some(no_bash_perms_with_hook()), + assert_no_settings_created: false, +})] +#[case::p09_no_bash_no_hook_user_apply_no(Case { + settings: Some(SETTINGS_NO_BASH_NO_HOOK), scope: InitScope::User, existing_config: false, + responses: vec![no()], + expected_config: ExpectedConfig::None, + expected_settings: Some(no_bash_perms()), + assert_no_settings_created: false, +})] +#[case::p10_no_bash_no_hook_user_existing_apply_no(Case { + settings: Some(SETTINGS_NO_BASH_NO_HOOK), scope: InitScope::User, existing_config: true, + responses: vec![no()], + expected_config: ExpectedConfig::Preserved, + expected_settings: Some(no_bash_perms()), + assert_no_settings_created: false, +})] +// --- No Bash perms, hook exists (cases 11-13) --- +#[case::p11_no_bash_hook_exists_user(Case { + settings: Some(settings_no_bash_with_hook()), scope: InitScope::User, existing_config: false, + responses: vec![], + expected_config: ExpectedConfig::Content(BOILERPLATE), + expected_settings: Some(no_bash_perms_with_hook()), + assert_no_settings_created: false, +})] +#[case::p12_no_bash_hook_exists_user_existing_overwrite_yes(Case { + settings: Some(settings_no_bash_with_hook()), scope: InitScope::User, existing_config: true, + responses: vec![yes()], + expected_config: ExpectedConfig::Content(BOILERPLATE), + expected_settings: Some(no_bash_perms_with_hook()), + assert_no_settings_created: false, +})] +#[case::p13_no_bash_hook_exists_user_existing_overwrite_no(Case { + settings: Some(settings_no_bash_with_hook()), scope: InitScope::User, existing_config: true, + responses: vec![no()], + expected_config: ExpectedConfig::Preserved, + expected_settings: Some(no_bash_perms_with_hook()), + assert_no_settings_created: false, +})] +// --- No Bash perms, no hook, project (cases 14-16) --- +#[case::p14_no_bash_no_hook_project(Case { + settings: Some(SETTINGS_NO_BASH_NO_HOOK), scope: InitScope::Project, existing_config: false, + responses: vec![], + expected_config: ExpectedConfig::Content(BOILERPLATE), + expected_settings: Some(no_bash_perms()), + assert_no_settings_created: false, +})] +#[case::p15_no_bash_no_hook_project_existing_overwrite_yes(Case { + settings: Some(SETTINGS_NO_BASH_NO_HOOK), scope: InitScope::Project, existing_config: true, + responses: vec![yes()], + expected_config: ExpectedConfig::Content(BOILERPLATE), + expected_settings: Some(no_bash_perms()), + assert_no_settings_created: false, +})] +#[case::p16_no_bash_no_hook_project_existing_overwrite_no(Case { + settings: Some(SETTINGS_NO_BASH_NO_HOOK), scope: InitScope::Project, existing_config: true, + responses: vec![no()], + expected_config: ExpectedConfig::Preserved, + expected_settings: Some(no_bash_perms()), + assert_no_settings_created: false, +})] +// --- No Bash perms, hook exists, project (cases 17-19) --- +#[case::p17_no_bash_hook_exists_project(Case { + settings: Some(settings_no_bash_with_hook()), scope: InitScope::Project, existing_config: false, + responses: vec![], + expected_config: ExpectedConfig::Content(BOILERPLATE), + expected_settings: Some(no_bash_perms_with_hook()), + assert_no_settings_created: false, +})] +#[case::p18_no_bash_hook_exists_project_existing_overwrite_yes(Case { + settings: Some(settings_no_bash_with_hook()), scope: InitScope::Project, existing_config: true, + responses: vec![yes()], + expected_config: ExpectedConfig::Content(BOILERPLATE), + expected_settings: Some(no_bash_perms_with_hook()), + assert_no_settings_created: false, +})] +#[case::p19_no_bash_hook_exists_project_existing_overwrite_no(Case { + settings: Some(settings_no_bash_with_hook()), scope: InitScope::Project, existing_config: true, + responses: vec![no()], + expected_config: ExpectedConfig::Preserved, + expected_settings: Some(no_bash_perms_with_hook()), + assert_no_settings_created: false, +})] +// --- Bash perms, no hook, user (cases 20-27) --- +#[case::p20_bash_no_hook_user_mig_yes_app_yes(Case { + settings: Some(SETTINGS_BASH_ONLY), scope: InitScope::User, existing_config: false, + responses: vec![yes(), yes()], + expected_config: ExpectedConfig::ContentOwned(config_with_bash_rules()), + expected_settings: Some(perms_removed_with_hook()), + assert_no_settings_created: false, +})] +#[case::p21_bash_no_hook_user_existing_mig_yes_app_yes(Case { + settings: Some(SETTINGS_BASH_ONLY), scope: InitScope::User, existing_config: true, + responses: vec![yes(), yes()], + expected_config: ExpectedConfig::ContentOwned(config_with_bash_rules()), + expected_settings: Some(perms_removed_with_hook()), + assert_no_settings_created: false, +})] +#[case::p22_bash_no_hook_user_mig_yes_app_no(Case { + settings: Some(SETTINGS_BASH_ONLY), scope: InitScope::User, existing_config: false, + responses: vec![yes(), no()], + expected_config: ExpectedConfig::None, + expected_settings: Some(bash_perms_unchanged()), + assert_no_settings_created: false, +})] +#[case::p23_bash_no_hook_user_existing_mig_yes_app_no(Case { + settings: Some(SETTINGS_BASH_ONLY), scope: InitScope::User, existing_config: true, + responses: vec![yes(), no()], + expected_config: ExpectedConfig::Preserved, + expected_settings: Some(bash_perms_unchanged()), + assert_no_settings_created: false, +})] +#[case::p24_bash_no_hook_user_mig_no_app_yes(Case { + settings: Some(SETTINGS_BASH_ONLY), scope: InitScope::User, existing_config: false, + responses: vec![no(), yes()], + expected_config: ExpectedConfig::Content(BOILERPLATE), + expected_settings: Some(bash_perms_with_hook()), + assert_no_settings_created: false, +})] +#[case::p25_bash_no_hook_user_existing_mig_no_app_yes(Case { + settings: Some(SETTINGS_BASH_ONLY), scope: InitScope::User, existing_config: true, + responses: vec![no(), yes()], + expected_config: ExpectedConfig::Content(BOILERPLATE), + expected_settings: Some(bash_perms_with_hook()), + assert_no_settings_created: false, +})] +#[case::p26_bash_no_hook_user_mig_no_app_no(Case { + settings: Some(SETTINGS_BASH_ONLY), scope: InitScope::User, existing_config: false, + responses: vec![no(), no()], + expected_config: ExpectedConfig::None, + expected_settings: Some(bash_perms_unchanged()), + assert_no_settings_created: false, +})] +#[case::p27_bash_no_hook_user_existing_mig_no_app_no(Case { + settings: Some(SETTINGS_BASH_ONLY), scope: InitScope::User, existing_config: true, + responses: vec![no(), no()], + expected_config: ExpectedConfig::Preserved, + expected_settings: Some(bash_perms_unchanged()), + assert_no_settings_created: false, +})] +// --- Bash perms, hook exists, user (cases 28-35) --- +#[case::p28_bash_hook_user_mig_yes_app_yes(Case { + settings: Some(settings_bash_only_with_hook()), scope: InitScope::User, existing_config: false, + responses: vec![yes(), yes()], + expected_config: ExpectedConfig::ContentOwned(config_with_bash_rules()), + expected_settings: Some(perms_removed_with_hook()), + assert_no_settings_created: false, +})] +#[case::p29_bash_hook_user_existing_mig_yes_app_yes(Case { + settings: Some(settings_bash_only_with_hook()), scope: InitScope::User, existing_config: true, + responses: vec![yes(), yes()], + expected_config: ExpectedConfig::ContentOwned(config_with_bash_rules()), + expected_settings: Some(perms_removed_with_hook()), + assert_no_settings_created: false, +})] +#[case::p30_bash_hook_user_mig_yes_app_no(Case { + settings: Some(settings_bash_only_with_hook()), scope: InitScope::User, existing_config: false, + responses: vec![yes(), no()], + expected_config: ExpectedConfig::None, + expected_settings: Some(bash_hook_original()), + assert_no_settings_created: false, +})] +#[case::p31_bash_hook_user_existing_mig_yes_app_no(Case { + settings: Some(settings_bash_only_with_hook()), scope: InitScope::User, existing_config: true, + responses: vec![yes(), no()], + expected_config: ExpectedConfig::Preserved, + expected_settings: Some(bash_hook_original()), + assert_no_settings_created: false, +})] +#[case::p32_bash_hook_user_mig_no_app_yes(Case { + settings: Some(settings_bash_only_with_hook()), scope: InitScope::User, existing_config: false, + responses: vec![no(), yes()], + expected_config: ExpectedConfig::Content(BOILERPLATE), + expected_settings: Some(bash_hook_original()), + assert_no_settings_created: false, +})] +#[case::p33_bash_hook_user_existing_mig_no_app_yes(Case { + settings: Some(settings_bash_only_with_hook()), scope: InitScope::User, existing_config: true, + responses: vec![no(), yes()], + expected_config: ExpectedConfig::Content(BOILERPLATE), + expected_settings: Some(bash_hook_original()), + assert_no_settings_created: false, +})] +#[case::p34_bash_hook_user_mig_no_app_no(Case { + settings: Some(settings_bash_only_with_hook()), scope: InitScope::User, existing_config: false, + responses: vec![no(), no()], + expected_config: ExpectedConfig::None, + expected_settings: Some(bash_hook_original()), + assert_no_settings_created: false, +})] +#[case::p35_bash_hook_user_existing_mig_no_app_no(Case { + settings: Some(settings_bash_only_with_hook()), scope: InitScope::User, existing_config: true, + responses: vec![no(), no()], + expected_config: ExpectedConfig::Preserved, + expected_settings: Some(bash_hook_original()), + assert_no_settings_created: false, +})] +// --- Bash perms, no hook, project (cases 36-43) --- +#[case::p36_bash_no_hook_project_mig_yes_app_yes(Case { + settings: Some(SETTINGS_BASH_ONLY), scope: InitScope::Project, existing_config: false, + responses: vec![yes(), yes()], + expected_config: ExpectedConfig::ContentOwned(config_with_bash_rules()), + expected_settings: Some(perms_removed_no_hook()), + assert_no_settings_created: false, +})] +#[case::p37_bash_no_hook_project_existing_mig_yes_app_yes(Case { + settings: Some(SETTINGS_BASH_ONLY), scope: InitScope::Project, existing_config: true, + responses: vec![yes(), yes()], + expected_config: ExpectedConfig::ContentOwned(config_with_bash_rules()), + expected_settings: Some(perms_removed_no_hook()), + assert_no_settings_created: false, +})] +#[case::p38_bash_no_hook_project_mig_yes_app_no(Case { + settings: Some(SETTINGS_BASH_ONLY), scope: InitScope::Project, existing_config: false, + responses: vec![yes(), no()], + expected_config: ExpectedConfig::None, + expected_settings: Some(bash_perms_unchanged()), + assert_no_settings_created: false, +})] +#[case::p39_bash_no_hook_project_existing_mig_yes_app_no(Case { + settings: Some(SETTINGS_BASH_ONLY), scope: InitScope::Project, existing_config: true, + responses: vec![yes(), no()], + expected_config: ExpectedConfig::Preserved, + expected_settings: Some(bash_perms_unchanged()), + assert_no_settings_created: false, +})] +#[case::p40_bash_no_hook_project_mig_no_app_yes(Case { + settings: Some(SETTINGS_BASH_ONLY), scope: InitScope::Project, existing_config: false, + responses: vec![no(), yes()], + expected_config: ExpectedConfig::Content(BOILERPLATE), + expected_settings: Some(bash_perms_unchanged()), + assert_no_settings_created: false, +})] +#[case::p41_bash_no_hook_project_existing_mig_no_app_yes(Case { + settings: Some(SETTINGS_BASH_ONLY), scope: InitScope::Project, existing_config: true, + responses: vec![no(), yes()], + expected_config: ExpectedConfig::Content(BOILERPLATE), + expected_settings: Some(bash_perms_unchanged()), + assert_no_settings_created: false, +})] +#[case::p42_bash_no_hook_project_mig_no_app_no(Case { + settings: Some(SETTINGS_BASH_ONLY), scope: InitScope::Project, existing_config: false, + responses: vec![no(), no()], + expected_config: ExpectedConfig::None, + expected_settings: Some(bash_perms_unchanged()), + assert_no_settings_created: false, +})] +#[case::p43_bash_no_hook_project_existing_mig_no_app_no(Case { + settings: Some(SETTINGS_BASH_ONLY), scope: InitScope::Project, existing_config: true, + responses: vec![no(), no()], + expected_config: ExpectedConfig::Preserved, + expected_settings: Some(bash_perms_unchanged()), + assert_no_settings_created: false, +})] +// --- Bash perms, hook exists, project (cases 44-51) --- +#[case::p44_bash_hook_project_mig_yes_app_yes(Case { + settings: Some(settings_bash_only_with_hook()), scope: InitScope::Project, existing_config: false, + responses: vec![yes(), yes()], + expected_config: ExpectedConfig::ContentOwned(config_with_bash_rules()), + expected_settings: Some(perms_removed_with_hook()), + assert_no_settings_created: false, +})] +#[case::p45_bash_hook_project_existing_mig_yes_app_yes(Case { + settings: Some(settings_bash_only_with_hook()), scope: InitScope::Project, existing_config: true, + responses: vec![yes(), yes()], + expected_config: ExpectedConfig::ContentOwned(config_with_bash_rules()), + expected_settings: Some(perms_removed_with_hook()), + assert_no_settings_created: false, +})] +#[case::p46_bash_hook_project_mig_yes_app_no(Case { + settings: Some(settings_bash_only_with_hook()), scope: InitScope::Project, existing_config: false, + responses: vec![yes(), no()], + expected_config: ExpectedConfig::None, + expected_settings: Some(bash_hook_original()), + assert_no_settings_created: false, +})] +#[case::p47_bash_hook_project_existing_mig_yes_app_no(Case { + settings: Some(settings_bash_only_with_hook()), scope: InitScope::Project, existing_config: true, + responses: vec![yes(), no()], + expected_config: ExpectedConfig::Preserved, + expected_settings: Some(bash_hook_original()), + assert_no_settings_created: false, +})] +#[case::p48_bash_hook_project_mig_no_app_yes(Case { + settings: Some(settings_bash_only_with_hook()), scope: InitScope::Project, existing_config: false, + responses: vec![no(), yes()], + expected_config: ExpectedConfig::Content(BOILERPLATE), + expected_settings: Some(bash_hook_original()), + assert_no_settings_created: false, +})] +#[case::p49_bash_hook_project_existing_mig_no_app_yes(Case { + settings: Some(settings_bash_only_with_hook()), scope: InitScope::Project, existing_config: true, + responses: vec![no(), yes()], + expected_config: ExpectedConfig::Content(BOILERPLATE), + expected_settings: Some(bash_hook_original()), + assert_no_settings_created: false, +})] +#[case::p50_bash_hook_project_mig_no_app_no(Case { + settings: Some(settings_bash_only_with_hook()), scope: InitScope::Project, existing_config: false, + responses: vec![no(), no()], + expected_config: ExpectedConfig::None, + expected_settings: Some(bash_hook_original()), + assert_no_settings_created: false, +})] +#[case::p51_bash_hook_project_existing_mig_no_app_no(Case { + settings: Some(settings_bash_only_with_hook()), scope: InitScope::Project, existing_config: true, + responses: vec![no(), no()], + expected_config: ExpectedConfig::Preserved, + expected_settings: Some(bash_hook_original()), + assert_no_settings_created: false, +})] +fn exhaustive_wizard_test(#[case] case: Case) -> Result<(), Box> { + let env = InitTestEnv::new()?; + + if let Some(settings) = case.settings { + env.setup_claude_settings(&case.scope, settings)?; + } + if case.existing_config { + env.setup_existing_config(&case.scope)?; + } + + let prompter = SequencePrompter::new(case.responses); + env.run(Some(&case.scope), &prompter)?; + + assert_wizard_result( + &env, + &case.scope, + &case.expected_config, + case.expected_settings, + )?; + + if case.assert_no_settings_created { + let claude_dir = env.claude_dir_for_scope(&case.scope); + assert!( + !claude_dir.join("settings.json").exists(), + "settings.json should not have been created" + ); + } + + Ok(()) +} + +// --- scope selection (separate from exhaustive patterns) --- + +#[rstest] +#[case::select_user(0, true, false)] +#[case::select_project(1, false, true)] +fn scope_select_without_explicit_scope( + #[case] selection: usize, + #[case] user_config_exists: bool, + #[case] project_config_exists: bool, +) -> Result<(), Box> { + let env = InitTestEnv::new()?; + + let prompter = SequencePrompter::new(vec![Response::Select(selection)]); + env.run(None, &prompter)?; + + assert_eq!(env.user_config_path().exists(), user_config_exists); + assert_eq!(env.cwd.join("runok.yml").exists(), project_config_exists); + Ok(()) +} diff --git a/tests/integration/main.rs b/tests/integration/main.rs index 583aff7..a838208 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -1,5 +1,6 @@ mod compound_command_evaluation; mod config_to_rule_evaluation; +mod init_wizard; mod optional_notation_and_path_ref; mod when_clause_rules; mod wrapper_recursive_evaluation;