use std::path::Path;
use crate::error::PawError;
pub const DEV_ALLOWLIST_PRESET: &[&str] = &[
"git status",
"git log",
"git diff",
"git show",
"git fetch",
"git commit",
"git push",
"git pull",
"git merge",
"git stash",
"git add",
"git restore",
"git rm",
"find",
"grep",
"sed -n",
];
pub const RUST_STACK_PRESET: &[&str] = &[
"cargo build",
"cargo test",
"cargo clippy",
"cargo fmt",
"cargo check",
"cargo tree",
"cargo deny",
"cargo update",
];
pub const NODE_STACK_PRESET: &[&str] = &[
"npm install",
"npm ci",
"npm test",
"npm run",
"pnpm install",
"pnpm test",
"pnpm run",
"yarn install",
"yarn test",
];
pub const PYTHON_STACK_PRESET: &[&str] = &[
"pytest",
"pip install",
"ruff",
"black",
"mypy",
"flake8",
"uv pip",
"uv sync",
];
pub const GO_STACK_PRESET: &[&str] = &[
"go build",
"go test",
"go vet",
"go fmt",
"gofmt",
"go mod",
"golangci-lint",
];
#[must_use]
pub fn stack_preset(name: &str) -> Option<&'static [&'static str]> {
match name {
"rust" => Some(RUST_STACK_PRESET),
"node" => Some(NODE_STACK_PRESET),
"python" => Some(PYTHON_STACK_PRESET),
"go" => Some(GO_STACK_PRESET),
_ => None,
}
}
#[must_use]
pub fn effective_patterns(stacks: &[String], extra: &[String]) -> Vec<String> {
let mut out: Vec<String> = DEV_ALLOWLIST_PRESET
.iter()
.map(|s| (*s).to_string())
.collect();
let push_unique = |out: &mut Vec<String>, pat: &str| {
if !out.iter().any(|existing| existing == pat) {
out.push(pat.to_string());
}
};
for stack in stacks {
if let Some(preset) = stack_preset(stack) {
for pat in preset {
push_unique(&mut out, pat);
}
}
}
for entry in extra {
push_unique(&mut out, entry);
}
out
}
pub fn setup_dev_allowlist(
stacks: &[String],
extra: &[String],
settings_path: &Path,
) -> Result<(), PawError> {
let new_entries = effective_patterns(stacks, extra);
let mut value: serde_json::Value = if settings_path.exists() {
let raw = std::fs::read_to_string(settings_path).map_err(|e| {
PawError::ConfigError(format!("failed to read {}: {e}", settings_path.display()))
})?;
if raw.trim().is_empty() {
serde_json::Value::Object(serde_json::Map::new())
} else {
serde_json::from_str(&raw).map_err(|e| {
PawError::ConfigError(format!("{}: invalid JSON: {e}", settings_path.display()))
})?
}
} else {
serde_json::Value::Object(serde_json::Map::new())
};
let obj = value.as_object_mut().ok_or_else(|| {
PawError::ConfigError(format!(
"{}: top-level value must be a JSON object",
settings_path.display()
))
})?;
let entry = obj
.entry("allowed_bash_prefixes".to_string())
.or_insert_with(|| serde_json::Value::Array(Vec::new()));
let array = entry.as_array_mut().ok_or_else(|| {
PawError::ConfigError(format!(
"{}: allowed_bash_prefixes must be an array",
settings_path.display()
))
})?;
for new_entry in new_entries {
let already_present = array
.iter()
.any(|v| v.as_str().is_some_and(|s| s == new_entry));
if !already_present {
array.push(serde_json::Value::String(new_entry));
}
}
if let Some(parent) = settings_path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent).map_err(|e| {
PawError::ConfigError(format!("failed to create {}: {e}", parent.display()))
})?;
}
let serialized = serde_json::to_string_pretty(&value).map_err(|e| {
PawError::ConfigError(format!(
"failed to serialize {}: {e}",
settings_path.display()
))
})?;
std::fs::write(settings_path, serialized).map_err(|e| {
PawError::ConfigError(format!("failed to write {}: {e}", settings_path.display()))
})?;
Ok(())
}
pub fn seed_supervisor_session(
stacks: &[String],
extra: &[String],
repo_root: &Path,
alt_settings: &[std::path::PathBuf],
) -> Vec<(std::path::PathBuf, PawError)> {
let mut failures = Vec::new();
let repo_settings = repo_root.join(".claude").join("settings.json");
if let Err(e) = setup_dev_allowlist(stacks, extra, &repo_settings) {
failures.push((repo_settings, e));
}
for target in alt_settings {
if target.parent().is_some_and(std::path::Path::is_dir)
&& let Err(e) = setup_dev_allowlist(stacks, extra, target)
{
failures.push((target.clone(), e));
}
}
failures
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn read_array(path: &Path) -> Vec<String> {
let raw = std::fs::read_to_string(path).unwrap();
let v: serde_json::Value = serde_json::from_str(&raw).unwrap();
v.get("allowed_bash_prefixes")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|x| x.as_str().map(String::from))
.collect()
})
.unwrap_or_default()
}
#[test]
fn writes_preset_when_file_absent() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("settings.json");
setup_dev_allowlist(&[], &[], &path).unwrap();
let entries = read_array(&path);
for pat in DEV_ALLOWLIST_PRESET {
assert!(
entries.iter().any(|e| e == pat),
"missing preset pattern {pat:?} in {entries:?}",
);
}
}
#[test]
fn merges_with_existing_user_entries() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("settings.json");
std::fs::write(
&path,
r#"{"some_custom_field":"value","allowed_bash_prefixes":["my-tool","some-other"]}"#,
)
.unwrap();
setup_dev_allowlist(&[], &[], &path).unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
let v: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(
v.get("some_custom_field").and_then(|x| x.as_str()),
Some("value"),
"must preserve unrelated top-level fields",
);
let entries = read_array(&path);
assert!(entries.iter().any(|e| e == "my-tool"));
assert!(entries.iter().any(|e| e == "some-other"));
for pat in DEV_ALLOWLIST_PRESET {
assert!(entries.iter().any(|e| e == pat), "missing {pat}");
}
}
#[test]
fn does_not_duplicate_existing_preset_entries() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("settings.json");
std::fs::write(
&path,
r#"{"allowed_bash_prefixes":["git diff","git push"]}"#,
)
.unwrap();
setup_dev_allowlist(&[], &[], &path).unwrap();
let entries = read_array(&path);
assert_eq!(entries.iter().filter(|e| *e == "git diff").count(), 1);
assert_eq!(entries.iter().filter(|e| *e == "git push").count(), 1);
}
#[test]
fn appends_extra_patterns_after_preset() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("settings.json");
let extra = vec!["pnpm test".to_string(), "deno fmt".to_string()];
setup_dev_allowlist(&[], &extra, &path).unwrap();
let entries = read_array(&path);
assert!(entries.iter().any(|e| e == "pnpm test"));
assert!(entries.iter().any(|e| e == "deno fmt"));
let pnpm_idx = entries.iter().position(|e| e == "pnpm test").unwrap();
let last_preset_idx = entries
.iter()
.rposition(|e| DEV_ALLOWLIST_PRESET.contains(&e.as_str()))
.unwrap();
assert!(
pnpm_idx > last_preset_idx,
"extra entries must follow the preset; entries: {entries:?}",
);
}
#[test]
fn extra_entries_not_validated() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("settings.json");
let extra = vec!["this is nonsense $$".to_string()];
setup_dev_allowlist(&[], &extra, &path).unwrap();
let entries = read_array(&path);
assert!(entries.iter().any(|e| e == "this is nonsense $$"));
}
#[test]
fn extra_duplicates_preset_entry_not_added_twice() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("settings.json");
let extra = vec!["git diff".to_string()];
setup_dev_allowlist(&[], &extra, &path).unwrap();
let entries = read_array(&path);
assert_eq!(
entries.iter().filter(|e| *e == "git diff").count(),
1,
"git diff appears more than once: {entries:?}",
);
}
#[test]
fn invalid_json_returns_error_not_panic() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("settings.json");
std::fs::write(&path, "not json {{{").unwrap();
let err = setup_dev_allowlist(&[], &[], &path).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("invalid JSON"), "got: {msg}");
let raw = std::fs::read_to_string(&path).unwrap();
assert_eq!(raw, "not json {{{");
}
#[test]
fn creates_parent_directory_when_missing() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(".claude").join("settings.json");
assert!(!path.parent().unwrap().exists());
setup_dev_allowlist(&[], &[], &path).unwrap();
assert!(path.exists());
}
#[test]
fn preset_constant_contains_only_universal_patterns() {
let required = [
"git status",
"git log",
"git diff",
"git show",
"git fetch",
"git commit",
"git push",
"git pull",
"git merge",
"git stash",
"git add",
"git restore",
"git rm",
"find",
"grep",
"sed -n",
];
for r in required {
assert!(
DEV_ALLOWLIST_PRESET.contains(&r),
"universal preset missing required pattern: {r}",
);
}
assert_eq!(
DEV_ALLOWLIST_PRESET.len(),
required.len(),
"universal preset must contain exactly the required patterns; got {DEV_ALLOWLIST_PRESET:?}",
);
let stack_specific = [
"cargo build",
"cargo test",
"cargo clippy",
"cargo fmt",
"cargo check",
"just",
"mdbook build",
"openspec validate",
"openspec status",
"npm install",
"pytest",
"go build",
];
for s in stack_specific {
assert!(
!DEV_ALLOWLIST_PRESET.contains(&s),
"universal preset must not contain stack-specific pattern: {s}",
);
}
let excluded = [
"git rebase",
"git reset",
"git checkout",
"git branch -D",
"git push --force",
"git push -f",
"sed",
];
for e in excluded {
assert!(
!DEV_ALLOWLIST_PRESET.contains(&e),
"preset must not contain excluded pattern: {e}",
);
}
}
#[test]
fn curated_stack_presets_obey_the_exclusion_rubric() {
let forbidden = [
"cargo install",
"cargo run",
"cargo bench",
"go run",
"npm publish",
"npm uninstall",
"pip uninstall",
];
for stack in ["rust", "node", "python", "go"] {
let preset = stack_preset(stack).expect("named stack resolves");
for f in forbidden {
assert!(
!preset.contains(&f),
"stack `{stack}` must not contain forbidden verb: {f}",
);
}
}
assert!(stack_preset("haskell").is_none());
}
#[test]
fn rust_stack_preset_carries_curated_cargo_verbs() {
let preset = stack_preset("rust").expect("rust stack resolves");
for pat in ["cargo build", "cargo test", "cargo clippy"] {
assert!(preset.contains(&pat), "rust stack missing {pat}");
}
}
#[test]
fn effective_patterns_orders_preset_before_extra() {
let extra = vec!["pnpm test".to_string()];
let out = effective_patterns(&[], &extra);
let pnpm_idx = out.iter().position(|s| s == "pnpm test").unwrap();
let git_idx = out.iter().position(|s| s == "git diff").unwrap();
assert!(
git_idx < pnpm_idx,
"preset entries must precede extra: git@{git_idx} vs pnpm@{pnpm_idx}",
);
}
#[test]
fn effective_patterns_deduplicates_extra_against_preset() {
let extra = vec!["git diff".to_string()];
let out = effective_patterns(&[], &extra);
assert_eq!(out.iter().filter(|s| *s == "git diff").count(), 1);
}
#[test]
fn effective_patterns_universal_only_when_no_stacks_or_extra() {
let out = effective_patterns(&[], &[]);
let expected: Vec<String> = DEV_ALLOWLIST_PRESET
.iter()
.map(|s| (*s).to_string())
.collect();
assert_eq!(
out, expected,
"no stacks + no extra must yield exactly the universal preset"
);
assert!(!out.iter().any(|s| s == "cargo build"));
}
#[test]
fn effective_patterns_rust_stack_adds_cargo_prefixes() {
let stacks = vec!["rust".to_string()];
let out = effective_patterns(&stacks, &[]);
for pat in RUST_STACK_PRESET {
assert!(out.iter().any(|s| s == pat), "missing rust prefix {pat}");
}
let git_idx = out.iter().position(|s| s == "git diff").unwrap();
let cargo_idx = out.iter().position(|s| s == "cargo build").unwrap();
assert!(git_idx < cargo_idx, "universal must precede stack prefixes");
}
#[test]
fn effective_patterns_node_stack_has_no_cargo() {
let stacks = vec!["node".to_string()];
let out = effective_patterns(&stacks, &[]);
assert!(out.iter().any(|s| s.starts_with("npm")));
assert!(
!out.iter().any(|s| s.starts_with("cargo")),
"node stack must not seed any cargo prefix: {out:?}",
);
}
#[test]
fn effective_patterns_multiple_stacks_compose_as_dedup_union() {
let stacks = vec!["rust".to_string(), "python".to_string()];
let out = effective_patterns(&stacks, &[]);
assert!(out.iter().any(|s| s == "cargo build"));
assert!(out.iter().any(|s| s == "pytest"));
let mut seen = std::collections::HashSet::new();
for s in &out {
assert!(seen.insert(s.clone()), "duplicate pattern in union: {s}");
}
}
#[test]
fn effective_patterns_unknown_stack_contributes_nothing() {
let stacks = vec!["haskell".to_string()];
let out = effective_patterns(&stacks, &[]);
let expected: Vec<String> = DEV_ALLOWLIST_PRESET
.iter()
.map(|s| (*s).to_string())
.collect();
assert_eq!(out, expected, "unknown stack must add nothing");
}
#[test]
fn rejects_top_level_array() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("settings.json");
std::fs::write(&path, "[]").unwrap();
let err = setup_dev_allowlist(&[], &[], &path).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("must be a JSON object"), "got: {msg}");
}
}