Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 5 additions & 21 deletions .claude/rules/coding.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
41 changes: 39 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,11 @@ enum Commands {
#[arg(long)]
service: Option<String>,
/// 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 {
Expand Down Expand Up @@ -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"),
}
Expand All @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
13 changes: 12 additions & 1 deletion src/tcc.rs
Original file line number Diff line number Diff line change
@@ -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<HashMap<&'static str, &'static str>> = LazyLock::new(|| {
let mut m = HashMap::new();
m.insert("kTCCServiceAccessibility", "Accessibility");
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -127,13 +132,15 @@ pub enum DbTarget {
User,
}

/// Handle for reading and writing macOS TCC.db databases.
pub struct TccDb {
user_db_path: PathBuf,
system_db_path: PathBuf,
target: DbTarget,
}

impl TccDb {
/// Open the user and system TCC databases for the given target mode.
pub fn new(target: DbTarget) -> Result<Self, TccError> {
let home = dirs::home_dir().ok_or(TccError::HomeDirNotFound)?;
Ok(Self {
Expand Down Expand Up @@ -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>,
Expand Down Expand Up @@ -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<String, TccError> {
if SERVICE_MAP.contains_key(input) {
return Ok(input.to_string());
Expand Down Expand Up @@ -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<String, TccError> {
let service_key = self.resolve_service_name(service)?;
self.check_root_for_write(&service_key, "grant", service, client)?;
Expand Down Expand Up @@ -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<String, TccError> {
let service_key = self.resolve_service_name(service)?;
self.check_root_for_write(&service_key, "revoke", service, client)?;
Expand Down
51 changes: 51 additions & 0 deletions tests/integration.rs
Original file line number Diff line number Diff line change
@@ -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).
Expand Down Expand Up @@ -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
);
}
}