From 8186d915f03c7cdf36e463362583bd7baf175801 Mon Sep 17 00:00:00 2001 From: glitch418x Date: Wed, 18 Feb 2026 09:28:17 +0300 Subject: [PATCH 1/5] feat(list): add --json flag for structured JSON output Adds serde/serde_json dependencies and a --json flag to the list subcommand. When passed, entries serialize as a JSON array instead of the human-readable table. Includes integration tests for valid JSON output and filtered JSON output. Co-Authored-By: Claude Opus 4.6 --- Cargo.toml | 3 +++ src/main.rs | 27 ++++++++++++++++++++++- src/tcc.rs | 3 ++- tests/integration.rs | 51 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 747658b..be15e3d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,9 @@ chrono = "0.4" dirs = "6" libc = "0.2" sha1_smol = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" [dev-dependencies] +serde_json = "1" tempfile = "3" diff --git a/src/main.rs b/src/main.rs index d841486..4e11eea 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,6 +34,9 @@ enum Commands { /// Compact mode: show only binary name instead of full path #[arg(short, long)] 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,15 @@ 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_services() { let cli = parse(&["tcc", "services"]).unwrap(); @@ -401,10 +415,21 @@ 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 { + println!( + "{}", + serde_json::to_string_pretty(&entries) + .expect("failed to serialize entries") + ); + } 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..030b8de 100644 --- a/src/tcc.rs +++ b/src/tcc.rs @@ -1,5 +1,6 @@ use chrono::{Local, TimeZone}; use rusqlite::{Connection, OpenFlags}; +use serde::Serialize; use std::collections::HashMap; use std::fmt; use std::path::{Path, PathBuf}; @@ -109,7 +110,7 @@ impl fmt::Display for TccError { } } -#[derive(Debug)] +#[derive(Debug, Serialize)] pub struct TccEntry { pub service_raw: String, pub service_display: String, 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 + ); + } +} From da2627f1950945865b26dac14a94186ca94c0a27 Mon Sep 17 00:00:00 2001 From: glitch418x Date: Wed, 18 Feb 2026 10:01:35 +0300 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20address=20CodeRabbit=20review=20?= =?UTF-8?q?=E2=80=94=20serde=5Fjson=20dedup,=20compact=20conflicts=5Fwith?= =?UTF-8?q?=20json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- Cargo.toml | 1 - src/main.rs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index be15e3d..1e2e0ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,5 +20,4 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" [dev-dependencies] -serde_json = "1" tempfile = "3" diff --git a/src/main.rs b/src/main.rs index 4e11eea..1953f55 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,7 +32,7 @@ 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)] From 4274012d62ab5f02c58faee371406bd87732c0ab Mon Sep 17 00:00:00 2001 From: glitch418x Date: Wed, 18 Feb 2026 10:11:39 +0300 Subject: [PATCH 3/5] test: add unit test for --compact --json conflict Co-Authored-By: Claude Opus 4.6 --- src/main.rs | 6 ++++++ src/tcc.rs | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/main.rs b/src/main.rs index 1953f55..e7c1708 100644 --- a/src/main.rs +++ b/src/main.rs @@ -236,6 +236,12 @@ mod tests { } } + #[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(); diff --git a/src/tcc.rs b/src/tcc.rs index 030b8de..f92accb 100644 --- a/src/tcc.rs +++ b/src/tcc.rs @@ -7,6 +7,7 @@ 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"); @@ -64,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 }, @@ -110,6 +112,7 @@ impl fmt::Display for TccError { } } +/// 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, @@ -120,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) @@ -128,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, @@ -135,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 { @@ -240,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>, @@ -282,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()); @@ -410,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)?; @@ -443,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)?; From 8c6940c3eb63f6438e1ed661e0fe1efb33101843 Mon Sep 17 00:00:00 2001 From: glitch418x Date: Wed, 18 Feb 2026 10:15:44 +0300 Subject: [PATCH 4/5] chore: sync coding rules from claude-plugin --- .claude/rules/coding.md | 26 +++++--------------------- 1 file changed, 5 insertions(+), 21 deletions(-) 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 From 7b71cc02ddcaf1e4b062a5edd1f8bc72e3346aad Mon Sep 17 00:00:00 2001 From: glitch418x Date: Wed, 18 Feb 2026 18:02:59 +0300 Subject: [PATCH 5/5] fix: replace .expect() with graceful error handling for JSON serialization Match the error handling pattern used elsewhere in main(): print to stderr with colored "Error" prefix and exit(1) instead of panicking with a stack trace. Co-Authored-By: Claude Opus 4.6 --- src/main.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/main.rs b/src/main.rs index e7c1708..3d6e092 100644 --- a/src/main.rs +++ b/src/main.rs @@ -427,11 +427,17 @@ fn main() { match db.list(client.as_deref(), service.as_deref()) { Ok(entries) => { if json { - println!( - "{}", - serde_json::to_string_pretty(&entries) - .expect("failed to serialize entries") - ); + 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); }