diff --git a/Cargo.lock b/Cargo.lock index 01040a2..c8b640b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -99,6 +99,7 @@ dependencies = [ "miette", "predicates", "serde", + "serde_json", "serde_yaml", "sha2", "shellexpand", @@ -913,6 +914,19 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" @@ -1528,3 +1542,9 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zmij" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439" diff --git a/Cargo.toml b/Cargo.toml index 098a5d1..f441bc4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } # Serialization serde = { version = "1", features = ["derive"] } +serde_json = "1" serde_yaml = "0.9" # Date/time for timestamps diff --git a/src/catalog.rs b/src/catalog.rs index 9ffe9e7..f6c654f 100644 --- a/src/catalog.rs +++ b/src/catalog.rs @@ -265,6 +265,16 @@ fn enumerate_entry_assets(entry: &Entry, manifest_dir: &Path) -> Result { + // Claude settings entries use composite sources (handled above for composites) + catalog_entries.push(CatalogEntry { + id: format!("{}:settings", entry.id), + name: "settings.json (composed)".to_string(), + kind: AssetKind::ClaudeSettings, + destination: format!("./{}", base_dest.display()), + short_description: None, + }); + } } Ok(catalog_entries) diff --git a/src/claude_settings.rs b/src/claude_settings.rs new file mode 100644 index 0000000..39216c2 --- /dev/null +++ b/src/claude_settings.rs @@ -0,0 +1,395 @@ +//! Claude Code settings composition module. +//! +//! Merges multiple permission YAML fragments into a single +//! Claude Code settings.json file. Each source provides a YAML +//! file with `allow` and/or `deny` permission lists. +//! +//! Merge strategy: +//! - Union all `allow` entries from all fragments +//! - Union all `deny` entries from all fragments +//! - Remove any entries from `allow` that also appear in `deny` +//! - Sort all lists alphabetically for determinism +//! - Deduplicate + +use crate::error::{ApsError, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; +use std::path::Path; +use tracing::{debug, info}; + +/// A permission fragment from a single source YAML file. +/// +/// Example YAML: +/// ```yaml +/// allow: +/// - "Bash(cat:*)" +/// - "Bash(git checkout:*)" +/// deny: +/// - "Bash(rm -rf:*)" +/// ``` +#[derive(Debug, Serialize, Deserialize, Default, Clone)] +pub struct PermissionFragment { + #[serde(default)] + pub allow: Vec, + #[serde(default)] + pub deny: Vec, +} + +/// Read a permission fragment from a YAML file. +pub fn read_permission_fragment(path: &Path) -> Result { + let content = std::fs::read_to_string(path).map_err(|e| { + ApsError::io( + e, + format!("Failed to read permission fragment: {:?}", path), + ) + })?; + + let fragment: PermissionFragment = + serde_yaml::from_str(&content).map_err(|e| ApsError::ClaudeSettingsError { + message: format!("Failed to parse permission fragment {:?}: {}", path, e), + })?; + + debug!( + "Read permission fragment from {:?}: {} allow, {} deny", + path, + fragment.allow.len(), + fragment.deny.len() + ); + + Ok(fragment) +} + +/// Composed permissions ready for JSON output. +#[derive(Debug, Serialize)] +pub struct ComposedPermissions { + pub allow: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub deny: Vec, +} + +/// Claude Code settings.json output structure. +#[derive(Debug, Serialize)] +pub struct ClaudeSettingsOutput { + pub permissions: ComposedPermissions, +} + +/// Compose multiple permission fragments into a single settings JSON string. +/// +/// Merge strategy: +/// 1. Union all `allow` entries from all fragments +/// 2. Union all `deny` entries from all fragments +/// 3. Remove any entries from `allow` that also appear in `deny` +/// 4. Sort all lists alphabetically (BTreeSet handles this) +/// 5. Deduplicate (BTreeSet handles this) +pub fn compose_permissions(fragments: &[PermissionFragment]) -> Result { + if fragments.is_empty() { + return Err(ApsError::ClaudeSettingsError { + message: "No permission fragments provided for composition".to_string(), + }); + } + + info!("Composing {} permission fragment(s)", fragments.len()); + + let mut all_allow: BTreeSet = BTreeSet::new(); + let mut all_deny: BTreeSet = BTreeSet::new(); + + for fragment in fragments { + all_allow.extend(fragment.allow.iter().cloned()); + all_deny.extend(fragment.deny.iter().cloned()); + } + + // Remove denied entries from allow list + for denied in &all_deny { + all_allow.remove(denied); + } + + let output = ClaudeSettingsOutput { + permissions: ComposedPermissions { + allow: all_allow.into_iter().collect(), + deny: all_deny.into_iter().collect(), + }, + }; + + let json = serde_json::to_string_pretty(&output).map_err(|e| { + ApsError::ClaudeSettingsError { + message: format!("Failed to serialize settings to JSON: {}", e), + } + })?; + + debug!("Composed settings JSON: {} bytes", json.len()); + + Ok(json) +} + +/// Write the composed settings JSON to a destination file. +pub fn write_settings_file(content: &str, dest: &Path) -> Result<()> { + // Ensure parent directory exists + if let Some(parent) = dest.parent() { + if !parent.exists() { + std::fs::create_dir_all(parent).map_err(|e| { + ApsError::io(e, format!("Failed to create directory: {:?}", parent)) + })?; + } + } + + // Write with trailing newline + let content_with_newline = if content.ends_with('\n') { + content.to_string() + } else { + format!("{}\n", content) + }; + + std::fs::write(dest, content_with_newline) + .map_err(|e| ApsError::io(e, format!("Failed to write settings file: {:?}", dest)))?; + + info!("Wrote settings file to {:?}", dest); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn test_compose_single_fragment_allow_only() { + let fragments = vec![PermissionFragment { + allow: vec![ + "Bash(cat:*)".to_string(), + "Bash(ls:*)".to_string(), + "WebSearch".to_string(), + ], + deny: vec![], + }]; + + let result = compose_permissions(&fragments).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); + + let allow = parsed["permissions"]["allow"].as_array().unwrap(); + assert_eq!(allow.len(), 3); + // BTreeSet sorts alphabetically + assert_eq!(allow[0], "Bash(cat:*)"); + assert_eq!(allow[1], "Bash(ls:*)"); + assert_eq!(allow[2], "WebSearch"); + + // No deny section when empty + assert!(parsed["permissions"]["deny"].is_null()); + } + + #[test] + fn test_compose_multiple_fragments_union() { + let fragments = vec![ + PermissionFragment { + allow: vec!["Bash(cat:*)".to_string(), "Bash(ls:*)".to_string()], + deny: vec![], + }, + PermissionFragment { + allow: vec![ + "Bash(ls:*)".to_string(), // duplicate + "WebSearch".to_string(), + ], + deny: vec![], + }, + ]; + + let result = compose_permissions(&fragments).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); + + let allow = parsed["permissions"]["allow"].as_array().unwrap(); + assert_eq!(allow.len(), 3); // deduped + assert_eq!(allow[0], "Bash(cat:*)"); + assert_eq!(allow[1], "Bash(ls:*)"); + assert_eq!(allow[2], "WebSearch"); + } + + #[test] + fn test_compose_deny_removes_from_allow() { + let fragments = vec![ + PermissionFragment { + allow: vec![ + "Bash(cat:*)".to_string(), + "Bash(curl:*)".to_string(), + "Bash(ls:*)".to_string(), + ], + deny: vec![], + }, + PermissionFragment { + allow: vec![], + deny: vec!["Bash(curl:*)".to_string()], + }, + ]; + + let result = compose_permissions(&fragments).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); + + let allow = parsed["permissions"]["allow"].as_array().unwrap(); + assert_eq!(allow.len(), 2); + assert_eq!(allow[0], "Bash(cat:*)"); + assert_eq!(allow[1], "Bash(ls:*)"); + + let deny = parsed["permissions"]["deny"].as_array().unwrap(); + assert_eq!(deny.len(), 1); + assert_eq!(deny[0], "Bash(curl:*)"); + } + + #[test] + fn test_compose_deny_from_multiple_fragments() { + let fragments = vec![ + PermissionFragment { + allow: vec![ + "Bash(cat:*)".to_string(), + "Bash(curl:*)".to_string(), + "Bash(rm -rf:*)".to_string(), + ], + deny: vec![], + }, + PermissionFragment { + allow: vec![], + deny: vec![ + "Bash(curl:*)".to_string(), + "Bash(rm -rf:*)".to_string(), + ], + }, + ]; + + let result = compose_permissions(&fragments).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); + + let allow = parsed["permissions"]["allow"].as_array().unwrap(); + assert_eq!(allow.len(), 1); + assert_eq!(allow[0], "Bash(cat:*)"); + + let deny = parsed["permissions"]["deny"].as_array().unwrap(); + assert_eq!(deny.len(), 2); + } + + #[test] + fn test_compose_empty_fragments_error() { + let fragments: Vec = vec![]; + let result = compose_permissions(&fragments); + assert!(result.is_err()); + } + + #[test] + fn test_compose_sorted_output() { + let fragments = vec![PermissionFragment { + allow: vec![ + "WebSearch".to_string(), + "Bash(cat:*)".to_string(), + "Bash(ls:*)".to_string(), + "Bash(git checkout:*)".to_string(), + ], + deny: vec![], + }]; + + let result = compose_permissions(&fragments).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); + + let allow = parsed["permissions"]["allow"].as_array().unwrap(); + // Should be sorted alphabetically + assert_eq!(allow[0], "Bash(cat:*)"); + assert_eq!(allow[1], "Bash(git checkout:*)"); + assert_eq!(allow[2], "Bash(ls:*)"); + assert_eq!(allow[3], "WebSearch"); + } + + #[test] + fn test_read_permission_fragment_yaml() { + let dir = tempdir().unwrap(); + let path = dir.path().join("permissions.yaml"); + std::fs::write( + &path, + r#"allow: + - "Bash(cat:*)" + - "Bash(ls:*)" + - "WebSearch" +deny: + - "Bash(rm -rf:*)" +"#, + ) + .unwrap(); + + let fragment = read_permission_fragment(&path).unwrap(); + assert_eq!(fragment.allow.len(), 3); + assert_eq!(fragment.deny.len(), 1); + assert_eq!(fragment.allow[0], "Bash(cat:*)"); + assert_eq!(fragment.deny[0], "Bash(rm -rf:*)"); + } + + #[test] + fn test_read_permission_fragment_allow_only() { + let dir = tempdir().unwrap(); + let path = dir.path().join("permissions.yaml"); + std::fs::write( + &path, + r#"allow: + - "Bash(cat:*)" + - "WebSearch" +"#, + ) + .unwrap(); + + let fragment = read_permission_fragment(&path).unwrap(); + assert_eq!(fragment.allow.len(), 2); + assert!(fragment.deny.is_empty()); + } + + #[test] + fn test_write_settings_file() { + let dir = tempdir().unwrap(); + let dest = dir.path().join(".claude").join("settings.json"); + + let content = r#"{"permissions":{"allow":["WebSearch"]}}"#; + write_settings_file(content, &dest).unwrap(); + + let written = std::fs::read_to_string(&dest).unwrap(); + assert!(written.contains("WebSearch")); + assert!(written.ends_with('\n')); + } + + #[test] + fn test_write_settings_creates_parent_dirs() { + let dir = tempdir().unwrap(); + let dest = dir.path().join("deep").join("nested").join("settings.json"); + + let content = r#"{"test": true}"#; + write_settings_file(content, &dest).unwrap(); + + assert!(dest.exists()); + } + + #[test] + fn test_compose_produces_valid_json() { + let fragments = vec![ + PermissionFragment { + allow: vec![ + "Bash(cat:*)".to_string(), + "Bash(git checkout:*)".to_string(), + "Bash(git fetch:*)".to_string(), + "WebSearch".to_string(), + "WebFetch(domain:github.com)".to_string(), + ], + deny: vec![], + }, + PermissionFragment { + allow: vec![ + "Bash(ls:*)".to_string(), + "Bash(find:*)".to_string(), + "mcp__context7__query-docs".to_string(), + ], + deny: vec![], + }, + ]; + + let result = compose_permissions(&fragments).unwrap(); + + // Should be valid JSON + let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert!(parsed["permissions"]["allow"].is_array()); + + // Should have the union of all permissions (8 unique) + let allow = parsed["permissions"]["allow"].as_array().unwrap(); + assert_eq!(allow.len(), 8); + } +} diff --git a/src/commands.rs b/src/commands.rs index fb5655b..a5f910f 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -3,7 +3,10 @@ use crate::cli::{ CatalogGenerateArgs, InitArgs, ManifestFormat, StatusArgs, SyncArgs, ValidateArgs, }; use crate::error::{ApsError, Result}; -use crate::install::{install_composite_entry, install_entry, InstallOptions, InstallResult}; +use crate::install::{ + install_claude_settings_entry, install_composite_entry, install_entry, InstallOptions, + InstallResult, +}; use crate::lockfile::{display_status, Lockfile}; use crate::manifest::{ discover_manifest, manifest_dir, validate_manifest, AssetKind, Manifest, DEFAULT_MANIFEST_NAME, @@ -156,8 +159,10 @@ pub fn cmd_sync(args: SyncArgs) -> Result<()> { // Install selected entries let mut results: Vec = Vec::new(); for entry in &entries_to_install { - // Use composite install for composite entries, regular install otherwise - let result = if entry.is_composite() { + // Route to appropriate install function based on entry kind + let result = if entry.kind == AssetKind::ClaudeSettings { + install_claude_settings_entry(entry, &base_dir, &lockfile, &options)? + } else if entry.is_composite() { install_composite_entry(entry, &base_dir, &lockfile, &options)? } else { install_entry(entry, &base_dir, &lockfile, &options)? diff --git a/src/error.rs b/src/error.rs index 1110d2a..70c9382 100644 --- a/src/error.rs +++ b/src/error.rs @@ -140,6 +140,10 @@ pub enum ApsError { #[error("Failed to compose markdown files: {message}")] #[diagnostic(code(aps::compose::error))] ComposeError { message: String }, + + #[error("Failed to compose Claude settings: {message}")] + #[diagnostic(code(aps::claude_settings::error))] + ClaudeSettingsError { message: String }, } impl ApsError { diff --git a/src/install.rs b/src/install.rs index 3c4b7d0..89bb68b 100644 --- a/src/install.rs +++ b/src/install.rs @@ -1,5 +1,6 @@ use crate::backup::{create_backup, has_conflict}; use crate::checksum::{compute_source_checksum, compute_string_checksum}; +use crate::claude_settings::{compose_permissions, read_permission_fragment, write_settings_file}; use crate::compose::{ compose_markdown, read_source_file, write_composed_file, ComposeOptions, ComposedSource, }; @@ -327,6 +328,7 @@ pub fn install_entry( let should_check_conflict = match entry.kind { AssetKind::AgentsMd => true, // Single file - always check AssetKind::CompositeAgentsMd => true, // Composite file - always check + AssetKind::ClaudeSettings => true, // Settings file - always check AssetKind::CursorRules | AssetKind::CursorSkillsRoot | AssetKind::AgentSkill => { // For directory assets with symlinks, we add files to the directory // without backing up existing content from other sources @@ -478,6 +480,102 @@ pub fn install_composite_entry( }) } +/// Install a claude_settings entry (compose multiple YAML permission fragments into JSON) +pub fn install_claude_settings_entry( + entry: &Entry, + manifest_dir: &Path, + lockfile: &Lockfile, + options: &InstallOptions, +) -> Result { + info!("Processing claude_settings entry: {}", entry.id); + + if entry.sources.is_empty() { + return Err(ApsError::CompositeRequiresSources { + id: entry.id.clone(), + }); + } + + // Resolve all sources and read permission fragments + let mut fragments = Vec::new(); + let mut all_checksums: Vec = Vec::new(); + + for source in &entry.sources { + let adapter = source.to_adapter(); + let resolved = adapter.resolve(manifest_dir)?; + + if !resolved.source_path.exists() { + return Err(ApsError::SourcePathNotFound { + path: resolved.source_path, + }); + } + + // Read the permission fragment + let fragment = read_permission_fragment(&resolved.source_path)?; + fragments.push(fragment); + + // Compute and collect checksum for this source + let source_checksum = compute_source_checksum(&resolved.source_path)?; + all_checksums.push(source_checksum); + } + + // Compose all fragments into a single JSON string + let composed_json = compose_permissions(&fragments)?; + + // Compute checksum of the final composed content + let checksum = compute_string_checksum(&composed_json); + debug!("Composed settings checksum: {}", checksum); + + // Resolve destination path + let dest_path = manifest_dir.join(entry.destination()); + debug!("Destination path: {:?}", dest_path); + + // Check if content is unchanged + if lockfile.checksum_matches(&entry.id, &checksum) && dest_path.exists() { + info!( + "Claude settings entry {} is up to date (checksum match)", + entry.id + ); + return Ok(InstallResult { + id: entry.id.clone(), + installed: false, + skipped_no_change: true, + locked_entry: None, + warnings: Vec::new(), + dest_path: dest_path.clone(), + was_symlink: false, + upgrade_available: None, + }); + } + + // Check for conflicts and handle backup if needed + handle_conflict(&dest_path, manifest_dir, options)?; + + // Write the settings file + if !options.dry_run { + write_settings_file(&composed_json, &dest_path)?; + info!("Wrote Claude settings to {:?}", dest_path); + } else { + println!("[dry-run] Would write Claude settings to {:?}", dest_path); + } + + // Create locked entry with original source paths (preserving shell variables) + let source_paths: Vec = entry.sources.iter().map(|s| s.display_path()).collect(); + + let locked_entry = + LockedEntry::new_composite(source_paths, &dest_path.to_string_lossy(), checksum); + + Ok(InstallResult { + id: entry.id.clone(), + installed: !options.dry_run, + skipped_no_change: false, + locked_entry: Some(locked_entry), + warnings: Vec::new(), + dest_path, + was_symlink: false, + upgrade_available: None, + }) +} + /// Install an asset based on its kind fn install_asset( kind: &AssetKind, @@ -518,6 +616,14 @@ fn install_asset( message: "Composite entries should use install_composite_entry".to_string(), }); } + AssetKind::ClaudeSettings => { + // Claude settings entries are handled by install_claude_settings_entry + // This arm exists for exhaustive matching + return Err(ApsError::ClaudeSettingsError { + message: "Claude settings entries should use install_claude_settings_entry" + .to_string(), + }); + } AssetKind::CursorRules | AssetKind::CursorSkillsRoot | AssetKind::AgentSkill => { if use_symlink { if include.is_empty() { diff --git a/src/main.rs b/src/main.rs index 2fe502b..3c3eaa8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod backup; mod catalog; mod checksum; +mod claude_settings; mod cli; mod commands; mod compose; diff --git a/src/manifest.rs b/src/manifest.rs index 966e1a2..7c3a58d 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -69,7 +69,8 @@ impl Entry { /// Check if this is a composite entry (uses multiple sources) pub fn is_composite(&self) -> bool { - self.kind == AssetKind::CompositeAgentsMd && !self.sources.is_empty() + (self.kind == AssetKind::CompositeAgentsMd || self.kind == AssetKind::ClaudeSettings) + && !self.sources.is_empty() } /// Get the destination path for this entry (with shell variable expansion) @@ -99,6 +100,8 @@ pub enum AssetKind { AgentSkill, /// Composite AGENTS.md - merge multiple markdown files into one CompositeAgentsMd, + /// Claude Code settings.json - compose permissions from multiple YAML fragments + ClaudeSettings, } impl AssetKind { @@ -110,6 +113,7 @@ impl AssetKind { AssetKind::AgentsMd => PathBuf::from("AGENTS.md"), AssetKind::AgentSkill => PathBuf::from(".claude/skills"), AssetKind::CompositeAgentsMd => PathBuf::from("AGENTS.md"), + AssetKind::ClaudeSettings => PathBuf::from(".claude/settings.json"), } } @@ -122,6 +126,7 @@ impl AssetKind { "agents_md" => Ok(AssetKind::AgentsMd), "agent_skill" => Ok(AssetKind::AgentSkill), "composite_agents_md" => Ok(AssetKind::CompositeAgentsMd), + "claude_settings" => Ok(AssetKind::ClaudeSettings), _ => Err(ApsError::InvalidAssetKind { kind: s.to_string(), }), @@ -307,7 +312,9 @@ pub fn validate_manifest(manifest: &Manifest) -> Result<()> { } // Validate source configuration based on kind - if entry.kind == AssetKind::CompositeAgentsMd { + if entry.kind == AssetKind::CompositeAgentsMd + || entry.kind == AssetKind::ClaudeSettings + { // Composite entries require sources array if entry.sources.is_empty() { return Err(ApsError::CompositeRequiresSources { diff --git a/tests/cli.rs b/tests/cli.rs index b7e7cab..1f589c5 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -823,3 +823,398 @@ entries: {} temp.child("aps.manifest.lock") .assert(predicate::path::missing()); } + +// ============================================================================ +// Claude Settings Tests +// ============================================================================ + +#[test] +fn sync_claude_settings_single_source() { + let temp = assert_fs::TempDir::new().unwrap(); + + // Create a permission fragment file + let perms_dir = temp.child("perms"); + perms_dir.create_dir_all().unwrap(); + perms_dir + .child("base.yaml") + .write_str( + r#"allow: + - "Bash(cat:*)" + - "Bash(ls:*)" + - "WebSearch" +"#, + ) + .unwrap(); + + // Create manifest + let manifest = format!( + r#"entries: + - id: claude-perms + kind: claude_settings + sources: + - type: filesystem + root: {} + path: base.yaml + dest: .claude/settings.json +"#, + perms_dir.path().display() + ); + + temp.child("aps.yaml").write_str(&manifest).unwrap(); + + aps().arg("sync").current_dir(&temp).assert().success(); + + // Verify settings.json was created + let settings = temp.child(".claude/settings.json"); + settings.assert(predicate::path::exists()); + + // Verify JSON content + settings.assert(predicate::str::contains("\"permissions\"")); + settings.assert(predicate::str::contains("\"allow\"")); + settings.assert(predicate::str::contains("Bash(cat:*)")); + settings.assert(predicate::str::contains("Bash(ls:*)")); + settings.assert(predicate::str::contains("WebSearch")); +} + +#[test] +fn sync_claude_settings_multiple_sources_compose() { + let temp = assert_fs::TempDir::new().unwrap(); + + // Create two permission fragment files + let perms_dir = temp.child("perms"); + perms_dir.create_dir_all().unwrap(); + + perms_dir + .child("shared.yaml") + .write_str( + r#"allow: + - "Bash(git checkout:*)" + - "Bash(git fetch:*)" + - "WebSearch" +"#, + ) + .unwrap(); + + perms_dir + .child("local.yaml") + .write_str( + r#"allow: + - "Bash(cat:*)" + - "Bash(ls:*)" + - "Bash(find:*)" + - "WebFetch(domain:github.com)" +"#, + ) + .unwrap(); + + // Create manifest with multiple sources + let manifest = format!( + r#"entries: + - id: claude-perms + kind: claude_settings + sources: + - type: filesystem + root: {dir} + path: shared.yaml + - type: filesystem + root: {dir} + path: local.yaml + dest: .claude/settings.json +"#, + dir = perms_dir.path().display() + ); + + temp.child("aps.yaml").write_str(&manifest).unwrap(); + + aps().arg("sync").current_dir(&temp).assert().success(); + + // Verify settings.json was created with merged permissions + let settings = temp.child(".claude/settings.json"); + settings.assert(predicate::path::exists()); + + // Read the content and parse as JSON + let content = std::fs::read_to_string(settings.path()).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&content).unwrap(); + + let allow = parsed["permissions"]["allow"].as_array().unwrap(); + // Should contain union of both files (7 unique entries) + assert_eq!(allow.len(), 7); + + // Should be sorted alphabetically + assert_eq!(allow[0], "Bash(cat:*)"); + assert_eq!(allow[1], "Bash(find:*)"); + assert_eq!(allow[2], "Bash(git checkout:*)"); + assert_eq!(allow[3], "Bash(git fetch:*)"); + assert_eq!(allow[4], "Bash(ls:*)"); + assert_eq!(allow[5], "WebFetch(domain:github.com)"); + assert_eq!(allow[6], "WebSearch"); +} + +#[test] +fn sync_claude_settings_deny_removes_from_allow() { + let temp = assert_fs::TempDir::new().unwrap(); + + let perms_dir = temp.child("perms"); + perms_dir.create_dir_all().unwrap(); + + // Allow fragment + perms_dir + .child("allow.yaml") + .write_str( + r#"allow: + - "Bash(cat:*)" + - "Bash(curl:*)" + - "Bash(ls:*)" + - "WebSearch" +"#, + ) + .unwrap(); + + // Deny fragment + perms_dir + .child("deny.yaml") + .write_str( + r#"deny: + - "Bash(curl:*)" +"#, + ) + .unwrap(); + + let manifest = format!( + r#"entries: + - id: claude-perms + kind: claude_settings + sources: + - type: filesystem + root: {dir} + path: allow.yaml + - type: filesystem + root: {dir} + path: deny.yaml + dest: .claude/settings.json +"#, + dir = perms_dir.path().display() + ); + + temp.child("aps.yaml").write_str(&manifest).unwrap(); + + aps().arg("sync").current_dir(&temp).assert().success(); + + // Read and parse JSON + let content = + std::fs::read_to_string(temp.child(".claude/settings.json").path()).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&content).unwrap(); + + let allow = parsed["permissions"]["allow"].as_array().unwrap(); + // curl should be removed from allow (it's in deny) + assert_eq!(allow.len(), 3); + assert!(!allow.iter().any(|v| v == "Bash(curl:*)")); + + // deny list should contain curl + let deny = parsed["permissions"]["deny"].as_array().unwrap(); + assert_eq!(deny.len(), 1); + assert_eq!(deny[0], "Bash(curl:*)"); +} + +#[test] +fn sync_claude_settings_deduplicates() { + let temp = assert_fs::TempDir::new().unwrap(); + + let perms_dir = temp.child("perms"); + perms_dir.create_dir_all().unwrap(); + + // Both files have overlapping permissions + perms_dir + .child("a.yaml") + .write_str( + r#"allow: + - "Bash(cat:*)" + - "WebSearch" +"#, + ) + .unwrap(); + + perms_dir + .child("b.yaml") + .write_str( + r#"allow: + - "WebSearch" + - "Bash(ls:*)" +"#, + ) + .unwrap(); + + let manifest = format!( + r#"entries: + - id: claude-perms + kind: claude_settings + sources: + - type: filesystem + root: {dir} + path: a.yaml + - type: filesystem + root: {dir} + path: b.yaml + dest: .claude/settings.json +"#, + dir = perms_dir.path().display() + ); + + temp.child("aps.yaml").write_str(&manifest).unwrap(); + + aps().arg("sync").current_dir(&temp).assert().success(); + + let content = + std::fs::read_to_string(temp.child(".claude/settings.json").path()).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&content).unwrap(); + + let allow = parsed["permissions"]["allow"].as_array().unwrap(); + // WebSearch should appear only once (deduped) + assert_eq!(allow.len(), 3); +} + +#[test] +fn sync_claude_settings_idempotent() { + let temp = assert_fs::TempDir::new().unwrap(); + + let perms_dir = temp.child("perms"); + perms_dir.create_dir_all().unwrap(); + + perms_dir + .child("perms.yaml") + .write_str( + r#"allow: + - "Bash(cat:*)" + - "WebSearch" +"#, + ) + .unwrap(); + + let manifest = format!( + r#"entries: + - id: claude-perms + kind: claude_settings + sources: + - type: filesystem + root: {} + path: perms.yaml + dest: .claude/settings.json +"#, + perms_dir.path().display() + ); + + temp.child("aps.yaml").write_str(&manifest).unwrap(); + + // First sync + aps().arg("sync").current_dir(&temp).assert().success(); + + // Second sync should show [current] (no changes) + aps() + .arg("sync") + .current_dir(&temp) + .assert() + .success() + .stdout(predicate::str::contains("[current]")); +} + +#[test] +fn sync_claude_settings_default_destination() { + let temp = assert_fs::TempDir::new().unwrap(); + + let perms_dir = temp.child("perms"); + perms_dir.create_dir_all().unwrap(); + + perms_dir + .child("perms.yaml") + .write_str( + r#"allow: + - "WebSearch" +"#, + ) + .unwrap(); + + // No dest specified - should default to .claude/settings.json + let manifest = format!( + r#"entries: + - id: claude-perms + kind: claude_settings + sources: + - type: filesystem + root: {} + path: perms.yaml +"#, + perms_dir.path().display() + ); + + temp.child("aps.yaml").write_str(&manifest).unwrap(); + + aps().arg("sync").current_dir(&temp).assert().success(); + + // Default destination is .claude/settings.json + temp.child(".claude/settings.json") + .assert(predicate::path::exists()); +} + +#[test] +fn validate_claude_settings_entry() { + let temp = assert_fs::TempDir::new().unwrap(); + + let perms_dir = temp.child("perms"); + perms_dir.create_dir_all().unwrap(); + + perms_dir + .child("perms.yaml") + .write_str( + r#"allow: + - "WebSearch" +"#, + ) + .unwrap(); + + let manifest = format!( + r#"entries: + - id: claude-perms + kind: claude_settings + sources: + - type: filesystem + root: {} + path: perms.yaml + dest: .claude/settings.json +"#, + perms_dir.path().display() + ); + + temp.child("aps.yaml").write_str(&manifest).unwrap(); + + aps() + .arg("validate") + .current_dir(&temp) + .assert() + .success() + .stdout(predicate::str::contains("valid")); +} + +#[test] +fn sync_claude_settings_requires_sources() { + let temp = assert_fs::TempDir::new().unwrap(); + + // claude_settings with source (singular) instead of sources should fail validation + let manifest = r#"entries: + - id: claude-perms + kind: claude_settings + source: + type: filesystem + root: /tmp + path: perms.yaml + dest: .claude/settings.json +"#; + + temp.child("aps.yaml").write_str(manifest).unwrap(); + + aps() + .arg("validate") + .current_dir(&temp) + .assert() + .failure() + .stderr(predicate::str::contains("sources")); +}