diff --git a/Cargo.toml b/Cargo.toml index 747658b..dca32b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,10 @@ version = "0.1.0" edition = "2024" description = "CLI tool for managing macOS TCC permissions" +[lib] +name = "tccutil_rs" +path = "src/lib.rs" + [[bin]] name = "tccutil-rs" path = "src/main.rs" @@ -16,6 +20,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/lib.rs b/src/lib.rs new file mode 100644 index 0000000..8a5798c --- /dev/null +++ b/src/lib.rs @@ -0,0 +1 @@ +pub mod tcc; diff --git a/src/main.rs b/src/main.rs index d841486..fab00c6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,3 @@ -mod tcc; - #[cfg(test)] use clap::CommandFactory; #[cfg(test)] @@ -8,7 +6,9 @@ use clap::{Parser, Subcommand}; use colored::Colorize; use std::process; -use tcc::{DbTarget, SERVICE_MAP, TccDb, TccEntry, auth_value_display, compact_client}; +use tccutil_rs::tcc::{ + DbTarget, SERVICE_MAP, TccDb, TccEntry, TccError, auth_value_display, compact_client, +}; #[derive(Parser, Debug)] #[command(name = "tccutil-rs", about = "Manage macOS TCC permissions", version)] @@ -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,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(); @@ -359,6 +373,12 @@ mod tests { assert_eq!(err.kind(), ErrorKind::MissingRequiredArgument); } + #[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 cli_has_version() { let cmd = Cli::command(); @@ -367,7 +387,7 @@ mod tests { } /// Run a TCC command and handle the result uniformly -fn run_command(result: Result) { +fn run_command(result: Result) { match result { Ok(msg) => println!("{}", msg.green()), Err(e) => { @@ -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..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..2bd108c 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1,4 +1,6 @@ +use serde_json::Value; use std::process::Command; +use tccutil_rs::tcc::TccEntry; /// Helper: run the `tccutil-rs` binary with given args, returning (stdout, stderr, success). fn run_tcc(args: &[&str]) -> (String, String, bool) { @@ -129,3 +131,159 @@ fn version_flag_prints_version() { "version output should mention tccutil-rs" ); } + +// ── tccutil-rs list --json ────────────────────────────────────────── + +const EXPECTED_JSON_FIELDS: &[&str] = &[ + "service_raw", + "service_display", + "client", + "auth_value", + "last_modified", + "is_system", +]; + +#[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"); + + // Always assert: output is valid JSON and is an array + let parsed: Value = serde_json::from_str(&stdout).expect("output should be valid JSON"); + let arr = parsed.as_array().expect("JSON output should be an array"); + + // If entries exist, verify each has the expected fields + for (i, entry) in arr.iter().enumerate() { + assert!( + entry.is_object(), + "entry at index {} should be an object", + i + ); + for field in EXPECTED_JSON_FIELDS { + assert!( + entry.get(field).is_some(), + "entry at index {} missing field '{}'", + i, + field + ); + } + } + + // Unconditional field check: serialize an actual TccEntry and verify + // that EXPECTED_JSON_FIELDS matches the real struct fields. If TccEntry + // gains, loses, or renames a field, this will catch the mismatch. + let entry = TccEntry { + service_raw: String::new(), + service_display: String::new(), + client: String::new(), + auth_value: 0, + last_modified: String::new(), + is_system: false, + }; + let serialized = serde_json::to_value(&entry).expect("TccEntry should serialize"); + let obj = serialized + .as_object() + .expect("serialized TccEntry should be an object"); + for field in EXPECTED_JSON_FIELDS { + assert!( + obj.contains_key(*field), + "TccEntry serialization missing expected field '{}'", + field + ); + } + assert_eq!( + obj.len(), + EXPECTED_JSON_FIELDS.len(), + "TccEntry has {} fields but EXPECTED_JSON_FIELDS lists {} (add/remove entries to keep in sync)", + obj.len(), + EXPECTED_JSON_FIELDS.len() + ); +} + +#[test] +fn list_json_service_filter_returns_valid_structure() { + // Use a service that almost certainly exists (Accessibility is one of the oldest TCC services). + // Even if zero rows match, the output must still be a valid JSON array. + let (stdout, _stderr, success) = + run_tcc(&["--user", "list", "--json", "--service", "Accessibility"]); + assert!(success); + + let parsed: Value = serde_json::from_str(&stdout).expect("output should be valid JSON"); + let arr = parsed.as_array().expect("JSON output should be an array"); + + for (i, entry) in arr.iter().enumerate() { + assert!(entry.is_object(), "entry {} should be an object", i); + assert!( + entry.get("service_raw").is_some(), + "entry {} missing service_raw", + i + ); + } +} + +#[test] +fn list_compact_and_json_conflict() { + let (_stdout, stderr, success) = run_tcc(&["--user", "list", "--compact", "--json"]); + assert!(!success, "passing both --compact and --json should fail"); + assert!( + stderr.contains("cannot be used with"), + "clap should report argument conflict, got: {}", + stderr + ); +} + +#[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" + ); + + // Unconditional: output is valid JSON array regardless of DB contents + let parsed: Value = serde_json::from_str(&stdout).expect("output should be valid JSON"); + let arr = parsed.as_array().expect("should be an array"); + + // Every returned entry (if any) must be an object with a "client" field + // containing the filter string. This verifies filter correctness structurally, + // even if the result set is empty (no assertions are skipped). + for (i, entry) in arr.iter().enumerate() { + assert!( + entry.is_object(), + "entry at index {} should be an object", + i + ); + let client = entry + .get("client") + .and_then(|v| v.as_str()) + .unwrap_or_else(|| panic!("entry at index {} missing 'client' string field", i)); + assert!( + client.to_lowercase().contains("apple"), + "filtered entry at index {} should contain 'apple', got: {}", + i, + client + ); + } + + // Unconditional: verify filtering with a guaranteed-no-match client + // produces a valid empty JSON array (exercises the filter code path + // even when the DB is empty) + let (stdout2, _stderr2, success2) = run_tcc(&[ + "--user", + "list", + "--json", + "--client", + "zzz_nonexistent_client_zzz", + ]); + assert!(success2, "filter with no-match client should still exit 0"); + let parsed2: Value = + serde_json::from_str(&stdout2).expect("no-match output should be valid JSON"); + let arr2 = parsed2 + .as_array() + .expect("no-match output should be an array"); + assert!( + arr2.is_empty(), + "filtering by nonexistent client should return empty array, got {} entries", + arr2.len() + ); +}