Files
ai-control/src-tauri/src/lib.rs
T
marcus.hinz f8fcd8d033 Verbrauch-Tab: Tokens/Kosten pro Pool und Projekt aus Transcripts
- usage_stats: streamt <pool>/projects/*/*.jsonl, zählt message.usage,
  Dedup über message.id+requestId, Zeitfilter (eigener RFC3339-Parser),
  Projektpfad-Mapping wie claudes Ordnerkodierung
- Preistabelle je Modell (Fable/Opus/Sonnet/Haiku, Cache 1.25x/0.1x),
  Kosten als API-Gegenwert ausgewiesen
- UI-Tab mit 7/30-Tage-Filter: Pool-Zeilen aggregiert, Projekte
  aufklappbar darunter, Summenzeile; i18n de/en
- Tests: Aggregation+Dedup+Zeitfilter, leerer Fall, parse_ts-Referenz
2026-07-04 14:10:53 +02:00

1512 lines
47 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
mod terminal;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::process::Command;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
const CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
const AUTH_URL: &str = "https://claude.ai/oauth/authorize";
const TOKEN_URL: &str = "https://console.anthropic.com/v1/oauth/token";
const SCOPES: &str = "org:create_api_key user:profile user:inference";
/// Feste Dateinamen im Pool-Ordner. Der Pool-Ordner selbst ist das
/// CLAUDE_CONFIG_DIR; claude liest die .credentials.json dort direkt.
const CREDENTIALS_FILE: &str = ".credentials.json";
const APIKEY_FILE: &str = "apikey";
const POOL_FILE: &str = "pool.json";
const PROJECT_FILE: &str = "ai-control.json";
/// Wurzelpfade; in Tests mit temporärem home instanziierbar.
struct Paths {
home: PathBuf,
}
impl Paths {
fn real() -> Self {
Paths {
home: PathBuf::from(std::env::var("HOME").expect("HOME nicht gesetzt")),
}
}
fn projects_dir(&self) -> PathBuf {
self.home.join("claude-projects")
}
fn pools_dir(&self) -> PathBuf {
self.home.join(".config").join("ai-control").join("pools")
}
fn pool_dir(&self, pool: &str) -> PathBuf {
self.pools_dir().join(pool)
}
fn project_config(&self, project: &str) -> PathBuf {
self.projects_dir().join(project).join(PROJECT_FILE)
}
}
#[derive(Serialize)]
struct Project {
name: String,
path: String,
pool: Option<String>,
running: bool,
terminal: TerminalConfig,
}
#[derive(Serialize, Deserialize)]
struct Pool {
name: String,
#[serde(rename = "credentialType")]
credential_type: String,
}
#[derive(Serialize, Deserialize, Default)]
struct ProjectConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pool: Option<String>,
#[serde(default, skip_serializing_if = "TerminalConfig::is_empty")]
terminal: TerminalConfig,
}
#[derive(Serialize, Deserialize, Default, Clone)]
pub struct TerminalConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub theme: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
}
impl TerminalConfig {
fn is_empty(&self) -> bool {
self.theme.is_none() && self.icon.is_none() && self.title.is_none()
}
}
fn bundle_id(project: &str) -> String {
format!("com.mitchellh.ghostty.{project}")
}
fn is_running(project: &str) -> bool {
ghostty_running(project) || !terminal_pids(project).is_empty()
}
fn ghostty_running(project: &str) -> bool {
let out = Command::new("lsappinfo")
.args(["find", &format!("bundleid={}", bundle_id(project))])
.output();
match out {
Ok(o) => !String::from_utf8_lossy(&o.stdout).trim().is_empty(),
Err(_) => false,
}
}
/// PIDs der eingebauten Terminal-Prozesse (`app --terminal <projekt>`).
fn terminal_pids(project: &str) -> Vec<u32> {
let out = Command::new("pgrep")
.args(["-f", "--", &format!("--terminal {project}$")])
.output();
match out {
Ok(o) => String::from_utf8_lossy(&o.stdout)
.lines()
.filter_map(|l| l.trim().parse().ok())
.collect(),
Err(_) => Vec::new(),
}
}
fn check_name(name: &str) -> Result<(), String> {
if name.trim().is_empty() || name.contains('/') || name.contains("..") {
return Err(format!("ungültiger Name: {name}"));
}
Ok(())
}
fn read_pool(paths: &Paths, pool: &str) -> Result<Pool, String> {
let cfg_path = paths.pool_dir(pool).join(POOL_FILE);
let raw = fs::read_to_string(&cfg_path)
.map_err(|e| format!("{}: {e}", cfg_path.display()))?;
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))
}
// ---------- Projekte ----------
fn list_projects_in(paths: &Paths) -> Result<Vec<Project>, String> {
let mut projects = Vec::new();
let entries = fs::read_dir(paths.projects_dir()).map_err(|e| e.to_string())?;
for entry in entries {
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
if !path.join(".claude").is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
let cfg = read_project_config_in(paths, &name)?;
projects.push(Project {
running: is_running(&name),
path: path.to_string_lossy().into_owned(),
pool: cfg.pool,
terminal: cfg.terminal,
name,
});
}
projects.sort_by(|a, b| a.name.cmp(&b.name));
Ok(projects)
}
fn create_project_in(paths: &Paths, name: &str) -> Result<(), String> {
check_name(name)?;
let dir = paths.projects_dir().join(name);
if dir.exists() {
return Err(format!("Projekt existiert bereits: {name}"));
}
fs::create_dir_all(dir.join(".claude")).map_err(|e| e.to_string())
}
fn delete_project_in(paths: &Paths, name: &str) -> Result<(), String> {
check_name(name)?;
let dir = paths.projects_dir().join(name);
if !dir.is_dir() {
return Err(format!("Projekt nicht gefunden: {name}"));
}
fs::remove_dir_all(&dir).map_err(|e| e.to_string())
}
fn read_project_config_in(paths: &Paths, project: &str) -> Result<ProjectConfig, String> {
let cfg_path = paths.project_config(project);
if !cfg_path.is_file() {
return Ok(ProjectConfig::default());
}
let raw =
fs::read_to_string(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()))?;
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))
}
fn write_project_config_in(
paths: &Paths,
project: &str,
cfg: &ProjectConfig,
) -> Result<(), String> {
let raw = serde_json::to_string_pretty(cfg).map_err(|e| e.to_string())?;
fs::write(paths.project_config(project), raw + "\n").map_err(|e| e.to_string())
}
fn assign_pool_in(paths: &Paths, project: &str, pool: &str) -> Result<(), String> {
if !paths.pool_dir(pool).join(POOL_FILE).is_file() {
return Err(format!("Pool existiert nicht: {pool}"));
}
let mut cfg = read_project_config_in(paths, project)?;
cfg.pool = Some(pool.to_string());
write_project_config_in(paths, project, &cfg)
}
/// Nimmt den Pool raus; die Config-Datei bleibt nur, wenn sie noch
/// Terminal-Einstellungen trägt.
fn unassign_pool_in(paths: &Paths, project: &str) -> Result<(), String> {
let mut cfg = read_project_config_in(paths, project)?;
cfg.pool = None;
if cfg.terminal.is_empty() {
let cfg_path = paths.project_config(project);
return fs::remove_file(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()));
}
write_project_config_in(paths, project, &cfg)
}
fn set_terminal_config_in(
paths: &Paths,
project: &str,
terminal: TerminalConfig,
) -> Result<(), String> {
let mut cfg = read_project_config_in(paths, project)?;
cfg.terminal = terminal;
write_project_config_in(paths, project, &cfg)
}
/// Terminal-Einstellungen eines Projekts, für den Terminal-Prozess.
pub(crate) fn terminal_config(project: &str) -> Result<TerminalConfig, String> {
Ok(read_project_config_in(&Paths::real(), project)?.terminal)
}
/// Namen aller Projekte, die `pool` zugeordnet haben.
fn projects_using_pool(paths: &Paths, pool: &str) -> Result<Vec<String>, String> {
let mut users = Vec::new();
let entries = fs::read_dir(paths.projects_dir()).map_err(|e| e.to_string())?;
for entry in entries {
let entry = entry.map_err(|e| e.to_string())?;
let cfg_path = entry.path().join(PROJECT_FILE);
if !cfg_path.is_file() {
continue;
}
let raw = fs::read_to_string(&cfg_path).map_err(|e| e.to_string())?;
let cfg: ProjectConfig = serde_json::from_str(&raw).map_err(|e| e.to_string())?;
if cfg.pool.as_deref() == Some(pool) {
users.push(entry.file_name().to_string_lossy().into_owned());
}
}
users.sort();
Ok(users)
}
// ---------- Pools ----------
#[derive(Serialize)]
struct PoolInfo {
name: String,
#[serde(rename = "credentialType")]
credential_type: String,
projects: Vec<String>,
#[serde(rename = "hasCredentials")]
has_credentials: bool,
}
fn list_pools_in(paths: &Paths) -> Result<Vec<PoolInfo>, String> {
let mut pools = Vec::new();
let entries = fs::read_dir(paths.pools_dir()).map_err(|e| e.to_string())?;
for entry in entries {
let entry = entry.map_err(|e| e.to_string())?;
let cfg_path = entry.path().join(POOL_FILE);
if !cfg_path.is_file() {
continue;
}
let raw = fs::read_to_string(&cfg_path).map_err(|e| e.to_string())?;
let pool: Pool =
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))?;
// oauth: Credentials liegen im Keychain, dessen Prüfung wäre ein
// security-Aufruf pro Pool im 3-s-Polling — deshalb hier immer true.
let has_credentials = match pool.credential_type.as_str() {
"apikey" => fs::read_to_string(entry.path().join(APIKEY_FILE))
.map(|s| !s.trim().is_empty())
.unwrap_or(false),
_ => true,
};
pools.push(PoolInfo {
projects: projects_using_pool(paths, &pool.name)?,
name: pool.name,
credential_type: pool.credential_type,
has_credentials,
});
}
pools.sort_by(|a, b| a.name.cmp(&b.name));
Ok(pools)
}
fn check_new_pool(paths: &Paths, name: &str) -> Result<PathBuf, String> {
check_name(name)?;
let dir = paths.pool_dir(name);
if dir.exists() {
return Err(format!("Pool existiert bereits: {name}"));
}
Ok(dir)
}
fn write_pool_json(dir: &PathBuf, name: &str, credential_type: &str) -> Result<(), String> {
fs::create_dir_all(dir).map_err(|e| e.to_string())?;
let pool = Pool {
name: name.to_string(),
credential_type: credential_type.to_string(),
};
let raw = serde_json::to_string_pretty(&pool).map_err(|e| e.to_string())?;
fs::write(dir.join(POOL_FILE), raw + "\n").map_err(|e| e.to_string())
}
fn write_secret_file(path: &PathBuf, content: &str) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
fs::write(path, content).map_err(|e| e.to_string())?;
fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|e| e.to_string())
}
/// Legt einen apikey-Pool an: Key-Datei (0600), settings.json mit
/// apiKeyHelper auf diese Datei, pool.json.
fn create_apikey_pool_in(paths: &Paths, name: &str, key: &str) -> Result<(), String> {
let dir = check_new_pool(paths, name)?;
let key = key.trim();
if key.is_empty() {
return Err("leerer API-Key".into());
}
let key_path = dir.join(APIKEY_FILE);
write_secret_file(&key_path, &format!("{key}\n"))?;
let settings = serde_json::json!({
"apiKeyHelper": format!("cat '{}'", key_path.display()),
});
let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?;
fs::write(dir.join("settings.json"), raw + "\n").map_err(|e| e.to_string())?;
write_pool_json(&dir, name, "apikey")
}
/// Legt einen oauth-Pool an: erst Login (schreibt .credentials.json in den
/// Pool-Ordner), dann nur bei Erfolg pool.json.
fn create_oauth_pool_in(
paths: &Paths,
name: &str,
login: impl FnOnce(&PathBuf) -> Result<(), String>,
) -> Result<(), String> {
let dir = check_new_pool(paths, name)?;
login(&dir.join(CREDENTIALS_FILE))?;
write_pool_json(&dir, name, "oauth")
}
/// Löscht einen Pool samt Ordner (inkl. Credentials); nur wenn kein Projekt ihn nutzt.
/// Löscht den Pool; Projekte, die ihn zugeordnet hatten, verlieren die
/// Zuordnung (Terminal-Einstellungen bleiben erhalten).
fn delete_pool_in(paths: &Paths, name: &str) -> Result<(), String> {
let dir = paths.pool_dir(name);
if !dir.join(POOL_FILE).is_file() {
return Err(format!("Pool nicht gefunden: {name}"));
}
for project in projects_using_pool(paths, name)? {
unassign_pool_in(paths, &project)?;
}
fs::remove_dir_all(&dir).map_err(|e| e.to_string())
}
/// Schreibt den API-Key eines apikey-Pools neu (0600).
fn set_apikey_in(paths: &Paths, pool: &str, key: &str) -> Result<(), String> {
let p = read_pool(paths, pool)?;
if p.credential_type != "apikey" {
return Err(format!("Pool {pool} ist kein apikey-Pool"));
}
let key = key.trim();
if key.is_empty() {
return Err("leerer API-Key".into());
}
write_secret_file(&paths.pool_dir(pool).join(APIKEY_FILE), &format!("{key}\n"))
}
fn oauth_credential_path(paths: &Paths, pool: &str) -> Result<PathBuf, String> {
let p = read_pool(paths, pool)?;
if p.credential_type != "oauth" {
return Err(format!("Pool {pool} ist kein oauth-Pool"));
}
Ok(paths.pool_dir(pool).join(CREDENTIALS_FILE))
}
// ---------- Verbrauch ----------
#[derive(Serialize, Default)]
struct UsageTotals {
#[serde(rename = "inputTokens")]
input_tokens: u64,
#[serde(rename = "outputTokens")]
output_tokens: u64,
#[serde(rename = "cacheCreationTokens")]
cache_creation_tokens: u64,
#[serde(rename = "cacheReadTokens")]
cache_read_tokens: u64,
#[serde(rename = "costUsd")]
cost_usd: f64,
}
#[derive(Serialize)]
struct UsageRow {
pool: String,
project: String,
#[serde(flatten)]
totals: UsageTotals,
}
/// USD pro MTok (input, output, cache_write, cache_read).
/// Cache: write 1.25× / read 0.1× des Input-Preises.
fn model_rates(model: &str) -> (f64, f64, f64, f64) {
let (input, output) = if model.contains("fable") || model.contains("mythos") {
(10.0, 50.0)
} else if model.contains("opus") {
(5.0, 25.0)
} else if model.contains("haiku") {
(1.0, 5.0)
} else {
// sonnet und Unbekanntes
(3.0, 15.0)
};
(input, output, input * 1.25, input * 0.1)
}
/// Tage seit Unix-Epoche für ein Kalenderdatum (Howard Hinnant, days_from_civil).
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let mp = (m + 9) % 12;
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146097 + doe - 719468
}
/// "YYYY-MM-DDTHH:MM:SS(.mmm)Z" (UTC) → Unix-Sekunden.
fn parse_ts(ts: &str) -> Option<i64> {
if ts.len() < 19 {
return None;
}
let num = |r: std::ops::Range<usize>| ts.get(r)?.parse::<i64>().ok();
let (y, m, d) = (num(0..4)?, num(5..7)?, num(8..10)?);
let (h, mi, s) = (num(11..13)?, num(14..16)?, num(17..19)?);
Some(days_from_civil(y, m, d) * 86400 + h * 3600 + mi * 60 + s)
}
/// Projektpfad so kodieren, wie claude die Transcript-Ordner benennt
/// (alles außer [a-zA-Z0-9] wird '-').
fn encode_project_path(path: &str) -> String {
path
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect()
}
/// Eine Transcript-Zeile auswerten: nur Zeilen mit message.usage zählen,
/// Zeitfilter, Dedup über message.id+requestId (Retries doppeln sonst).
fn add_usage_line(
line: &str,
cutoff: i64,
seen: &mut HashSet<String>,
totals: &mut UsageTotals,
) {
let v: serde_json::Value = match serde_json::from_str(line) {
Ok(v) => v,
// Nicht-JSON (z. B. angeschnittene letzte Zeile einer laufenden Session)
Err(_) => return,
};
let usage = &v["message"]["usage"];
if !usage.is_object() {
return;
}
match v["timestamp"].as_str().and_then(parse_ts) {
Some(t) if t >= cutoff => {}
_ => return,
}
if let Some(id) = v["message"]["id"].as_str() {
let key = format!("{id}:{}", v["requestId"].as_str().unwrap_or(""));
if !seen.insert(key) {
return;
}
}
let model = v["message"]["model"].as_str().unwrap_or("");
if model == "<synthetic>" {
return;
}
let input = usage["input_tokens"].as_u64().unwrap_or(0);
let output = usage["output_tokens"].as_u64().unwrap_or(0);
let cache_write = usage["cache_creation_input_tokens"].as_u64().unwrap_or(0);
let cache_read = usage["cache_read_input_tokens"].as_u64().unwrap_or(0);
let (ri, ro, rw, rr) = model_rates(model);
totals.input_tokens += input;
totals.output_tokens += output;
totals.cache_creation_tokens += cache_write;
totals.cache_read_tokens += cache_read;
totals.cost_usd += input as f64 / 1e6 * ri
+ output as f64 / 1e6 * ro
+ cache_write as f64 / 1e6 * rw
+ cache_read as f64 / 1e6 * rr;
}
/// Aggregiert message.usage aus den Transcript-JSONLs aller Pools:
/// <pool>/projects/<kodierter-projektpfad>/*.jsonl → Pool × Projekt.
/// Sichtfenster ist, was claude noch nicht weggeräumt hat (cleanupPeriodDays).
fn usage_stats_in(paths: &Paths, days: u32) -> Result<Vec<UsageRow>, String> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| e.to_string())?
.as_secs() as i64;
let cutoff = now - days as i64 * 86400;
// kodierter Projektpfad → Projektname
let mut names = HashMap::new();
for entry in fs::read_dir(paths.projects_dir()).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
if !entry.path().join(".claude").is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
names.insert(encode_project_path(&entry.path().to_string_lossy()), name);
}
let mut seen = HashSet::new();
let mut rows: HashMap<(String, String), UsageTotals> = HashMap::new();
for pool_entry in fs::read_dir(paths.pools_dir()).map_err(|e| e.to_string())? {
let pool_entry = pool_entry.map_err(|e| e.to_string())?;
let pool = pool_entry.file_name().to_string_lossy().into_owned();
let projects_root = pool_entry.path().join("projects");
if !projects_root.is_dir() {
continue;
}
for proj_entry in fs::read_dir(&projects_root).map_err(|e| e.to_string())? {
let proj_entry = proj_entry.map_err(|e| e.to_string())?;
if !proj_entry.path().is_dir() {
continue;
}
let encoded = proj_entry.file_name().to_string_lossy().into_owned();
// Unbekannte Ordner (Sessions außerhalb der Projekte) unter dem
// kodierten Namen ausweisen statt verschlucken.
let project = names.get(&encoded).cloned().unwrap_or(encoded);
let totals = rows.entry((pool.clone(), project)).or_default();
for file in fs::read_dir(proj_entry.path()).map_err(|e| e.to_string())? {
let file = file.map_err(|e| e.to_string())?;
let path = file.path();
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
continue;
}
let f = fs::File::open(&path).map_err(|e| format!("{}: {e}", path.display()))?;
for line in BufReader::new(f).lines() {
let line = line.map_err(|e| format!("{}: {e}", path.display()))?;
add_usage_line(&line, cutoff, &mut seen, totals);
}
}
}
}
let mut out: Vec<UsageRow> = rows
.into_iter()
.map(|((pool, project), totals)| UsageRow { pool, project, totals })
.collect();
out.sort_by(|a, b| {
a.pool
.cmp(&b.pool)
.then(b.totals.cost_usd.total_cmp(&a.totals.cost_usd))
});
Ok(out)
}
// ---------- Tauri-Commands ----------
#[tauri::command]
fn list_projects() -> Result<Vec<Project>, String> {
list_projects_in(&Paths::real())
}
#[tauri::command]
fn create_project(name: String) -> Result<(), String> {
create_project_in(&Paths::real(), &name)
}
#[tauri::command]
fn delete_project(name: String) -> Result<(), String> {
delete_project_in(&Paths::real(), &name)
}
#[tauri::command]
fn list_pools() -> Result<Vec<PoolInfo>, String> {
list_pools_in(&Paths::real())
}
#[tauri::command]
fn create_oauth_pool(name: String) -> Result<(), String> {
create_oauth_pool_in(&Paths::real(), &name, run_oauth_flow)
}
#[tauri::command]
fn create_apikey_pool(name: String, key: String) -> Result<(), String> {
create_apikey_pool_in(&Paths::real(), &name, &key)
}
#[tauri::command]
fn delete_pool(name: String) -> Result<(), String> {
delete_pool_in(&Paths::real(), &name)
}
#[tauri::command]
fn assign_pool(project: String, pool: String) -> Result<(), String> {
assign_pool_in(&Paths::real(), &project, &pool)
}
#[tauri::command]
fn unassign_pool(project: String) -> Result<(), String> {
unassign_pool_in(&Paths::real(), &project)
}
#[tauri::command]
fn usage_stats(days: u32) -> Result<Vec<UsageRow>, String> {
usage_stats_in(&Paths::real(), days)
}
#[tauri::command]
fn set_terminal_config(
project: String,
theme: Option<String>,
icon: Option<String>,
title: Option<String>,
) -> Result<(), String> {
set_terminal_config_in(&Paths::real(), &project, TerminalConfig { theme, icon, title })
}
#[tauri::command]
fn set_apikey(pool: String, key: String) -> Result<(), String> {
set_apikey_in(&Paths::real(), &pool, &key)
}
/// Keychain-Service-Name des Pools: claude legt pro CLAUDE_CONFIG_DIR einen
/// suffixierten Eintrag an, Suffix = erste 8 Hex-Zeichen von SHA-256 über
/// den Pool-Pfad.
fn keychain_service(paths: &Paths, pool: &str) -> String {
let hash = Sha256::digest(paths.pool_dir(pool).to_string_lossy().as_bytes());
let hex: String = hash.iter().map(|b| format!("{b:02x}")).collect();
format!("Claude Code-credentials-{}", &hex[..8])
}
fn keychain_entry_exists(service: &str) -> Result<bool, String> {
let out = Command::new("security")
.args(["find-generic-password", "-s", service])
.output()
.map_err(|e| e.to_string())?;
Ok(out.status.success())
}
#[tauri::command]
fn keychain_status(pool: String) -> Result<bool, String> {
keychain_entry_exists(&keychain_service(&Paths::real(), &pool))
}
/// Meldet einen oauth-Pool neu an: nur bei ungenutztem Pool. Löscht vorher
/// den suffixierten Keychain-Eintrag, damit der neue Login beim nächsten
/// Session-Start auch greift (claude importiert die frische
/// .credentials.json und legt den Eintrag neu an).
#[tauri::command]
fn oauth_login(pool: String) -> Result<(), String> {
let paths = Paths::real();
let cred_path = oauth_credential_path(&paths, &pool)?;
let running: Vec<String> = projects_using_pool(&paths, &pool)?
.into_iter()
.filter(|p| is_running(p))
.collect();
if !running.is_empty() {
return Err(format!(
"Neuanmeldung nur bei ungenutztem Pool möglich — läuft: {}",
running.join(", ")
));
}
let service = keychain_service(&paths, &pool);
if keychain_entry_exists(&service)? {
let out = Command::new("security")
.args(["delete-generic-password", "-s", &service])
.output()
.map_err(|e| e.to_string())?;
if !out.status.success() {
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
}
}
run_oauth_flow(&cred_path)
}
fn open_project(project: &str) -> Result<(), String> {
// Läuft die UI selbst in einer Ghostty-App, erbt `open` deren
// XDG_CONFIG_HOME und kalt gestartete Apps läsen die falsche Config.
let out = Command::new("open")
.env_remove("XDG_CONFIG_HOME")
.args(["-b", &bundle_id(project)])
.output()
.map_err(|e| e.to_string())?;
if out.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
}
#[tauri::command]
fn start_project(project: String) -> Result<(), String> {
open_project(&project)
}
/// Beendet den laufenden Prozess eines Projekts: das eingebaute Terminal per
/// SIGTERM auf die exakte PID (nie über die Bundle-ID — die würde alle
/// Instanzen inkl. der Haupt-App treffen), sonst die Ghostty-App per Quit.
#[tauri::command]
fn stop_project(project: String) -> Result<(), String> {
let pids = terminal_pids(&project);
if !pids.is_empty() {
for pid in pids {
let out = Command::new("kill")
.arg(pid.to_string())
.output()
.map_err(|e| e.to_string())?;
if !out.status.success() {
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
}
}
return Ok(());
}
if ghostty_running(&project) {
let out = Command::new("osascript")
.args(["-e", &format!("quit app id \"{}\"", bundle_id(&project))])
.output()
.map_err(|e| e.to_string())?;
if !out.status.success() {
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
}
return Ok(());
}
Err(format!("{project} läuft nicht"))
}
/// Beendet die Ghostty-App des Projekts (regulärer Quit, Ghostty darf noch
/// nachfragen), wartet auf das Prozessende und startet sie neu.
#[tauri::command]
fn restart_project(project: String) -> Result<(), String> {
let out = Command::new("osascript")
.args(["-e", &format!("quit app id \"{}\"", bundle_id(&project))])
.output()
.map_err(|e| e.to_string())?;
if !out.status.success() {
return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
}
let deadline = Instant::now() + Duration::from_secs(30);
while is_running(&project) {
if Instant::now() > deadline {
return Err(format!("{project} hat sich nach 30 s nicht beendet"));
}
std::thread::sleep(Duration::from_millis(250));
}
open_project(&project)
}
// ---------- OAuth-Flow ----------
fn pct(s: &str) -> String {
let mut out = String::new();
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{:02X}", b)),
}
}
out
}
fn random_urlsafe(len: usize) -> String {
let mut buf = vec![0u8; len];
getrandom::getrandom(&mut buf).expect("getrandom");
URL_SAFE_NO_PAD.encode(buf)
}
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Systemzeit")
.as_millis() as u64
}
/// Schreibt die Credential-Datei im Claude-Code-Format (0600).
fn write_credentials_file(
cred_path: &PathBuf,
creds: &serde_json::Value,
) -> Result<(), String> {
let raw = serde_json::to_string_pretty(creds).map_err(|e| e.to_string())?;
write_secret_file(cred_path, &(raw + "\n"))
}
/// Tauscht Code/Refresh-Token gegen Tokens und schreibt die Credential-Datei.
fn exchange_and_store(
body: serde_json::Value,
cred_path: &PathBuf,
) -> Result<(), String> {
let resp = ureq::post(TOKEN_URL)
.set("Content-Type", "application/json")
.send_json(body);
let resp = match resp {
Ok(r) => r,
Err(ureq::Error::Status(code, r)) => {
let txt = r.into_string().unwrap_or_default();
return Err(format!("Token-Endpoint {code}: {txt}"));
}
Err(e) => return Err(e.to_string()),
};
let v: serde_json::Value = resp.into_json().map_err(|e| e.to_string())?;
let access = v["access_token"]
.as_str()
.ok_or("access_token fehlt in Antwort")?;
let refresh = v["refresh_token"]
.as_str()
.ok_or("refresh_token fehlt in Antwort")?;
let expires_in = v["expires_in"].as_u64().unwrap_or(0);
let scopes: Vec<String> = v["scope"]
.as_str()
.unwrap_or(SCOPES)
.split_whitespace()
.map(|s| s.to_string())
.collect();
let creds = serde_json::json!({
"claudeAiOauth": {
"accessToken": access,
"refreshToken": refresh,
"expiresAt": now_ms() + expires_in * 1000,
"scopes": scopes,
}
});
write_credentials_file(cred_path, &creds)
}
/// Startet den Authorization-Code-Flow mit PKCE, öffnet den Browser und
/// nimmt den Redirect auf einem lokalen Loopback-Port entgegen; schreibt
/// die Tokens nach `cred_path`.
fn run_oauth_flow(cred_path: &PathBuf) -> Result<(), String> {
let listener = TcpListener::bind("127.0.0.1:0").map_err(|e| e.to_string())?;
listener.set_nonblocking(true).map_err(|e| e.to_string())?;
let port = listener.local_addr().map_err(|e| e.to_string())?.port();
let redirect_uri = format!("http://localhost:{port}/callback");
let verifier = random_urlsafe(32);
let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
let state = random_urlsafe(32);
let auth_url = format!(
"{AUTH_URL}?code=true&client_id={CLIENT_ID}&response_type=code\
&redirect_uri={ru}&scope={sc}&code_challenge={challenge}\
&code_challenge_method=S256&state={st}",
ru = pct(&redirect_uri),
sc = pct(SCOPES),
st = pct(&state),
);
Command::new("open")
.arg(&auth_url)
.status()
.map_err(|e| format!("Browser öffnen: {e}"))?;
let (code, got_state) = wait_for_callback(&listener)?;
if got_state != state {
return Err("state stimmt nicht überein".into());
}
let body = serde_json::json!({
"grant_type": "authorization_code",
"code": code,
"state": got_state,
"client_id": CLIENT_ID,
"redirect_uri": redirect_uri,
"code_verifier": verifier,
});
exchange_and_store(body, cred_path)
}
/// Pollt den Loopback-Listener bis zum Redirect mit `code`/`state`.
fn wait_for_callback(listener: &TcpListener) -> Result<(String, String), String> {
let deadline = Instant::now() + Duration::from_secs(180);
loop {
if Instant::now() > deadline {
return Err("Login-Timeout (kein Redirect nach 180 s)".into());
}
match listener.accept() {
Ok((mut stream, _)) => {
let mut reader = BufReader::new(&stream);
let mut line = String::new();
reader.read_line(&mut line).map_err(|e| e.to_string())?;
// "GET /callback?code=...&state=... HTTP/1.1"
let target = line.split_whitespace().nth(1).unwrap_or("");
let query = target.split_once('?').map(|(_, q)| q).unwrap_or("");
let mut code = String::new();
let mut state = String::new();
for pair in query.split('&') {
if let Some(v) = pair.strip_prefix("code=") {
code = pct_decode(v);
} else if let Some(v) = pair.strip_prefix("state=") {
state = pct_decode(v);
}
}
let ok = !code.is_empty();
let body = if ok {
"Anmeldung erfolgreich. Fenster kann geschlossen werden."
} else {
"Kein Code im Redirect."
};
let _ = stream.write_all(
format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/plain; charset=utf-8\r\n\
Content-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
)
.as_bytes(),
);
if ok {
return Ok((code, state));
}
// Nebenanfrage (z. B. favicon) ignorieren und weiter warten.
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(100));
}
Err(e) => return Err(e.to_string()),
}
}
}
fn pct_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::new();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'%' if i + 2 < bytes.len() => {
let h = u8::from_str_radix(&s[i + 1..i + 3], 16).unwrap_or(b'%');
out.push(h);
i += 3;
}
b'+' => {
out.push(b' ');
i += 1;
}
c => {
out.push(c);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// generate_context! darf pro Crate nur einmal expandieren (_EMBED_INFO_PLIST).
let context = tauri::generate_context!();
let mut args = std::env::args().skip(1);
match args.next().as_deref() {
// `app --terminal <projekt>`: eigener Prozess pro Terminal-Fenster,
// damit jedes Terminal ein eigenes Dock-Icon bekommt.
Some("--terminal") => {
let project = args.next().expect("--terminal braucht einen Projektnamen");
let icon = terminal_config(&project)
.expect("Projekt-Config nicht lesbar")
.icon;
terminal_builder(project)
.build(context)
.expect("error while building tauri application")
// Das Dock-Icon erst nach dem App-Start setzen: in setup() gesetzt
// überschreibt macOS es beim Anlegen des Dock-Tiles wieder.
.run(move |_app, event| {
if let tauri::RunEvent::Ready = event {
if let Some(icon) = icon.as_deref() {
terminal::set_dock_icon(icon);
}
}
});
}
_ => main_builder()
.run(context)
.expect("error while running tauri application"),
}
}
/// Haupt-App: reine Tray-App ohne Dock-Eintrag.
fn main_builder() -> tauri::Builder<tauri::Wry> {
use tauri::Manager;
tauri::Builder::default()
.plugin(tauri_plugin_autostart::init(
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
None,
))
.plugin(tauri_plugin_dialog::init())
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
tauri_plugin_log::Builder::default()
.level(log::LevelFilter::Info)
.build(),
)?;
}
// Nur Tray-Icon in der Menüleiste, kein Dock-Eintrag.
#[cfg(target_os = "macos")]
app.set_activation_policy(tauri::ActivationPolicy::Accessory);
// Fenster im Code statt in tauri.conf.json, damit der Terminal-Prozess
// (gleiches Binary, gleiche Config) kein main-Fenster anlegt.
tauri::WebviewWindowBuilder::new(app, "main", tauri::WebviewUrl::default())
.title("ai-control")
.inner_size(800.0, 600.0)
.visible(false)
.build()?;
let open = tauri::menu::MenuItem::with_id(app, "open", "Öffnen", true, None::<&str>)?;
let quit = tauri::menu::MenuItem::with_id(app, "quit", "Beenden", true, None::<&str>)?;
let menu = tauri::menu::Menu::with_items(app, &[&open, &quit])?;
tauri::tray::TrayIconBuilder::with_id("main")
// include_bytes! statt default_window_icon: cargo trackt die Datei,
// neu generierte Icons landen damit sicher im nächsten Build.
.icon(tauri::image::Image::from_bytes(include_bytes!(
"../icons/128x128.png"
))?)
// Template-Icon: macOS färbt es passend zur Menüleiste ein.
.icon_as_template(true)
.menu(&menu)
.on_menu_event(|app, event| match event.id.as_ref() {
"open" => {
let w = app.get_webview_window("main").unwrap();
w.show().unwrap();
w.set_focus().unwrap();
}
"quit" => app.exit(0),
_ => {}
})
.build(app)?;
Ok(())
})
// Hauptfenster schließen versteckt nur; Beenden geht übers Tray-Menü.
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
window.hide().unwrap();
}
})
.invoke_handler(tauri::generate_handler![
list_projects,
create_project,
delete_project,
list_pools,
create_oauth_pool,
create_apikey_pool,
delete_pool,
assign_pool,
unassign_pool,
set_terminal_config,
usage_stats,
start_project,
stop_project,
restart_project,
oauth_login,
keychain_status,
set_apikey,
terminal::open_terminal
])
}
/// Terminal-Prozess: ein Fenster mit eigener PTY; Activation-Policy bleibt
/// Regular, dadurch Dock-Icon und Cmd-Tab-Eintrag pro Terminal.
fn terminal_builder(project: String) -> tauri::Builder<tauri::Wry> {
tauri::Builder::default()
.manage(terminal::Terminals::default())
.setup(move |app| {
let cfg = terminal_config(&project)?;
terminal::build_window(app.handle(), &project, &cfg)?;
Ok(())
})
// Fenster zu → PTY-Session abräumen; danach endet der Prozess.
.on_window_event(|window, event| {
if let tauri::WindowEvent::Destroyed = event {
terminal::close(window);
}
})
.invoke_handler(tauri::generate_handler![
terminal::term_start,
terminal::term_log,
terminal::term_write,
terminal::term_resize,
// Pool-Chip im Terminal-Header braucht die Projektliste auch hier.
list_projects
])
}
// ---------- Tests ----------
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
static N: AtomicU32 = AtomicU32::new(0);
/// Frisches Pseudo-Home pro Test; claude-projects existiert wie auf dem
/// echten System.
fn tmp_paths() -> Paths {
let dir = std::env::temp_dir().join(format!(
"ai-control-test-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::SeqCst)
));
fs::create_dir_all(dir.join("claude-projects")).unwrap();
Paths { home: dir }
}
fn dummy_creds() -> serde_json::Value {
serde_json::json!({
"claudeAiOauth": {
"accessToken": "at-test",
"refreshToken": "rt-test",
"expiresAt": 1_999_999_999_000u64,
"scopes": ["user:inference"],
}
})
}
fn make_oauth_pool(p: &Paths, name: &str) {
create_oauth_pool_in(p, name, |cred| {
write_credentials_file(cred, &dummy_creds())
})
.unwrap();
}
fn mode(path: &PathBuf) -> u32 {
fs::metadata(path).unwrap().permissions().mode() & 0o777
}
// -- Pool anlegen --
#[test]
fn apikey_pool_anlegen() {
let p = tmp_paths();
create_apikey_pool_in(&p, "kunde", "sk-test-123").unwrap();
let dir = p.pool_dir("kunde");
let pool: Pool =
serde_json::from_str(&fs::read_to_string(dir.join(POOL_FILE)).unwrap()).unwrap();
assert_eq!(pool.name, "kunde");
assert_eq!(pool.credential_type, "apikey");
let key_path = dir.join(APIKEY_FILE);
assert_eq!(fs::read_to_string(&key_path).unwrap(), "sk-test-123\n");
assert_eq!(mode(&key_path), 0o600);
let settings: serde_json::Value =
serde_json::from_str(&fs::read_to_string(dir.join("settings.json")).unwrap())
.unwrap();
assert_eq!(
settings["apiKeyHelper"].as_str().unwrap(),
format!("cat '{}'", key_path.display())
);
}
#[test]
fn oauth_pool_anlegen() {
let p = tmp_paths();
make_oauth_pool(&p, "privat");
let dir = p.pool_dir("privat");
let pool: Pool =
serde_json::from_str(&fs::read_to_string(dir.join(POOL_FILE)).unwrap()).unwrap();
assert_eq!(pool.credential_type, "oauth");
let cred_path = dir.join(CREDENTIALS_FILE);
let creds: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&cred_path).unwrap()).unwrap();
assert_eq!(creds["claudeAiOauth"]["accessToken"], "at-test");
assert_eq!(mode(&cred_path), 0o600);
}
#[test]
fn oauth_pool_login_fehlschlag_legt_nichts_an() {
let p = tmp_paths();
let err = create_oauth_pool_in(&p, "privat", |_| Err("Login abgebrochen".into()));
assert!(err.is_err());
assert!(!p.pool_dir("privat").exists());
}
#[test]
fn pool_doppelt_anlegen_scheitert() {
let p = tmp_paths();
create_apikey_pool_in(&p, "kunde", "sk-1").unwrap();
let err = create_apikey_pool_in(&p, "kunde", "sk-2").unwrap_err();
assert!(err.contains("existiert bereits"));
}
#[test]
fn pool_leerer_key_scheitert() {
let p = tmp_paths();
assert!(create_apikey_pool_in(&p, "kunde", " ").is_err());
}
#[test]
fn pool_name_mit_slash_scheitert() {
let p = tmp_paths();
assert!(create_apikey_pool_in(&p, "a/b", "sk-1").is_err());
}
// -- Pool ändern --
#[test]
fn apikey_aendern() {
let p = tmp_paths();
create_apikey_pool_in(&p, "kunde", "sk-alt").unwrap();
set_apikey_in(&p, "kunde", "sk-neu").unwrap();
let key_path = p.pool_dir("kunde").join(APIKEY_FILE);
assert_eq!(fs::read_to_string(&key_path).unwrap(), "sk-neu\n");
assert_eq!(mode(&key_path), 0o600);
}
#[test]
fn apikey_aendern_auf_oauth_pool_scheitert() {
let p = tmp_paths();
make_oauth_pool(&p, "privat");
assert!(set_apikey_in(&p, "privat", "sk-1").is_err());
}
#[test]
fn oauth_relogin_ueberschreibt_credentials() {
let p = tmp_paths();
make_oauth_pool(&p, "privat");
let cred_path = oauth_credential_path(&p, "privat").unwrap();
let mut neu = dummy_creds();
neu["claudeAiOauth"]["accessToken"] = "at-neu".into();
write_credentials_file(&cred_path, &neu).unwrap();
let creds: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&cred_path).unwrap()).unwrap();
assert_eq!(creds["claudeAiOauth"]["accessToken"], "at-neu");
}
#[test]
fn oauth_credential_path_auf_apikey_pool_scheitert() {
let p = tmp_paths();
create_apikey_pool_in(&p, "kunde", "sk-1").unwrap();
assert!(oauth_credential_path(&p, "kunde").is_err());
}
// -- Pool löschen --
#[test]
fn pool_loeschen() {
let p = tmp_paths();
create_apikey_pool_in(&p, "kunde", "sk-1").unwrap();
delete_pool_in(&p, "kunde").unwrap();
assert!(!p.pool_dir("kunde").exists());
}
// -- Verbrauch --
fn usage_line(id: &str, req: &str, ts: &str, model: &str, input: u64, output: u64) -> String {
serde_json::json!({
"type": "assistant",
"timestamp": ts,
"requestId": req,
"message": {
"id": id,
"model": model,
"usage": {
"input_tokens": input,
"output_tokens": output,
"cache_creation_input_tokens": 1_000_000,
"cache_read_input_tokens": 2_000_000
}
}
})
.to_string()
}
#[test]
fn verbrauch_aggregation_dedup_und_zeitfilter() {
let p = tmp_paths();
create_apikey_pool_in(&p, "kunde", "sk-1").unwrap();
create_project_in(&p, "proj").unwrap();
let proj_path = p.projects_dir().join("proj");
let encoded = encode_project_path(&proj_path.to_string_lossy());
let dir = p.pool_dir("kunde").join("projects").join(&encoded);
fs::create_dir_all(&dir).unwrap();
let lines = [
// Sonnet: 1M in ($3) + 1M out ($15) + 1M cache-write ($3.75) + 2M cache-read ($0.60)
usage_line("msg_1", "req_1", "2099-01-01T10:00:00.000Z", "claude-sonnet-5", 1_000_000, 1_000_000),
// Retry-Duplikat: gleiche message.id + requestId → zählt nicht
usage_line("msg_1", "req_1", "2099-01-01T10:00:01.000Z", "claude-sonnet-5", 1_000_000, 1_000_000),
// außerhalb des Zeitfensters → zählt nicht
usage_line("msg_2", "req_2", "2020-01-01T00:00:00.000Z", "claude-sonnet-5", 5, 5),
// kein usage-Objekt → ignoriert
r#"{"type":"user","timestamp":"2099-01-01T10:00:02.000Z"}"#.to_string(),
// kaputte Zeile (laufende Session) → ignoriert
r#"{"type":"assist"#.to_string(),
];
fs::write(dir.join("session.jsonl"), lines.join("\n")).unwrap();
// Cutoff = jetzt 365 Tage: schließt 2020 aus, 2099 ein — datumsunabhängig
let rows = usage_stats_in(&p, 365).unwrap();
assert_eq!(rows.len(), 1);
let r = &rows[0];
assert_eq!(r.pool, "kunde");
assert_eq!(r.project, "proj");
assert_eq!(r.totals.input_tokens, 1_000_000);
assert_eq!(r.totals.output_tokens, 1_000_000);
assert_eq!(r.totals.cache_creation_tokens, 1_000_000);
assert_eq!(r.totals.cache_read_tokens, 2_000_000);
assert!((r.totals.cost_usd - 22.35).abs() < 1e-9);
}
#[test]
fn verbrauch_leer_ohne_transcripts() {
let p = tmp_paths();
create_apikey_pool_in(&p, "kunde", "sk-1").unwrap();
assert!(usage_stats_in(&p, 30).unwrap().is_empty());
}
#[test]
fn parse_ts_referenz() {
// 2026-01-01 = 1767225600; +181 Tage bis 01.07. +10 h = 1782900000
assert_eq!(parse_ts("2026-07-01T10:00:00.000Z"), Some(1_782_900_000));
assert_eq!(parse_ts("kein datum"), None);
}
/// Referenzwert vom echten System: privateDefault → 096c4ef9
/// (verifiziert 2026-07-03 gegen den von claude angelegten Eintrag).
#[test]
fn keychain_service_suffix() {
let p = Paths { home: PathBuf::from("/Users/marcus.hinz") };
assert_eq!(
keychain_service(&p, "privateDefault"),
"Claude Code-credentials-096c4ef9"
);
}
#[test]
fn pool_key_status() {
let p = tmp_paths();
create_apikey_pool_in(&p, "kunde", "sk-1").unwrap();
make_oauth_pool(&p, "privat");
let pools = list_pools_in(&p).unwrap();
assert!(pools.iter().find(|x| x.name == "kunde").unwrap().has_credentials);
// oauth: Keychain wird im Listing bewusst nicht geprüft → immer true.
assert!(pools.iter().find(|x| x.name == "privat").unwrap().has_credentials);
fs::write(p.pool_dir("kunde").join(APIKEY_FILE), "\n").unwrap();
let pools = list_pools_in(&p).unwrap();
assert!(!pools.iter().find(|x| x.name == "kunde").unwrap().has_credentials);
fs::remove_file(p.pool_dir("kunde").join(APIKEY_FILE)).unwrap();
let pools = list_pools_in(&p).unwrap();
assert!(!pools.iter().find(|x| x.name == "kunde").unwrap().has_credentials);
}
#[test]
fn pool_loeschen_loest_zuordnungen() {
let p = tmp_paths();
create_apikey_pool_in(&p, "kunde", "sk-1").unwrap();
create_project_in(&p, "proj").unwrap();
assign_pool_in(&p, "proj", "kunde").unwrap();
delete_pool_in(&p, "kunde").unwrap();
assert!(!p.pool_dir("kunde").exists());
assert!(!p.project_config("proj").exists());
}
#[test]
fn pool_loeschen_erhaelt_terminal_config() {
let p = tmp_paths();
create_apikey_pool_in(&p, "kunde", "sk-1").unwrap();
create_project_in(&p, "proj").unwrap();
assign_pool_in(&p, "proj", "kunde").unwrap();
set_terminal_config_in(
&p,
"proj",
TerminalConfig { theme: Some("dracula".into()), icon: None, title: None },
)
.unwrap();
delete_pool_in(&p, "kunde").unwrap();
let cfg = read_project_config_in(&p, "proj").unwrap();
assert_eq!(cfg.pool, None);
assert_eq!(cfg.terminal.theme.as_deref(), Some("dracula"));
}
#[test]
fn pool_loeschen_unbekannt_scheitert() {
let p = tmp_paths();
assert!(delete_pool_in(&p, "gibtsnicht").is_err());
}
// -- Projekt zuordnen / rausnehmen / wechseln --
#[test]
fn projekt_zuordnen() {
let p = tmp_paths();
create_apikey_pool_in(&p, "kunde", "sk-1").unwrap();
create_project_in(&p, "proj").unwrap();
assign_pool_in(&p, "proj", "kunde").unwrap();
let cfg: ProjectConfig =
serde_json::from_str(&fs::read_to_string(p.project_config("proj")).unwrap())
.unwrap();
assert_eq!(cfg.pool.as_deref(), Some("kunde"));
let pools = list_pools_in(&p).unwrap();
assert_eq!(pools[0].projects, vec!["proj"]);
}
#[test]
fn projekt_zuordnen_pool_fehlt_scheitert() {
let p = tmp_paths();
create_project_in(&p, "proj").unwrap();
assert!(assign_pool_in(&p, "proj", "gibtsnicht").is_err());
}
#[test]
fn projekt_rausnehmen() {
let p = tmp_paths();
create_apikey_pool_in(&p, "kunde", "sk-1").unwrap();
create_project_in(&p, "proj").unwrap();
assign_pool_in(&p, "proj", "kunde").unwrap();
unassign_pool_in(&p, "proj").unwrap();
assert!(!p.project_config("proj").exists());
}
#[test]
fn projekt_rausnehmen_ohne_zuordnung_scheitert() {
let p = tmp_paths();
create_project_in(&p, "proj").unwrap();
assert!(unassign_pool_in(&p, "proj").is_err());
}
/// Wechsel oauth → apikey: das Projekt sieht nur den Pool-Namen,
/// der Credential-Typ ist ihm egal.
#[test]
fn pool_wechseln_typ_ist_projekt_egal() {
let p = tmp_paths();
make_oauth_pool(&p, "privat");
create_apikey_pool_in(&p, "kunde", "sk-1").unwrap();
create_project_in(&p, "proj").unwrap();
assign_pool_in(&p, "proj", "privat").unwrap();
assign_pool_in(&p, "proj", "kunde").unwrap();
let raw = fs::read_to_string(p.project_config("proj")).unwrap();
let cfg: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(cfg, serde_json::json!({ "pool": "kunde" }));
}
// -- Projekt anlegen / löschen --
#[test]
fn projekt_anlegen() {
let p = tmp_paths();
create_project_in(&p, "proj").unwrap();
assert!(p.projects_dir().join("proj").join(".claude").is_dir());
let projects = list_projects_in(&p).unwrap();
assert_eq!(projects.len(), 1);
assert_eq!(projects[0].name, "proj");
assert_eq!(projects[0].pool, None);
}
#[test]
fn projekt_doppelt_anlegen_scheitert() {
let p = tmp_paths();
create_project_in(&p, "proj").unwrap();
assert!(create_project_in(&p, "proj").is_err());
}
#[test]
fn projekt_loeschen() {
let p = tmp_paths();
create_project_in(&p, "proj").unwrap();
delete_project_in(&p, "proj").unwrap();
assert!(!p.projects_dir().join("proj").exists());
}
#[test]
fn projekt_loeschen_unbekannt_scheitert() {
let p = tmp_paths();
assert!(delete_project_in(&p, "gibtsnicht").is_err());
}
}