diff --git a/.claude/rules/coding.md b/.claude/rules/coding.md index 066d47d..34874a2 100644 --- a/.claude/rules/coding.md +++ b/.claude/rules/coding.md @@ -184,43 +184,27 @@ List it explicitly and ask before removing. Don't leave corpses. Don't delete wi ## Git -### Conventional Commits -Format: `type(scope): description` - -Types: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`, `style`, `perf`, `ci`, `build` - -Rules: -- Scope is optional but encouraged -- Description: lowercase, imperative mood, no period -- Examples: - - `feat(grid): add rebalance logic` - - `fix: handle empty response from API` - - `refactor(auth): extract token refresh into service` - - `docs: update setup instructions` - ### Branch Naming Format: `type/short-description` Examples: `feat/add-grid-engine`, `fix/empty-response`, `refactor/auth-service` -### Atomic Commits -One logical change per commit. If you need "and" in the message, split it. -Commit early, commit often. Small commits > monolith commits. - ### Clean Commits No stray `console.log`, `TODO` comments, or debug code in commits. Review your diff before committing. +### Commit Messages +Describe **what changed and why**, not the process. Never write "address code review", "fix review comments", or "apply feedback" — these are meaningless in git log. Write what actually changed: `fix: remove duplicate serde_json dev-dependency`, `test: assert --compact conflicts with --json`. +Squash review-fix commits into the original before merging — the PR history is noise, the commit log is the record. + ### Rebase Over Merge Use rebase for feature branches to maintain clean linear history. Reserve merge commits for integrating long-lived branches. -## Merge Requests -When creating MRs (GitLab) or PRs (GitHub): +## Pull Requests - Assign yourself as the author/implementer - Request review from the human maintainer - Enable source branch deletion on merge -- Title follows conventional commit format - Lint, type check, tests: all green - Diff is small and focused - No debug artifacts diff --git a/Cargo.toml b/Cargo.toml index 747658b..1e2e0ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,8 @@ chrono = "0.4" dirs = "6" libc = "0.2" sha1_smol = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" [dev-dependencies] tempfile = "3" diff --git a/src/main.rs b/src/main.rs index d841486..3d6e092 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,8 +32,11 @@ enum Commands { #[arg(long)] service: Option, /// Compact mode: show only binary name instead of full path - #[arg(short, long)] + #[arg(short, long, conflicts_with = "json")] compact: bool, + /// Output as JSON array + #[arg(long)] + json: bool, }, /// Grant a TCC permission (inserts new entry) Grant { @@ -204,10 +207,12 @@ mod tests { client, service, compact, + json, } => { assert_eq!(client.as_deref(), Some("apple")); assert_eq!(service.as_deref(), Some("Camera")); assert!(!compact); + assert!(!json); } _ => panic!("expected List"), } @@ -222,6 +227,21 @@ mod tests { } } + #[test] + fn parse_list_json() { + let cli = parse(&["tcc", "list", "--json"]).unwrap(); + match cli.command { + Commands::List { json, .. } => assert!(json), + _ => panic!("expected List"), + } + } + + #[test] + fn parse_list_compact_and_json_conflict() { + let err = parse(&["tcc", "list", "--compact", "--json"]).unwrap_err(); + assert_eq!(err.kind(), ErrorKind::ArgumentConflict); + } + #[test] fn parse_services() { let cli = parse(&["tcc", "services"]).unwrap(); @@ -401,10 +421,27 @@ fn main() { client, service, compact, + json, } => { let db = make_db(target); match db.list(client.as_deref(), service.as_deref()) { - Ok(entries) => print_entries(&entries, compact), + Ok(entries) => { + if json { + match serde_json::to_string_pretty(&entries) { + Ok(json_str) => println!("{}", json_str), + Err(e) => { + eprintln!( + "{}: failed to serialize entries: {}", + "Error".red().bold(), + e + ); + process::exit(1); + } + } + } else { + print_entries(&entries, compact); + } + } Err(e) => { eprintln!("{}: {}", "Error".red().bold(), e); process::exit(1); diff --git a/src/tcc.rs b/src/tcc.rs index 721feb1..f92accb 100644 --- a/src/tcc.rs +++ b/src/tcc.rs @@ -1,11 +1,13 @@ use chrono::{Local, TimeZone}; use rusqlite::{Connection, OpenFlags}; +use serde::Serialize; use std::collections::HashMap; use std::fmt; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::LazyLock; +/// Mapping of internal TCC service keys (e.g. `kTCCServiceCamera`) to human-readable names. pub static SERVICE_MAP: LazyLock> = LazyLock::new(|| { let mut m = HashMap::new(); m.insert("kTCCServiceAccessibility", "Accessibility"); @@ -63,6 +65,7 @@ const KNOWN_DIGESTS: &[&str] = &[ "f773496775", // Sonoma (alt) ]; +/// Errors returned by TCC database operations. #[derive(Debug)] pub enum TccError { DbOpen { path: PathBuf, source: String }, @@ -109,7 +112,8 @@ impl fmt::Display for TccError { } } -#[derive(Debug)] +/// A single row from the TCC `access` table, enriched with a human-readable service name. +#[derive(Debug, Serialize)] pub struct TccEntry { pub service_raw: String, pub service_display: String, @@ -119,6 +123,7 @@ pub struct TccEntry { pub is_system: bool, } +/// Which TCC database(s) to target for reads and writes. #[derive(Clone, Copy, PartialEq)] pub enum DbTarget { /// Use both DBs for reads, system for writes (default) @@ -127,6 +132,7 @@ pub enum DbTarget { User, } +/// Handle for reading and writing macOS TCC.db databases. pub struct TccDb { user_db_path: PathBuf, system_db_path: PathBuf, @@ -134,6 +140,7 @@ pub struct TccDb { } impl TccDb { + /// Open the user and system TCC databases for the given target mode. pub fn new(target: DbTarget) -> Result { let home = dirs::home_dir().ok_or(TccError::HomeDirNotFound)?; Ok(Self { @@ -239,6 +246,7 @@ impl TccDb { Ok(entries) } + /// List TCC entries, optionally filtered by client and/or service substring. pub fn list( &self, client_filter: Option<&str>, @@ -281,6 +289,7 @@ impl TccDb { Ok(entries) } + /// Resolve a user-supplied service name to the internal `kTCCService*` key. pub fn resolve_service_name(&self, input: &str) -> Result { if SERVICE_MAP.contains_key(input) { return Ok(input.to_string()); @@ -409,6 +418,7 @@ impl TccDb { Ok((conn, warning)) } + /// Insert or replace a TCC entry with `auth_value = 2` (granted). pub fn grant(&self, service: &str, client: &str) -> Result { let service_key = self.resolve_service_name(service)?; self.check_root_for_write(&service_key, "grant", service, client)?; @@ -442,6 +452,7 @@ impl TccDb { )) } + /// Delete a TCC entry for the given service and client. pub fn revoke(&self, service: &str, client: &str) -> Result { let service_key = self.resolve_service_name(service)?; self.check_root_for_write(&service_key, "revoke", service, client)?; diff --git a/tests/integration.rs b/tests/integration.rs index 263680c..9822808 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1,3 +1,4 @@ +use serde_json::Value; use std::process::Command; /// Helper: run the `tccutil-rs` binary with given args, returning (stdout, stderr, success). @@ -129,3 +130,53 @@ fn version_flag_prints_version() { "version output should mention tccutil-rs" ); } + +// ── tccutil-rs list --json ────────────────────────────────────────── + +#[test] +fn list_json_outputs_valid_json_array() { + let (stdout, _stderr, success) = run_tcc(&["--user", "list", "--json"]); + assert!(success, "tccutil-rs --user list --json should exit 0"); + + let parsed: Value = serde_json::from_str(&stdout).expect("output should be valid JSON"); + assert!(parsed.is_array(), "JSON output should be an array"); + + // If there are entries, verify expected fields exist + if let Some(arr) = parsed.as_array() { + for entry in arr { + assert!(entry.get("service_raw").is_some(), "missing service_raw"); + assert!( + entry.get("service_display").is_some(), + "missing service_display" + ); + assert!(entry.get("client").is_some(), "missing client"); + assert!(entry.get("auth_value").is_some(), "missing auth_value"); + assert!( + entry.get("last_modified").is_some(), + "missing last_modified" + ); + assert!(entry.get("is_system").is_some(), "missing is_system"); + } + } +} + +#[test] +fn list_json_with_client_filter_only_contains_matching_entries() { + let (stdout, _stderr, success) = run_tcc(&["--user", "list", "--json", "--client", "apple"]); + assert!( + success, + "tccutil-rs --user list --json --client apple should exit 0" + ); + + let parsed: Value = serde_json::from_str(&stdout).expect("output should be valid JSON"); + let arr = parsed.as_array().expect("should be an array"); + + for entry in arr { + let client = entry["client"].as_str().expect("client should be a string"); + assert!( + client.to_lowercase().contains("apple"), + "filtered entry should contain 'apple', got: {}", + client + ); + } +}