|
|
|
@@ -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, ®)?;
|
|
|
|
|
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, ®)?;
|
|
|
|
|
|
|
|
|
|
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/**)"));
|
|
|
|
|