Projekt-Identität und -Config neu: .ai-control/config.json mit UUID und Name

- Projekt-Config zieht von ai-control.json nach .ai-control/config.json um,
  das Icon liegt daneben und synct mit dem Projekt.
- Jedes Projekt trägt eine UUID (id) und den Anzeigenamen (name) in der
  Config; die UUID ist Registry-Schlüssel und project-Parameter aller
  Commands, der Name reine Darstellung.
- Pool-Zuordnung ist maschinenlokal und liegt in der projects.json-Registry,
  nicht mehr in der syncbaren Projekt-Config.
- open_terminal prüft die ID im Ordner gegen die Registry (verschobene oder
  ersetzte Ordner scheitern laut).
- Import übernimmt eine mitgebrachte ID, legt Arbeitsordner und Archiv-Home
  an und zieht die Archiv-Permission nach.
- Migration beim App-Start: Alt-Datei umziehen, Pool in die Registry,
  Icons aus ~/.config/ai-control/icons ins Projekt, Registry auf UUIDs.
- ProjectConfig reicht unbekannte Keys per serde(flatten) durch den
  Round-Trip (Regressionstest config_roundtrip_erhaelt_fremde_keys).
This commit is contained in:
marcus hinz
2026-07-20 10:55:21 +02:00
parent 6d44f77671
commit b9d006184a
20 changed files with 830 additions and 346 deletions
+12 -5
View File
@@ -11,7 +11,7 @@ use crate::domain::project::{
};
use crate::domain::registry::project_dir;
/// Konfiguriertes Archiv-Home des Projekts (ai-control.json: archiveHome),
/// Konfiguriertes Archiv-Home des Projekts (config.json: archiveHome),
/// Home-expandiert; None, wenn nicht gesetzt.
pub(crate) fn project_archive_home(project: &str) -> Option<PathBuf> {
let paths = Paths::real();
@@ -27,7 +27,7 @@ pub(crate) fn require_archive_home(project: &str) -> Result<PathBuf, String> {
Ok(home)
}
/// Setzt das Archiv-Home: ai-control.json (~-relativ) und ein Eintrag in
/// Setzt das Archiv-Home: config.json (~-relativ) und ein Eintrag in
/// permissions.additionalDirectories + Edit der Projekt-settings.json, damit
/// claude das Archiv später lesen/scannen darf.
pub(crate) fn set_project_archive_home(project: &str, dir: &str) -> Result<(), String> {
@@ -53,7 +53,12 @@ pub(crate) fn set_project_archive_home(project: &str, dir: &str) -> Result<(), S
}
/// Trägt den Archiv-Ordner idempotent in additionalDirectories + Edit-Allow ein.
fn add_archive_permission(paths: &Paths, project: &str, dir: &str) -> Result<(), String> {
/// Auch beim Projekt-Import im Einsatz (mitgebrachtes archiveHome).
pub(crate) fn add_archive_permission(
paths: &Paths,
project: &str,
dir: &str,
) -> Result<(), String> {
let sp = settings_path(&project_dir(paths, project)?);
let mut v: serde_json::Value = if sp.is_file() {
serde_json::from_str(&fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?)
@@ -90,7 +95,7 @@ fn add_archive_permission(paths: &Paths, project: &str, dir: &str) -> Result<(),
fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
}
/// Wählt das Archiv ab: archiveHome aus der ai-control.json entfernen und die
/// Wählt das Archiv ab: archiveHome aus der config.json entfernen und die
/// beim Setzen eingetragenen Rechte (additionalDirectories + Edit-Allow) aus
/// der Projekt-settings.json zurücknehmen. Der Ordner selbst bleibt liegen.
pub(crate) fn clear_project_archive_home(project: &str) -> Result<(), String> {
@@ -171,7 +176,9 @@ pub(crate) fn archive_panel_content(
let (stamp, iso) = utc_stamp(secs);
let title = first_line(&text);
let path = free_path(&dir, &stamp, &slugify(&title));
let doc = format!("{}{}\n", frontmatter(&title, project, &iso, meta), text.trim_end());
// Frontmatter trägt den Anzeigenamen, nicht die Projekt-ID.
let name = crate::domain::project::display_name_in(&Paths::real(), project)?;
let doc = format!("{}{}\n", frontmatter(&title, &name, &iso, meta), text.trim_end());
fs::write(&path, doc).map_err(|e| format!("{}: {e}", path.display()))?;
Ok(path)
}
+2 -2
View File
@@ -33,8 +33,8 @@ impl Paths {
self.config_dir().join("pools")
}
/// Gemeinsames Icons-Verzeichnis aller Projekte — synct mit der App-Config,
/// unabhängig von Pool-Zuordnung und Quell-Repos.
/// Ehemals gemeinsames Icons-Verzeichnis aller Projekte — Icons liegen jetzt
/// im .ai-control/-Ordner des Projekts; hiervon liest nur noch die Migration.
pub(crate) fn icons_dir(&self) -> PathBuf {
self.config_dir().join("icons")
}
+14 -6
View File
@@ -80,8 +80,16 @@ pub(crate) fn list_pools_in(
}
_ => true,
};
let projects = projects_using_pool(paths, &id)?;
let running = projects.iter().filter(|p| is_running(p)).cloned().collect();
// Anzeige-Namen fürs Frontend; die Lauf-Erkennung braucht die IDs.
let mut projects = Vec::new();
let mut running = Vec::new();
for project in projects_using_pool(paths, &id)? {
let name = crate::domain::project::display_name_in(paths, &project)?;
if is_running(&project) {
running.push(name.clone());
}
projects.push(name);
}
pools.push(PoolInfo {
id,
projects,
@@ -486,6 +494,7 @@ mod tests {
use crate::domain::project::{
assign_pool_in, read_project_config_in, set_terminal_config_in, TerminalConfig,
};
use crate::domain::registry::project_pool;
use crate::domain::settings::APP_SETTINGS_FILE;
use crate::domain::testutil::{
create_project, make_apikey_pool, make_oauth_pool, map_store, mode, tmp_paths, FailStore,
@@ -625,8 +634,7 @@ mod tests {
assert_eq!(pool.name, "kunde-neu");
assert_eq!(pool.credential_type, "apikey");
assert!(p.pool_dir(&id).is_dir());
let cfg = read_project_config_in(&p, "proj").unwrap();
assert_eq!(cfg.pool.as_deref(), Some(id.as_str()));
assert_eq!(project_pool(&p, "proj").unwrap().as_deref(), Some(id.as_str()));
}
#[test]
@@ -772,7 +780,7 @@ mod tests {
assign_pool_in(&p, "proj", &kunde).unwrap();
delete_pool_in(&p, &store, &kunde).unwrap();
assert!(!p.pool_dir(&kunde).exists());
assert!(!crate::domain::project::project_config_path(&p, "proj").unwrap().exists());
assert_eq!(project_pool(&p, "proj").unwrap(), None);
}
#[test]
@@ -824,8 +832,8 @@ mod tests {
)
.unwrap();
delete_pool_in(&p, &store, &kunde).unwrap();
assert_eq!(project_pool(&p, "proj").unwrap(), None);
let cfg = read_project_config_in(&p, "proj").unwrap();
assert_eq!(cfg.pool, None);
assert_eq!(cfg.terminal.theme.as_deref(), Some("dracula"));
}
+508 -158
View File
@@ -1,21 +1,38 @@
//! Projekte: Anlage (Wizard), Registry-Aufnahme, Arbeitsordner, Pool-Zuordnung,
//! Terminal-Einstellungen, Löschen. Der Projektordner trägt .claude/settings.json
//! und die ai-control.json (Pool + Terminal-Config).
//! und .ai-control/ (config.json + Icon — synct mit dem Projekt); die
//! Pool-Zuordnung ist maschinenlokal und liegt in der Registry.
//!
//! Identität: Jedes Projekt hat eine UUID (`id` in der config.json). Sie ist
//! der Registry-Schlüssel und der `project`-Parameter aller Commands; der
//! Anzeigename (`name`) ist reine Darstellung. Registry-Eintrag und Ordner
//! sind damit gegeneinander prüfbar (`verify_project_dir_in`).
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::path::PathBuf;
use crate::domain::check_name;
use crate::domain::paths::{contract_home, expand_home, Paths};
use crate::domain::pool::POOL_FILE;
use crate::domain::registry::{load_registry, project_dir, register_project, save_registry};
use crate::domain::registry::{
load_registry, project_dir, project_pool, register_project, save_registry, set_project_pool,
RegEntry,
};
/// Projekt-Config im Projektordner (Pool-Zuordnung + Terminal-Einstellungen).
pub(crate) const PROJECT_FILE: &str = "ai-control.json";
/// Projekt-eigener Config-Ordner im Projekt-Root: config.json + Icon.
pub(crate) const PROJECT_CONFIG_DIR: &str = ".ai-control";
/// Projekt-Config im Config-Ordner (Terminal-Einstellungen + Archiv-Home).
pub(crate) const PROJECT_FILE: &str = "config.json";
/// Alt-Layout: einzelne Datei im Projekt-Root; wird beim App-Start migriert.
const LEGACY_PROJECT_FILE: &str = "ai-control.json";
#[derive(Serialize)]
pub(crate) struct Project {
pub(crate) id: String,
pub(crate) name: String,
pub(crate) path: String,
pub(crate) pool: Option<String>,
@@ -25,8 +42,13 @@ pub(crate) struct Project {
#[derive(Serialize, Deserialize, Default)]
pub(crate) struct ProjectConfig {
/// Projekt-UUID — Registry-Schlüssel; synct mit dem Projekt und macht den
/// Registry-Eintrag gegen den Ordner prüfbar.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) pool: Option<String>,
pub(crate) id: Option<String>,
/// Anzeigename (Projektliste, Fenstertitel-Fallback, .desktop-Name).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) name: Option<String>,
#[serde(default, skip_serializing_if = "TerminalConfig::is_empty")]
pub(crate) terminal: TerminalConfig,
/// Archiv-Home des Projekts: Zielordner fürs Archivieren von Panel-Entwürfen
@@ -72,7 +94,7 @@ pub(crate) fn settings_path(dir: &std::path::Path) -> PathBuf {
}
pub(crate) fn project_config_path(paths: &Paths, project: &str) -> Result<PathBuf, String> {
Ok(project_dir(paths, project)?.join(PROJECT_FILE))
Ok(project_dir(paths, project)?.join(PROJECT_CONFIG_DIR).join(PROJECT_FILE))
}
pub(crate) fn read_project_config_in(
@@ -94,7 +116,9 @@ pub(crate) fn write_project_config_in(
cfg: &ProjectConfig,
) -> Result<(), String> {
let raw = serde_json::to_string_pretty(cfg).map_err(|e| e.to_string())?;
let path = project_config_path(paths, project)?;
let dir = project_dir(paths, project)?.join(PROJECT_CONFIG_DIR);
fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let path = dir.join(PROJECT_FILE);
fs::write(&path, raw + "\n").map_err(|e| format!("{}: {e}", path.display()))
}
@@ -122,65 +146,91 @@ pub(crate) fn running_projects_using_pool(
.collect())
}
/// Namen aller Projekte, die `pool` zugeordnet haben.
/// Namen aller Projekte, denen die Registry `pool` zuordnet.
pub(crate) fn projects_using_pool(paths: &Paths, pool: &str) -> Result<Vec<String>, String> {
let mut users = Vec::new();
for (name, dir) in load_registry(paths)? {
let cfg_path = dir.join(PROJECT_FILE);
if !cfg_path.is_file() {
continue;
}
let raw = fs::read_to_string(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()))?;
let cfg: ProjectConfig = serde_json::from_str(&raw).map_err(|e| e.to_string())?;
if cfg.pool.as_deref() == Some(pool) {
users.push(name);
}
}
Ok(users)
Ok(
load_registry(paths)?
.into_iter()
.filter(|(_, e)| e.pool.as_deref() == Some(pool))
.map(|(name, _)| name)
.collect(),
)
}
pub(crate) fn list_projects_in(paths: &Paths) -> Result<Vec<Project>, String> {
let mut projects = Vec::new();
for (name, dir) in load_registry(paths)? {
let cfg = read_project_config_in(paths, &name)?;
for (id, entry) in load_registry(paths)? {
let cfg = read_project_config_in(paths, &id)?;
projects.push(Project {
running: is_running(&name),
path: contract_home(paths, &dir),
pool: cfg.pool,
running: is_running(&id),
path: contract_home(paths, &entry.dir),
pool: entry.pool,
name: cfg.name.unwrap_or_else(|| id.clone()),
terminal: cfg.terminal,
name,
id,
});
}
Ok(projects)
}
/// Anzeigename eines Projekts; Fallback ist die ID (Projekte vor der Migration).
pub(crate) fn display_name_in(paths: &Paths, project: &str) -> Result<String, String> {
Ok(
read_project_config_in(paths, project)?
.name
.unwrap_or_else(|| project.to_string()),
)
}
/// Prüft Registry-Eintrag gegen den Ordner: die config.json unter dem
/// registrierten Pfad muss die Projekt-ID tragen. Liefert den Ordner.
pub(crate) fn verify_project_dir_in(paths: &Paths, project: &str) -> Result<PathBuf, String> {
let dir = project_dir(paths, project)?;
let cfg = read_project_config_in(paths, project)?;
if cfg.id.as_deref() != Some(project) {
return Err(format!(
"Projektordner passt nicht zur Registry: {} trägt die ID {}, registriert ist {project}",
dir.display(),
cfg.id.as_deref().unwrap_or("(keine)"),
));
}
Ok(dir)
}
/// Pool-Config-Verzeichnis eines Projekts — wird dem Terminal als
/// CLAUDE_CONFIG_DIR mitgegeben.
///
/// Der Name wird geprüft, obwohl `assign_pool_in` das beim Zuweisen schon tut:
/// Die Quelle ist die `ai-control.json` **im Projektordner**, also eine
/// versionierte Datei, die mit einem geklonten Repo hereinkommt. Ungeprüft
/// bestimmte sie mit `../…` ein beliebiges Verzeichnis als CLAUDE_CONFIG_DIR —
/// und dessen `settings.json` trägt `apiKeyHelper`, ein Kommando, das beim
/// Sessionstart ausgeführt wird.
/// Der Pool-Name wird geprüft, obwohl `assign_pool_in` das beim Zuweisen schon
/// tut: Die Migration übernimmt Pool-Einträge aus der alten, versionierten
/// ai-control.json eines geklonten Repos. Ungeprüft bestimmte ein `../…`
/// daraus ein beliebiges Verzeichnis als CLAUDE_CONFIG_DIR — und dessen
/// `settings.json` trägt `apiKeyHelper`, ein Kommando, das beim Sessionstart
/// ausgeführt wird.
pub(crate) fn project_pool_dir(project: &str) -> Result<Option<PathBuf>, String> {
let paths = Paths::real();
let Some(pool) = read_project_config_in(&paths, project)?.pool else {
project_pool_dir_in(&Paths::real(), project)
}
pub(crate) fn project_pool_dir_in(
paths: &Paths,
project: &str,
) -> Result<Option<PathBuf>, String> {
let Some(pool) = project_pool(paths, project)? else {
return Ok(None);
};
check_name(&pool).map_err(|_| format!("ungültiger Pool in {PROJECT_FILE}: {pool}"))?;
check_name(&pool).map_err(|_| format!("ungültiger Pool in der Registry: {pool}"))?;
Ok(Some(paths.pool_dir(&pool)))
}
/// 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)
/// Projekt-Config für den Terminal-Prozess (Terminal-Einstellungen + Name).
pub(crate) fn project_config(project: &str) -> Result<ProjectConfig, String> {
read_project_config_in(&Paths::real(), project)
}
/// Legt ein Projekt nach dem Muster der bestehenden an: memory/, .gitignore
/// (Sentinel), .claude/settings.json (autoMemoryDirectory, Permissions,
/// Berechtigungen), ai-control.json mit Pool/Terminal-Config, Registry-Eintrag.
/// Ohne Zielordner landet das Projekt unter ~/claude-projects/<name>.
/// Berechtigungen), Registry-Eintrag mit Pool, .ai-control/config.json mit
/// ID, Name und Terminal-Config. Ohne Zielordner landet das Projekt unter
/// ~/claude-projects/<name>. Liefert die neue Projekt-ID.
#[allow(clippy::too_many_arguments)]
pub(crate) fn create_project_full_in(
paths: &Paths,
@@ -191,14 +241,14 @@ pub(crate) fn create_project_full_in(
create_work_dir: bool,
terminal: TerminalConfig,
todo: bool,
) -> Result<(), String> {
) -> Result<String, String> {
check_name(name)?;
let dir = match dir {
Some(d) => expand_home(paths, d),
None => paths.projects_dir().join(name),
};
let mut reg = load_registry(paths)?;
if reg.contains_key(name) {
if list_projects_in(paths)?.iter().any(|p| p.name == name) {
return Err(format!("Projekt existiert bereits: {name}"));
}
if dir.exists() {
@@ -245,21 +295,22 @@ pub(crate) fn create_project_full_in(
fs::write(dir.join(".claude").join("settings.json"), raw + "\n")
.map_err(|e| format!("{}: {e}", dir.join(".claude").join("settings.json").display()))?;
reg.insert(name.to_string(), dir.clone());
let id = uuid::Uuid::new_v4().to_string();
reg.insert(id.clone(), RegEntry { dir: dir.clone(), pool: pool.map(str::to_string) });
save_registry(paths, &reg)?;
let cfg = ProjectConfig {
pool: pool.map(str::to_string),
id: Some(id.clone()),
name: Some(name.to_string()),
terminal,
..ProjectConfig::default()
archive_home: None,
rest: Default::default(),
};
if cfg.pool.is_some() || !cfg.terminal.is_empty() {
write_project_config_in(paths, name, &cfg)?;
}
write_project_config_in(paths, &id, &cfg)?;
if todo {
crate::domain::todo::set_todo_in(paths, name, true)?;
crate::domain::todo::set_todo_in(paths, &id, true)?;
}
crate::platform::write_terminal_desktop(paths, name, &cfg.terminal);
Ok(())
crate::platform::write_terminal_desktop(paths, &id, &cfg);
Ok(id)
}
/// Verlegt den Projektordner: Registry-Eintrag auf den neuen Pfad; Verweise
@@ -273,14 +324,14 @@ pub(crate) fn set_project_dir_in(paths: &Paths, name: &str, dir: &str) -> Result
return Err(format!("Ordner nicht gefunden: {}", new_dir.display()));
}
let mut reg = load_registry(paths)?;
let old_dir = reg
.get(name)
.cloned()
let entry = reg
.get_mut(name)
.ok_or_else(|| format!("Projekt nicht registriert: {name}"))?;
let old_dir = entry.dir.clone();
if old_dir == new_dir {
return Ok(());
}
reg.insert(name.to_string(), new_dir.clone());
entry.dir = new_dir.clone();
save_registry(paths, &reg)?;
let sp = settings_path(&new_dir);
@@ -309,23 +360,51 @@ pub(crate) fn set_project_dir_in(paths: &Paths, name: &str, dir: &str) -> Result
fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
}
/// Nimmt einen bestehenden Ordner als Projekt auf; der Name ist der Ordnername.
/// Nimmt einen bestehenden Ordner als Projekt auf. Eine mitgebrachte
/// .ai-control/config.json liefert ID und Name (Sync-Fall: dasselbe Projekt
/// behält seine ID auf jeder Maschine); ohne Config entsteht eine neue ID mit
/// dem Ordnernamen als Name. Mitgebrachte Pfade werden geprüft und angelegt:
/// Arbeitsordner aus der settings.json sowie das Archiv-Home samt
/// Permissions-Eintrag.
pub(crate) fn add_project_in(paths: &Paths, path: &str) -> Result<(), String> {
let dir = expand_home(paths, path);
if !dir.is_dir() {
return Err(format!("Ordner nicht gefunden: {}", dir.display()));
}
let name = dir
let dirname = dir
.file_name()
.ok_or_else(|| format!("kein Ordnername: {}", dir.display()))?
.to_string_lossy()
.into_owned();
check_name(&name)?;
register_project(paths, &name, &dir)?;
let cfg = read_project_config_in(paths, &name)
.map(|c| c.terminal)
.unwrap_or_default();
crate::platform::write_terminal_desktop(paths, &name, &cfg);
check_name(&dirname)?;
let cfg_path = dir.join(PROJECT_CONFIG_DIR).join(PROJECT_FILE);
let mut cfg: ProjectConfig = if cfg_path.is_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()))?
} else {
ProjectConfig::default()
};
if let Some(id) = cfg.id.as_deref() {
if load_registry(paths)?.contains_key(id) {
return Err(format!("Projekt ist schon registriert: {id}"));
}
}
let id = cfg.id.get_or_insert_with(|| uuid::Uuid::new_v4().to_string()).clone();
cfg.name.get_or_insert(dirname);
register_project(paths, &id, &dir)?;
write_project_config_in(paths, &id, &cfg)?;
for wd in project_work_dirs_in(paths, &id)? {
let wd_path = expand_home(paths, &wd);
fs::create_dir_all(&wd_path).map_err(|e| format!("{}: {e}", wd_path.display()))?;
}
if let Some(archive) = cfg.archive_home.as_deref() {
let expanded = expand_home(paths, archive);
fs::create_dir_all(&expanded).map_err(|e| format!("{}: {e}", expanded.display()))?;
crate::domain::archive::add_archive_permission(paths, &id, archive)?;
}
crate::platform::write_terminal_desktop(paths, &id, &cfg);
Ok(())
}
@@ -449,21 +528,15 @@ pub(crate) fn assign_pool_in(paths: &Paths, project: &str, pool: &str) -> Result
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)
set_project_pool(paths, project, Some(pool))
}
/// Nimmt den Pool raus; die Config-Datei bleibt nur, wenn sie noch
/// Terminal-Einstellungen trägt.
/// Nimmt die Pool-Zuordnung aus der Registry.
pub(crate) 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() && cfg.archive_home.is_none() {
let cfg_path = project_config_path(paths, project)?;
return fs::remove_file(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()));
if project_pool(paths, project)?.is_none() {
return Err(format!("kein Pool zugeordnet: {project}"));
}
write_project_config_in(paths, project, &cfg)
set_project_pool(paths, project, None)
}
pub(crate) fn set_terminal_config_in(
@@ -472,8 +545,8 @@ pub(crate) fn set_terminal_config_in(
mut terminal: TerminalConfig,
) -> Result<(), String> {
let mut cfg = read_project_config_in(paths, project)?;
// Absolut gewählte Icons ins gemeinsame Icons-Verzeichnis kopieren und als
// Dateiname speichern — Icons gehören zur App-Config, nicht ins Quell-Repo.
// Absolut gewählte Icons in den Projekt-Config-Ordner kopieren und als
// Dateiname speichern — das Icon synct damit mit dem Projekt.
if let Some(icon) = terminal.icon.as_deref() {
if icon.starts_with('/') {
let src = PathBuf::from(icon);
@@ -482,10 +555,10 @@ pub(crate) fn set_terminal_config_in(
.and_then(|e| e.to_str())
.unwrap_or("png")
.to_lowercase();
let name = format!("{project}.{ext}");
let icons = paths.icons_dir();
fs::create_dir_all(&icons).map_err(|e| format!("{}: {e}", icons.display()))?;
let dest = icons.join(&name);
let name = format!("icon.{ext}");
let cfg_dir = project_dir(paths, project)?.join(PROJECT_CONFIG_DIR);
fs::create_dir_all(&cfg_dir).map_err(|e| format!("{}: {e}", cfg_dir.display()))?;
let dest = cfg_dir.join(&name);
if src != dest {
fs::copy(&src, &dest).map_err(|e| format!("{}: {e}", src.display()))?;
}
@@ -497,67 +570,126 @@ pub(crate) fn set_terminal_config_in(
terminal.rest = std::mem::take(&mut cfg.terminal.rest);
cfg.terminal = terminal;
write_project_config_in(paths, project, &cfg)?;
crate::platform::write_terminal_desktop(paths, project, &cfg.terminal);
crate::platform::write_terminal_desktop(paths, project, &cfg);
Ok(())
}
/// Icon-Pfad einer Projekt-Config auflösen: relative Namen liegen im
/// gemeinsamen Icons-Verzeichnis der App.
pub(crate) fn resolve_icon_path(paths: &Paths, icon: &str) -> PathBuf {
/// Projekt-Config-Ordner (.ai-control) des Projekts.
pub(crate) fn resolve_icon_path(
paths: &Paths,
project: &str,
icon: &str,
) -> Result<PathBuf, String> {
if icon.starts_with('/') {
PathBuf::from(icon)
Ok(PathBuf::from(icon))
} else {
paths.icons_dir().join(icon)
Ok(project_dir(paths, project)?.join(PROJECT_CONFIG_DIR).join(icon))
}
}
/// Migration beim App-Start (idempotent): ai-control.json aus dem Projekt-Root
/// nach .ai-control/config.json, die Pool-Zuordnung daraus in die Registry,
/// Projekt-Icons aus dem zentralen Icons-Verzeichnis in den Projekt-Config-Ordner.
/// Namens-Schlüssel der Registry werden auf die Projekt-UUID umgestellt; fehlt
/// die in der config.json, entsteht sie hier (Name = bisheriger Schlüssel).
pub(crate) fn migrate_layout_in(paths: &Paths) -> Result<(), String> {
let reg = load_registry(paths)?;
let mut new_reg: BTreeMap<String, RegEntry> = BTreeMap::new();
let mut reg_dirty = false;
for (key, mut entry) in reg {
let legacy = entry.dir.join(LEGACY_PROJECT_FILE);
if legacy.is_file() {
let raw = fs::read_to_string(&legacy).map_err(|e| format!("{}: {e}", legacy.display()))?;
let mut v: serde_json::Value =
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", legacy.display()))?;
let obj = v.as_object_mut().ok_or_else(|| format!("{}: kein Objekt", legacy.display()))?;
if let Some(pool) = obj.remove("pool").and_then(|p| p.as_str().map(str::to_string)) {
if entry.pool.is_none() {
entry.pool = Some(pool);
reg_dirty = true;
}
}
let cfg_dir = entry.dir.join(PROJECT_CONFIG_DIR);
let cfg_path = cfg_dir.join(PROJECT_FILE);
if !cfg_path.exists() && !obj.is_empty() {
fs::create_dir_all(&cfg_dir).map_err(|e| format!("{}: {e}", cfg_dir.display()))?;
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
fs::write(&cfg_path, raw + "\n").map_err(|e| format!("{}: {e}", cfg_path.display()))?;
}
fs::remove_file(&legacy).map_err(|e| format!("{}: {e}", legacy.display()))?;
}
// Config lesen; ID und Name sicherstellen. Der bisherige Registry-Schlüssel
// war der Anzeigename.
let cfg_dir = entry.dir.join(PROJECT_CONFIG_DIR);
let cfg_path = cfg_dir.join(PROJECT_FILE);
let mut cfg: ProjectConfig = if cfg_path.is_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()))?
} else {
ProjectConfig::default()
};
let mut cfg_dirty = cfg.id.is_none() || cfg.name.is_none();
let id = cfg.id.get_or_insert_with(|| uuid::Uuid::new_v4().to_string()).clone();
cfg.name.get_or_insert_with(|| key.clone());
// Icon aus ~/.config/ai-control/icons/<name> in den Projekt-Config-Ordner
// (Kopie + Löschen statt rename — Icons-Verzeichnis und Projekt können auf
// verschiedenen Dateisystemen liegen).
if let Some(icon) = cfg.terminal.icon.clone() {
if !icon.starts_with('/') {
let old = paths.icons_dir().join(&icon);
if old.is_file() {
let ext = std::path::Path::new(&icon)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("png")
.to_lowercase();
let name = format!("icon.{ext}");
fs::create_dir_all(&cfg_dir).map_err(|e| format!("{}: {e}", cfg_dir.display()))?;
fs::copy(&old, cfg_dir.join(&name)).map_err(|e| format!("{}: {e}", old.display()))?;
fs::remove_file(&old).map_err(|e| format!("{}: {e}", old.display()))?;
cfg.terminal.icon = Some(name);
cfg_dirty = true;
}
}
}
if cfg_dirty {
fs::create_dir_all(&cfg_dir).map_err(|e| format!("{}: {e}", cfg_dir.display()))?;
let raw = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;
fs::write(&cfg_path, raw + "\n").map_err(|e| format!("{}: {e}", cfg_path.display()))?;
}
if id != key {
reg_dirty = true;
}
new_reg.insert(id, entry);
}
if reg_dirty {
save_registry(paths, &new_reg)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::testutil::{create_project, make_apikey_pool, make_oauth_pool, map_store, tmp_paths};
use crate::domain::todo::TODO_FILE;
/// Der read-modify-write darf keine Keys verlieren, die dieser Build nicht
/// kennt. Genau daran ging `archiveHome` verloren: Ein älterer Build ohne das
/// Feld hat bei der Pool-Zuweisung die ganze Datei neu geschrieben.
/// Ein Pool-Eintrag mit Pfad-Bestandteilen — etwa per Migration aus der
/// mitgeklonten Alt-Datei in die Registry gelangt — darf kein beliebiges
/// Verzeichnis zum CLAUDE_CONFIG_DIR machen.
#[test]
fn pool_zuweisen_erhaelt_fremde_keys() {
let p = tmp_paths();
let pool = make_apikey_pool(&p, &map_store(), "kunde", "sk-1");
create_project(&p, "proj").unwrap();
let cfg_path = project_config_path(&p, "proj").unwrap();
fs::write(
&cfg_path,
r#"{"archiveHome":"~/archiv","zukunftsfeld":{"a":1},
"terminal":{"theme":"monokai","fontSize":13}}"#,
)
.unwrap();
assign_pool_in(&p, "proj", &pool).unwrap();
let raw = fs::read_to_string(&cfg_path).unwrap();
let v: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(v["archiveHome"], "~/archiv");
assert_eq!(v["zukunftsfeld"]["a"], 1);
assert_eq!(v["terminal"]["theme"], "monokai");
// Auch im verschachtelten terminal-Block, der als Ganzes mitgeschrieben wird.
assert_eq!(v["terminal"]["fontSize"], 13);
assert_eq!(v["pool"], serde_json::Value::String(pool));
}
/// Ein Pool-Eintrag aus einer mitgeklonten `ai-control.json` darf kein
/// beliebiges Verzeichnis zum CLAUDE_CONFIG_DIR machen.
#[test]
fn pool_aus_projektdatei_mit_traversal_wird_abgelehnt() {
fn pool_mit_traversal_wird_abgelehnt() {
let p = tmp_paths();
create_project(&p, "fremd").unwrap();
let cfg_path = project_config_path(&p, "fremd").unwrap();
fs::write(&cfg_path, r#"{"pool":"../../../../tmp/x"}"#).unwrap();
crate::domain::registry::set_project_pool(&p, "fremd", Some("../../../../tmp/x")).unwrap();
let cfg = read_project_config_in(&p, "fremd").unwrap();
assert_eq!(cfg.pool.as_deref(), Some("../../../../tmp/x"));
// Gelesen wird der Wert noch, aber er darf keinen Pfad bilden.
assert!(check_name(cfg.pool.as_deref().unwrap()).is_err());
let err = project_pool_dir_in(&p, "fremd").unwrap_err();
assert!(err.contains("ungültiger Pool"));
}
/// Das Setzen der Terminal-Config kommt aus der Oberfläche und kennt nur
@@ -586,7 +718,7 @@ mod tests {
fn projekt_wizard_scaffold() {
let p = tmp_paths();
let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1");
create_project_full_in(
let id = create_project_full_in(
&p,
"neu",
None,
@@ -610,10 +742,17 @@ mod tests {
);
assert!(p.home.join("projects").join("neu").is_dir());
let cfg = read_project_config_in(&p, "neu").unwrap();
assert_eq!(cfg.pool.as_deref(), Some(kunde.as_str()));
// ID ist UUID und Registry-Schlüssel; Pool liegt in der Registry.
assert!(uuid::Uuid::parse_str(&id).is_ok());
assert_eq!(project_dir(&p, &id).unwrap(), dir);
assert_eq!(project_pool(&p, &id).unwrap().as_deref(), Some(kunde.as_str()));
let cfg = read_project_config_in(&p, &id).unwrap();
assert_eq!(cfg.id.as_deref(), Some(id.as_str()));
assert_eq!(cfg.name.as_deref(), Some("neu"));
assert_eq!(cfg.terminal.title.as_deref(), Some("Neu"));
assert_eq!(cfg.terminal.theme.as_deref(), Some("dracula"));
// Registry-Eintrag und Ordner passen zusammen.
assert_eq!(verify_project_dir_in(&p, &id).unwrap(), dir);
let settings: serde_json::Value = serde_json::from_str(
&fs::read_to_string(dir.join(".claude").join("settings.json")).unwrap(),
@@ -639,11 +778,19 @@ mod tests {
#[test]
fn projekt_wizard_minimal_ohne_pool_und_workdir() {
let p = tmp_paths();
create_project_full_in(&p, "neu", None, None, None, false, TerminalConfig::default(), false).unwrap();
let id =
create_project_full_in(&p, "neu", None, None, None, false, TerminalConfig::default(), false)
.unwrap();
let dir = p.projects_dir().join("neu");
assert!(dir.join(".claude").join("settings.json").is_file());
// ohne Pool und Terminal-Config entsteht keine ai-control.json
assert!(!project_config_path(&p, "neu").unwrap().is_file());
// config.json entsteht immer — sie trägt die Projekt-Identität.
let v: serde_json::Value = serde_json::from_str(
&fs::read_to_string(project_config_path(&p, &id).unwrap()).unwrap(),
)
.unwrap();
assert_eq!(v["id"], id.as_str());
assert_eq!(v["name"], "neu");
assert!(v.get("terminal").is_none());
let settings: serde_json::Value = serde_json::from_str(
&fs::read_to_string(dir.join(".claude").join("settings.json")).unwrap(),
)
@@ -653,7 +800,7 @@ mod tests {
}
#[test]
fn projekt_wizard_doppelt_scheitert() {
fn projekt_wizard_doppelter_name_scheitert() {
let p = tmp_paths();
create_project(&p, "neu").unwrap();
let err =
@@ -690,10 +837,10 @@ mod tests {
create_project(&p, "proj").unwrap();
assign_pool_in(&p, "proj", &kunde).unwrap();
let cfg: ProjectConfig =
serde_json::from_str(&fs::read_to_string(project_config_path(&p, "proj").unwrap()).unwrap())
.unwrap();
assert_eq!(cfg.pool.as_deref(), Some(kunde.as_str()));
assert_eq!(project_pool(&p, "proj").unwrap().as_deref(), Some(kunde.as_str()));
// die Zuordnung ist maschinenlokal — die Projekt-Config bleibt ohne pool-Key
let raw = fs::read_to_string(project_config_path(&p, "proj").unwrap()).unwrap();
assert!(!raw.contains("pool"));
let pools =
crate::domain::pool::list_pools_in(&p, &crate::domain::testutil::FailStore).unwrap();
@@ -714,7 +861,7 @@ mod tests {
create_project(&p, "proj").unwrap();
assign_pool_in(&p, "proj", &kunde).unwrap();
unassign_pool_in(&p, "proj").unwrap();
assert!(!project_config_path(&p, "proj").unwrap().exists());
assert_eq!(project_pool(&p, "proj").unwrap(), None);
}
#[test]
@@ -736,12 +883,154 @@ mod tests {
assign_pool_in(&p, "proj", &privat).unwrap();
assign_pool_in(&p, "proj", &kunde).unwrap();
let raw = fs::read_to_string(project_config_path(&p, "proj").unwrap()).unwrap();
let cfg: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(cfg, serde_json::json!({ "pool": kunde }));
assert_eq!(project_pool(&p, "proj").unwrap().as_deref(), Some(kunde.as_str()));
}
// -- Projekt anlegen / löschen --
// -- Projekt-Config: Identität, Round-Trip, Icon --
#[test]
fn verify_erkennt_fremden_ordner() {
let p = tmp_paths();
create_project(&p, "proj").unwrap();
assert!(verify_project_dir_in(&p, "proj").is_ok());
// Ordner „ersetzt": config.json trägt eine andere ID.
fs::write(
p.projects_dir().join("proj").join(PROJECT_CONFIG_DIR).join(PROJECT_FILE),
r#"{"id": "anderes-projekt", "name": "proj"}"#,
)
.unwrap();
let err = verify_project_dir_in(&p, "proj").unwrap_err();
assert!(err.contains("passt nicht zur Registry"));
}
/// Der Config-Round-Trip (lesen, Feld ändern, neu schreiben) reicht Keys
/// durch, die diese Build-Version nicht kennt.
#[test]
fn config_roundtrip_erhaelt_fremde_keys() {
let p = tmp_paths();
create_project(&p, "proj").unwrap();
let cfg_dir = p.projects_dir().join("proj").join(PROJECT_CONFIG_DIR);
fs::write(
cfg_dir.join(PROJECT_FILE),
r#"{"id": "proj", "name": "proj", "archiveHome": "~/archiv", "kuenftig": {"x": 1}}"#,
)
.unwrap();
set_terminal_config_in(
&p,
"proj",
TerminalConfig { theme: Some("dracula".into()), ..Default::default() },
)
.unwrap();
let v: serde_json::Value =
serde_json::from_str(&fs::read_to_string(cfg_dir.join(PROJECT_FILE)).unwrap()).unwrap();
assert_eq!(v["kuenftig"]["x"], 1);
assert_eq!(v["archiveHome"], "~/archiv");
assert_eq!(v["terminal"]["theme"], "dracula");
assert_eq!(v["id"], "proj");
}
#[test]
fn icon_wandert_in_den_projekt_config_ordner() {
let p = tmp_paths();
create_project(&p, "proj").unwrap();
let src = p.home.join("bild.PNG");
fs::write(&src, b"png").unwrap();
set_terminal_config_in(
&p,
"proj",
TerminalConfig { icon: Some(src.display().to_string()), ..Default::default() },
)
.unwrap();
let cfg = read_project_config_in(&p, "proj").unwrap();
assert_eq!(cfg.terminal.icon.as_deref(), Some("icon.png"));
let dest = p.projects_dir().join("proj").join(PROJECT_CONFIG_DIR).join("icon.png");
assert!(dest.is_file());
assert_eq!(resolve_icon_path(&p, "proj", "icon.png").unwrap(), dest);
}
// -- Migration Alt-Layout --
/// Projekt im Alt-Layout: Registry-Schlüssel = Name, ai-control.json im
/// Projekt-Root, Icon im zentralen Icons-Verzeichnis, keine .ai-control/.
fn create_legacy_project(p: &Paths, name: &str) -> PathBuf {
let dir = p.projects_dir().join(name);
fs::create_dir_all(dir.join(".claude")).unwrap();
register_project(p, name, &dir).unwrap();
dir
}
#[test]
fn migration_altes_layout() {
let p = tmp_paths();
let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1");
let dir = create_legacy_project(&p, "proj");
fs::write(
dir.join(LEGACY_PROJECT_FILE),
format!(
r#"{{"pool": "{kunde}", "terminal": {{"icon": "proj.png"}}, "archiveHome": "~/archiv"}}"#
),
)
.unwrap();
fs::create_dir_all(p.icons_dir()).unwrap();
fs::write(p.icons_dir().join("proj.png"), b"png").unwrap();
migrate_layout_in(&p).unwrap();
// Registry-Schlüssel ist jetzt die UUID aus der config.json.
let reg = load_registry(&p).unwrap();
assert_eq!(reg.len(), 1);
let id = reg.keys().next().unwrap().clone();
assert!(uuid::Uuid::parse_str(&id).is_ok());
assert_eq!(project_pool(&p, &id).unwrap().as_deref(), Some(kunde.as_str()));
assert!(!dir.join(LEGACY_PROJECT_FILE).exists());
let cfg = read_project_config_in(&p, &id).unwrap();
assert_eq!(cfg.id.as_deref(), Some(id.as_str()));
assert_eq!(cfg.name.as_deref(), Some("proj"));
assert_eq!(cfg.archive_home.as_deref(), Some("~/archiv"));
// Icon liegt jetzt im Projekt, das zentrale Verzeichnis ist geräumt.
assert_eq!(cfg.terminal.icon.as_deref(), Some("icon.png"));
assert!(dir.join(PROJECT_CONFIG_DIR).join("icon.png").is_file());
assert!(!p.icons_dir().join("proj.png").exists());
assert_eq!(verify_project_dir_in(&p, &id).unwrap(), dir);
// Zweiter Lauf ändert nichts mehr.
migrate_layout_in(&p).unwrap();
let reg2 = load_registry(&p).unwrap();
assert_eq!(reg2.keys().collect::<Vec<_>>(), vec![&id]);
}
#[test]
fn migration_vergibt_id_auch_ohne_altdatei() {
let p = tmp_paths();
let dir = create_legacy_project(&p, "proj");
migrate_layout_in(&p).unwrap();
let reg = load_registry(&p).unwrap();
let id = reg.keys().next().unwrap().clone();
assert!(uuid::Uuid::parse_str(&id).is_ok());
let cfg = read_project_config_in(&p, &id).unwrap();
assert_eq!(cfg.name.as_deref(), Some("proj"));
assert_eq!(verify_project_dir_in(&p, &id).unwrap(), dir);
}
#[test]
fn migration_migriertes_projekt_bleibt_unveraendert() {
let p = tmp_paths();
create_project(&p, "proj").unwrap();
let before = fs::read_to_string(project_config_path(&p, "proj").unwrap()).unwrap();
migrate_layout_in(&p).unwrap();
assert_eq!(
fs::read_to_string(project_config_path(&p, "proj").unwrap()).unwrap(),
before
);
assert!(load_registry(&p).unwrap().contains_key("proj"));
}
// -- Projekt anlegen / importieren / löschen --
#[test]
fn projekt_anlegen() {
@@ -751,10 +1040,62 @@ mod tests {
let projects = list_projects_in(&p).unwrap();
assert_eq!(projects.len(), 1);
assert_eq!(projects[0].id, "proj");
assert_eq!(projects[0].name, "proj");
assert_eq!(projects[0].pool, None);
}
/// Import eines Ordners mit mitgebrachter Config: die ID wird übernommen
/// (Sync-Fall), Arbeitsordner und Archiv-Home werden angelegt, die
/// Archiv-Permission nachgezogen.
#[test]
fn projekt_import_uebernimmt_id_und_legt_pfade_an() {
let p = tmp_paths();
let dir = p.home.join("import").join("proj");
fs::create_dir_all(dir.join(PROJECT_CONFIG_DIR)).unwrap();
fs::write(
dir.join(PROJECT_CONFIG_DIR).join(PROJECT_FILE),
r#"{"id": "sync-id", "name": "Mein Projekt", "archiveHome": "~/archiv/proj"}"#,
)
.unwrap();
fs::create_dir_all(dir.join(".claude")).unwrap();
fs::write(
settings_path(&dir),
r#"{"permissions": {"additionalDirectories": ["~/work/proj"]}}"#,
)
.unwrap();
add_project_in(&p, &dir.display().to_string()).unwrap();
assert_eq!(project_dir(&p, "sync-id").unwrap(), dir);
assert_eq!(display_name_in(&p, "sync-id").unwrap(), "Mein Projekt");
assert!(p.home.join("work/proj").is_dir());
assert!(p.home.join("archiv/proj").is_dir());
let raw = fs::read_to_string(settings_path(&dir)).unwrap();
assert!(raw.contains("~/archiv/proj"));
assert!(raw.contains("Edit(~/archiv/proj/**)"));
// Doppelt aufnehmen scheitert an der ID.
let err = add_project_in(&p, &dir.display().to_string()).unwrap_err();
assert!(err.contains("schon registriert"));
}
/// Import ohne mitgebrachte Config: neue UUID, Ordnername wird Anzeigename.
#[test]
fn projekt_import_ohne_config_erzeugt_id() {
let p = tmp_paths();
let dir = p.home.join("import").join("roh");
fs::create_dir_all(&dir).unwrap();
add_project_in(&p, &dir.display().to_string()).unwrap();
let reg = load_registry(&p).unwrap();
let id = reg.keys().next().unwrap().clone();
assert!(uuid::Uuid::parse_str(&id).is_ok());
assert_eq!(display_name_in(&p, &id).unwrap(), "roh");
assert_eq!(verify_project_dir_in(&p, &id).unwrap(), dir);
}
#[test]
fn projekt_doppelt_anlegen_scheitert() {
let p = tmp_paths();
@@ -773,9 +1114,9 @@ mod tests {
#[test]
fn projekt_loeschen_laesst_arbeitsordner() {
let p = tmp_paths();
create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
let id = create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
.unwrap();
delete_project_in(&p, "proj", false).unwrap();
delete_project_in(&p, &id, false).unwrap();
assert!(!p.projects_dir().join("proj").exists());
assert!(p.home.join("projects").join("proj").is_dir());
}
@@ -783,9 +1124,9 @@ mod tests {
#[test]
fn projekt_loeschen_mit_arbeitsordner() {
let p = tmp_paths();
create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
let id = create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
.unwrap();
delete_project_in(&p, "proj", true).unwrap();
delete_project_in(&p, &id, true).unwrap();
assert!(!p.projects_dir().join("proj").exists());
assert!(!p.home.join("projects").join("proj").exists());
}
@@ -793,39 +1134,46 @@ mod tests {
#[test]
fn projekt_loeschen_fehlender_arbeitsordner_scheitert() {
let p = tmp_paths();
create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
let id = create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
.unwrap();
fs::remove_dir_all(p.home.join("projects").join("proj")).unwrap();
assert!(delete_project_in(&p, "proj", true).is_err());
assert!(delete_project_in(&p, &id, true).is_err());
}
#[test]
fn projekt_arbeitsordner_auslesen() {
let p = tmp_paths();
create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
let id = create_project_full_in(&p, "proj", None, None, Some("~/projects/proj"), true, TerminalConfig::default(), false)
.unwrap();
assert_eq!(project_work_dirs_in(&p, "proj").unwrap(), vec!["~/projects/proj"]);
assert_eq!(project_work_dirs_in(&p, &id).unwrap(), vec!["~/projects/proj"]);
// Projekt ohne settings.json → leer
create_project(&p, "alt").unwrap();
assert!(project_work_dirs_in(&p, "alt").unwrap().is_empty());
let dir = p.home.join("ohne-settings");
fs::create_dir_all(&dir).unwrap();
add_project_in(&p, &dir.display().to_string()).unwrap();
let alt = list_projects_in(&p)
.unwrap()
.into_iter()
.find(|pr| pr.name == "ohne-settings")
.unwrap();
assert!(project_work_dirs_in(&p, &alt.id).unwrap().is_empty());
}
#[test]
fn arbeitsordner_nachtraeglich_erfassen_und_entfernen() {
let p = tmp_paths();
create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false)
let id = create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false)
.unwrap();
fs::create_dir_all(p.home.join("projects/extra")).unwrap();
let picked = p.home.join("projects/extra").to_string_lossy().into_owned();
add_work_dir_in(&p, "proj", &picked).unwrap();
add_work_dir_in(&p, &id, &picked).unwrap();
// gespeichert wird ~-kontrahiert, in beiden permissions-Feldern
assert_eq!(project_work_dirs_in(&p, "proj").unwrap(), vec!["~/projects/extra"]);
assert_eq!(project_work_dirs_in(&p, &id).unwrap(), vec!["~/projects/extra"]);
let raw = fs::read_to_string(settings_path(&p.projects_dir().join("proj"))).unwrap();
assert!(raw.contains("Edit(~/projects/extra/**)"));
assert!(add_work_dir_in(&p, "proj", &picked).is_err()); // doppelt
assert!(add_work_dir_in(&p, &id, &picked).is_err()); // doppelt
remove_work_dir_in(&p, "proj", "~/projects/extra").unwrap();
assert!(project_work_dirs_in(&p, "proj").unwrap().is_empty());
remove_work_dir_in(&p, &id, "~/projects/extra").unwrap();
assert!(project_work_dirs_in(&p, &id).unwrap().is_empty());
let raw = fs::read_to_string(settings_path(&p.projects_dir().join("proj"))).unwrap();
assert!(!raw.contains("~/projects/extra"));
// Ordner selbst bleibt
@@ -835,15 +1183,17 @@ mod tests {
#[test]
fn projektordner_verlegen() {
let p = tmp_paths();
create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false)
let id = create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false)
.unwrap();
// Nutzer hat den Ordner selbst verschoben; die App ordnet nur neu zu.
let new_dir = p.home.join("elsewhere").join("proj");
fs::create_dir_all(p.home.join("elsewhere")).unwrap();
fs::rename(p.projects_dir().join("proj"), &new_dir).unwrap();
set_project_dir_in(&p, "proj", &new_dir.to_string_lossy()).unwrap();
set_project_dir_in(&p, &id, &new_dir.to_string_lossy()).unwrap();
assert_eq!(project_dir(&p, "proj").unwrap(), new_dir);
assert_eq!(project_dir(&p, &id).unwrap(), new_dir);
// Die config.json zieht mit — Verifikation greift am neuen Ort.
assert_eq!(verify_project_dir_in(&p, &id).unwrap(), new_dir);
let raw = fs::read_to_string(settings_path(&new_dir)).unwrap();
assert!(raw.contains("~/elsewhere/proj/memory"));
assert!(raw.contains("Edit(~/elsewhere/proj/**)"));
+78 -15
View File
@@ -1,26 +1,53 @@
//! Projekt-Registry: projects.json (Name → Ordner) unter ~/.config/ai-control.
//! Projekt-Registry: projects.json (Projekt-ID → Ordner + Pool) unter
//! ~/.config/ai-control. Schlüssel ist die Projekt-UUID aus der
//! .ai-control/config.json des Projekts; die Pool-Zuordnung ist
//! maschinenlokal und lebt deshalb hier, nicht in der syncbaren
//! Projekt-Config.
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs;
use std::path::PathBuf;
use crate::domain::paths::{contract_home, expand_home, Paths};
/// Registry Name → Projektordner; ohne projects.json gibt es keine Projekte.
pub(crate) fn load_registry(paths: &Paths) -> Result<BTreeMap<String, PathBuf>, String> {
/// Registry-Eintrag eines Projekts: Ordner + Pool-Zuordnung dieser Maschine.
pub(crate) struct RegEntry {
pub(crate) dir: PathBuf,
pub(crate) pool: Option<String>,
}
/// Dateiformat: Alt-Einträge sind reine Pfad-Strings, neue Einträge Objekte
/// mit path + pool. Geschrieben wird der String, solange kein Pool gesetzt ist.
#[derive(Serialize, Deserialize)]
#[serde(untagged)]
enum RawEntry {
Path(String),
Full {
path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pool: Option<String>,
},
}
/// Registry Projekt-ID → Eintrag; ohne projects.json gibt es keine Projekte.
pub(crate) fn load_registry(paths: &Paths) -> Result<BTreeMap<String, RegEntry>, String> {
let file = paths.projects_file();
if !file.is_file() {
return Ok(BTreeMap::new());
}
let raw = fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))?;
let map: BTreeMap<String, String> =
let map: BTreeMap<String, RawEntry> =
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", file.display()))?;
Ok(
map
.into_iter()
.map(|(name, p)| {
let dir = expand_home(paths, &p);
(name, dir)
.map(|(name, e)| {
let (path, pool) = match e {
RawEntry::Path(p) => (p, None),
RawEntry::Full { path, pool } => (path, pool),
};
(name, RegEntry { dir: expand_home(paths, &path), pool })
})
.collect(),
)
@@ -28,11 +55,20 @@ pub(crate) fn load_registry(paths: &Paths) -> Result<BTreeMap<String, PathBuf>,
pub(crate) fn save_registry(
paths: &Paths,
reg: &BTreeMap<String, PathBuf>,
reg: &BTreeMap<String, RegEntry>,
) -> Result<(), String> {
let contracted: BTreeMap<&String, String> =
reg.iter().map(|(name, dir)| (name, contract_home(paths, dir))).collect();
let raw = serde_json::to_string_pretty(&contracted).map_err(|e| e.to_string())?;
let raw_map: BTreeMap<&String, RawEntry> = reg
.iter()
.map(|(name, e)| {
let path = contract_home(paths, &e.dir);
let raw = match &e.pool {
None => RawEntry::Path(path),
Some(pool) => RawEntry::Full { path, pool: Some(pool.clone()) },
};
(name, raw)
})
.collect();
let raw = serde_json::to_string_pretty(&raw_map).map_err(|e| e.to_string())?;
fs::create_dir_all(paths.config_dir())
.map_err(|e| format!("{}: {e}", paths.config_dir().display()))?;
let file = paths.projects_file();
@@ -41,9 +77,36 @@ pub(crate) fn save_registry(
/// Ordner eines registrierten Projekts.
pub(crate) fn project_dir(paths: &Paths, name: &str) -> Result<PathBuf, String> {
load_registry(paths)?
.remove(name)
.ok_or_else(|| format!("Projekt nicht registriert: {name}"))
Ok(
load_registry(paths)?
.remove(name)
.ok_or_else(|| format!("Projekt nicht registriert: {name}"))?
.dir,
)
}
/// Pool-Zuordnung eines registrierten Projekts (maschinenlokal).
pub(crate) fn project_pool(paths: &Paths, name: &str) -> Result<Option<String>, String> {
Ok(
load_registry(paths)?
.remove(name)
.ok_or_else(|| format!("Projekt nicht registriert: {name}"))?
.pool,
)
}
/// Setzt bzw. löscht die Pool-Zuordnung eines Projekts in der Registry.
pub(crate) fn set_project_pool(
paths: &Paths,
name: &str,
pool: Option<&str>,
) -> Result<(), String> {
let mut reg = load_registry(paths)?;
let entry = reg
.get_mut(name)
.ok_or_else(|| format!("Projekt nicht registriert: {name}"))?;
entry.pool = pool.map(str::to_string);
save_registry(paths, &reg)
}
/// Nimmt ein Projekt in die Registry auf.
@@ -56,7 +119,7 @@ pub(crate) fn register_project(
if reg.contains_key(name) {
return Err(format!("Projekt existiert bereits: {name}"));
}
reg.insert(name.to_string(), dir.to_path_buf());
reg.insert(name.to_string(), RegEntry { dir: dir.to_path_buf(), pool: None });
save_registry(paths, &reg)
}
+10 -1
View File
@@ -75,7 +75,9 @@ pub(crate) fn make_apikey_pool(
create_apikey_pool_in(p, store, name, key, true).unwrap()
}
/// Minimal-Projekt: Ordner mit .claude/ + Registry-Eintrag.
/// Minimal-Projekt: Ordner mit .claude/, .ai-control/config.json und
/// Registry-Eintrag. Die Projekt-ID ist der Name — Tests adressieren das
/// Projekt damit direkt, ohne UUID-Variablen durchzureichen.
pub(crate) fn create_project(paths: &Paths, name: &str) -> Result<(), String> {
crate::domain::check_name(name)?;
let dir = paths.projects_dir().join(name);
@@ -84,6 +86,13 @@ pub(crate) fn create_project(paths: &Paths, name: &str) -> Result<(), String> {
}
fs::create_dir_all(dir.join(".claude"))
.map_err(|e| format!("{}: {e}", dir.join(".claude").display()))?;
fs::create_dir_all(dir.join(".ai-control"))
.map_err(|e| format!("{}: {e}", dir.join(".ai-control").display()))?;
fs::write(
dir.join(".ai-control").join("config.json"),
format!("{{\"id\": \"{name}\", \"name\": \"{name}\"}}\n"),
)
.map_err(|e| e.to_string())?;
register_project(paths, name, &dir)
}
+9 -8
View File
@@ -129,17 +129,18 @@ mod tests {
#[test]
fn todo_zuschalten_und_abschalten() {
let p = tmp_paths();
create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false)
.unwrap();
assert!(!todo_state_in(&p, "proj").unwrap());
let id =
create_project_full_in(&p, "proj", None, None, None, false, TerminalConfig::default(), false)
.unwrap();
assert!(!todo_state_in(&p, &id).unwrap());
set_todo_in(&p, "proj", true).unwrap();
assert!(todo_state_in(&p, "proj").unwrap());
set_todo_in(&p, &id, true).unwrap();
assert!(todo_state_in(&p, &id).unwrap());
let todo_path = p.projects_dir().join("proj").join(TODO_FILE);
assert_eq!(fs::read_to_string(&todo_path).unwrap(), TODO_SKELETON);
// doppelt aktivieren erzeugt keinen zweiten Hook
set_todo_in(&p, "proj", true).unwrap();
set_todo_in(&p, &id, true).unwrap();
let settings: serde_json::Value = serde_json::from_str(
&fs::read_to_string(settings_path(&p.projects_dir().join("proj"))).unwrap(),
)
@@ -149,8 +150,8 @@ mod tests {
// Abschalten: Hook weg, Datei (mit Inhalt) bleibt
fs::write(&todo_path, "# Offene Punkte\n\n- [ ] wichtig\n").unwrap();
set_todo_in(&p, "proj", false).unwrap();
assert!(!todo_state_in(&p, "proj").unwrap());
set_todo_in(&p, &id, false).unwrap();
assert!(!todo_state_in(&p, &id).unwrap());
assert!(todo_path.is_file());
assert!(fs::read_to_string(&todo_path).unwrap().contains("wichtig"));
}
+3 -2
View File
@@ -138,8 +138,9 @@ pub(crate) fn usage_stats_in(paths: &Paths, days: u32) -> Result<Vec<UsageRow>,
// kodierter Projektpfad → Projektname
let mut names = HashMap::new();
for (name, dir) in load_registry(paths)? {
names.insert(encode_project_path(&dir.to_string_lossy()), name);
for (id, entry) in load_registry(paths)? {
let name = crate::domain::project::display_name_in(paths, &id)?;
names.insert(encode_project_path(&entry.dir.to_string_lossy()), name);
}
let mut seen = HashSet::new();
+6 -4
View File
@@ -5,7 +5,7 @@ use std::process::Command;
use std::time::Duration;
use crate::domain::paths::Paths;
use crate::domain::project::list_projects_in;
use crate::domain::project::{display_name_in, list_projects_in};
use crate::domain::registry::project_dir;
use crate::domain::settings::sync_on_session_end;
@@ -20,11 +20,11 @@ fn git_root(dir: &std::path::Path) -> Option<std::path::PathBuf> {
.map(|a| a.to_path_buf())
}
/// (Name, läuft) aller Projekte, sortiert — dieselbe Erkennung wie die
/// (Projekt-ID, läuft) aller Projekte, sortiert — dieselbe Erkennung wie die
/// Projektliste; Grundlage für Session-Watcher und Tray-Menü.
fn project_state(paths: &Paths) -> Vec<(String, bool)> {
let mut state: Vec<(String, bool)> = list_projects_in(paths)
.map(|ps| ps.into_iter().map(|p| (p.name, p.running)).collect())
.map(|ps| ps.into_iter().map(|p| (p.id, p.running)).collect())
.unwrap_or_default();
state.sort();
state
@@ -98,7 +98,9 @@ pub(crate) fn spawn_session_watcher() {
for project in ended {
if let Ok(dir) = project_dir(&paths, project) {
if let Some(repo) = git_root(&dir) {
sync_session_context(&repo, project);
let name =
display_name_in(&paths, project).unwrap_or_else(|_| project.clone());
sync_session_context(&repo, &name);
}
}
}