Modul-Split: Fachlogik nach domain/, OS-Code nach platform/, Tauri-Verdrahtung in app.rs/commands.rs
- lib.rs nur noch Prozessrollen-Dispatch + Panic-Tracer - KWin-Script als KDE-Pendant zur GNOME-Extension (Popup ans Zeiger- Rechteck), wird von deb/rpm mit ausgeliefert - SNI-Tray auf ksni (pure Rust), appindicator-Depends entfallen
This commit is contained in:
@@ -0,0 +1,762 @@
|
||||
//! 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).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
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};
|
||||
|
||||
/// Projekt-Config im Projektordner (Pool-Zuordnung + Terminal-Einstellungen).
|
||||
pub(crate) const PROJECT_FILE: &str = "ai-control.json";
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct Project {
|
||||
pub(crate) name: String,
|
||||
pub(crate) path: String,
|
||||
pub(crate) pool: Option<String>,
|
||||
pub(crate) running: bool,
|
||||
pub(crate) terminal: TerminalConfig,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
pub(crate) struct ProjectConfig {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) pool: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "TerminalConfig::is_empty")]
|
||||
pub(crate) terminal: TerminalConfig,
|
||||
/// Zielordner fürs Archivieren von Panel-Entwürfen (~-relativ gespeichert).
|
||||
#[serde(
|
||||
default,
|
||||
rename = "archiveDir",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub(crate) archive_dir: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Default, Clone)]
|
||||
pub struct TerminalConfig {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub theme: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
impl TerminalConfig {
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
self.theme.is_none() && self.icon.is_none() && self.title.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn settings_path(dir: &std::path::Path) -> PathBuf {
|
||||
dir.join(".claude").join("settings.json")
|
||||
}
|
||||
|
||||
pub(crate) fn project_config_path(paths: &Paths, project: &str) -> Result<PathBuf, String> {
|
||||
Ok(project_dir(paths, project)?.join(PROJECT_FILE))
|
||||
}
|
||||
|
||||
pub(crate) fn read_project_config_in(
|
||||
paths: &Paths,
|
||||
project: &str,
|
||||
) -> Result<ProjectConfig, String> {
|
||||
let cfg_path = project_config_path(paths, project)?;
|
||||
if !cfg_path.is_file() {
|
||||
return Ok(ProjectConfig::default());
|
||||
}
|
||||
let raw =
|
||||
fs::read_to_string(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()))?;
|
||||
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))
|
||||
}
|
||||
|
||||
pub(crate) fn write_project_config_in(
|
||||
paths: &Paths,
|
||||
project: &str,
|
||||
cfg: &ProjectConfig,
|
||||
) -> Result<(), String> {
|
||||
let raw = serde_json::to_string_pretty(cfg).map_err(|e| e.to_string())?;
|
||||
let path = project_config_path(paths, project)?;
|
||||
fs::write(&path, raw + "\n").map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
pub(crate) fn is_running(project: &str) -> bool {
|
||||
!crate::platform::terminal_pids(project).is_empty()
|
||||
}
|
||||
|
||||
/// Beendet die Terminal-Prozesse eines Projekts per SIGTERM auf die exakte PID.
|
||||
pub(crate) fn kill_terminals(project: &str) -> Result<(), String> {
|
||||
for pid in crate::platform::terminal_pids(project) {
|
||||
crate::platform::kill_terminal(pid)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Projekte, die diesem Pool zugeordnet sind und gerade laufen — dieselbe
|
||||
/// Lauf-Erkennung wie die Projektliste (`is_running`).
|
||||
pub(crate) fn running_projects_using_pool(
|
||||
paths: &Paths,
|
||||
pool: &str,
|
||||
) -> Result<Vec<String>, String> {
|
||||
Ok(projects_using_pool(paths, pool)?
|
||||
.into_iter()
|
||||
.filter(|p| is_running(p))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Namen aller Projekte, die `pool` zugeordnet haben.
|
||||
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)
|
||||
}
|
||||
|
||||
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)?;
|
||||
projects.push(Project {
|
||||
running: is_running(&name),
|
||||
path: contract_home(paths, &dir),
|
||||
pool: cfg.pool,
|
||||
terminal: cfg.terminal,
|
||||
name,
|
||||
});
|
||||
}
|
||||
Ok(projects)
|
||||
}
|
||||
|
||||
/// Pool-Config-Verzeichnis eines Projekts — wird dem Terminal als
|
||||
/// CLAUDE_CONFIG_DIR mitgegeben.
|
||||
pub(crate) fn project_pool_dir(project: &str) -> Result<Option<PathBuf>, String> {
|
||||
let paths = Paths::real();
|
||||
Ok(read_project_config_in(&paths, project)?.pool.map(|p| paths.pool_dir(&p)))
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// 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>.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn create_project_full_in(
|
||||
paths: &Paths,
|
||||
name: &str,
|
||||
dir: Option<&str>,
|
||||
pool: Option<&str>,
|
||||
work_dir: Option<&str>,
|
||||
create_work_dir: bool,
|
||||
terminal: TerminalConfig,
|
||||
todo: bool,
|
||||
) -> Result<(), 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) {
|
||||
return Err(format!("Projekt existiert bereits: {name}"));
|
||||
}
|
||||
if dir.exists() {
|
||||
return Err(format!("Ordner existiert bereits: {}", dir.display()));
|
||||
}
|
||||
if let Some(pool) = pool {
|
||||
if !paths.pool_dir(pool).join(POOL_FILE).is_file() {
|
||||
return Err(format!("Pool existiert nicht: {pool}"));
|
||||
}
|
||||
}
|
||||
// Arbeitsverzeichnis zuerst prüfen/anlegen — scheitert das, entsteht kein
|
||||
// halbes Projekt.
|
||||
if let Some(wd) = work_dir {
|
||||
let wd_path = expand_home(paths, wd);
|
||||
if create_work_dir {
|
||||
fs::create_dir_all(&wd_path).map_err(|e| format!("{}: {e}", wd_path.display()))?;
|
||||
} else if !wd_path.is_dir() {
|
||||
return Err(format!("Arbeitsverzeichnis fehlt: {}", wd_path.display()));
|
||||
}
|
||||
}
|
||||
|
||||
fs::create_dir_all(dir.join(".claude"))
|
||||
.map_err(|e| format!("{}: {e}", dir.join(".claude").display()))?;
|
||||
fs::create_dir_all(dir.join("memory"))
|
||||
.map_err(|e| format!("{}: {e}", dir.join("memory").display()))?;
|
||||
fs::write(dir.join(".gitignore"), ".ai-control-running\n")
|
||||
.map_err(|e| format!("{}: {e}", dir.join(".gitignore").display()))?;
|
||||
|
||||
let contracted = contract_home(paths, &dir);
|
||||
let mut allow = vec![format!("Edit({contracted}/**)")];
|
||||
let mut additional: Vec<String> = Vec::new();
|
||||
if let Some(wd) = work_dir {
|
||||
allow.insert(0, format!("Edit({wd}/**)"));
|
||||
additional.push(wd.to_string());
|
||||
}
|
||||
let settings = serde_json::json!({
|
||||
"autoMemoryDirectory": format!("{contracted}/memory"),
|
||||
"permissions": {
|
||||
"allow": allow,
|
||||
"additionalDirectories": additional,
|
||||
},
|
||||
});
|
||||
let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?;
|
||||
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());
|
||||
save_registry(paths, ®)?;
|
||||
let cfg = ProjectConfig { pool: pool.map(str::to_string), terminal, archive_dir: None };
|
||||
if cfg.pool.is_some() || !cfg.terminal.is_empty() {
|
||||
write_project_config_in(paths, name, &cfg)?;
|
||||
}
|
||||
if todo {
|
||||
crate::domain::todo::set_todo_in(paths, name, true)?;
|
||||
}
|
||||
crate::platform::write_terminal_desktop(paths, name, &cfg.terminal);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Verlegt den Projektordner: Registry-Eintrag auf den neuen Pfad; Verweise
|
||||
/// auf den alten Root in der settings.json (autoMemoryDirectory,
|
||||
/// Edit-Permission) ziehen mit. Verschoben wird nichts — der neue Ordner
|
||||
/// muss existieren.
|
||||
pub(crate) fn set_project_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<(), String> {
|
||||
check_name(name)?;
|
||||
let new_dir = expand_home(paths, dir);
|
||||
if !new_dir.is_dir() {
|
||||
return Err(format!("Ordner nicht gefunden: {}", new_dir.display()));
|
||||
}
|
||||
let mut reg = load_registry(paths)?;
|
||||
let old_dir = reg
|
||||
.get(name)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("Projekt nicht registriert: {name}"))?;
|
||||
if old_dir == new_dir {
|
||||
return Ok(());
|
||||
}
|
||||
reg.insert(name.to_string(), new_dir.clone());
|
||||
save_registry(paths, ®)?;
|
||||
|
||||
let sp = settings_path(&new_dir);
|
||||
if !sp.is_file() {
|
||||
return Ok(()); // importierte Projekte ohne settings.json
|
||||
}
|
||||
let old_c = contract_home(paths, &old_dir);
|
||||
let new_c = contract_home(paths, &new_dir);
|
||||
let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
|
||||
let mut v: serde_json::Value =
|
||||
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?;
|
||||
if let Some(mem) = v["autoMemoryDirectory"].as_str() {
|
||||
if let Some(rest) = mem.strip_prefix(&old_c) {
|
||||
v["autoMemoryDirectory"] = serde_json::json!(format!("{new_c}{rest}"));
|
||||
}
|
||||
}
|
||||
if let Some(allow) = v["permissions"]["allow"].as_array_mut() {
|
||||
let old_edit = format!("Edit({old_c}/**)");
|
||||
for e in allow.iter_mut() {
|
||||
if e.as_str() == Some(&old_edit) {
|
||||
*e = serde_json::json!(format!("Edit({new_c}/**)"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
|
||||
fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
|
||||
}
|
||||
|
||||
/// Nimmt einen bestehenden Ordner als Projekt auf; der Name ist der Ordnername.
|
||||
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
|
||||
.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);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Trägt einen Arbeitsordner in die Projekt-settings.json ein — wie im
|
||||
/// Wizard: permissions.additionalDirectories + Edit-Permission. Legt die
|
||||
/// Datei an, wenn sie fehlt (importierte Projekte).
|
||||
pub(crate) fn add_work_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<(), String> {
|
||||
check_name(name)?;
|
||||
let wd_path = expand_home(paths, dir);
|
||||
if !wd_path.is_dir() {
|
||||
return Err(format!("Arbeitsverzeichnis fehlt: {}", wd_path.display()));
|
||||
}
|
||||
let dir = contract_home(paths, &wd_path);
|
||||
let sp = settings_path(&project_dir(paths, name)?);
|
||||
let mut v: serde_json::Value = if sp.is_file() {
|
||||
let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
|
||||
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?
|
||||
} else {
|
||||
serde_json::json!({})
|
||||
};
|
||||
let root = v.as_object_mut().ok_or("settings.json ist kein Objekt")?;
|
||||
let perms = root
|
||||
.entry("permissions")
|
||||
.or_insert_with(|| serde_json::json!({}))
|
||||
.as_object_mut()
|
||||
.ok_or("permissions ist kein Objekt")?;
|
||||
let dirs = perms
|
||||
.entry("additionalDirectories")
|
||||
.or_insert_with(|| serde_json::json!([]))
|
||||
.as_array_mut()
|
||||
.ok_or("additionalDirectories ist kein Array")?;
|
||||
if dirs.iter().any(|d| d.as_str() == Some(&dir)) {
|
||||
return Err(format!("schon eingetragen: {dir}"));
|
||||
}
|
||||
dirs.push(serde_json::json!(dir));
|
||||
let allow = perms
|
||||
.entry("allow")
|
||||
.or_insert_with(|| serde_json::json!([]))
|
||||
.as_array_mut()
|
||||
.ok_or("allow ist kein Array")?;
|
||||
allow.insert(0, serde_json::json!(format!("Edit({dir}/**)")));
|
||||
let parent = sp.parent().ok_or("settings.json ohne Elternordner")?;
|
||||
fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
|
||||
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
|
||||
fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
|
||||
}
|
||||
|
||||
/// Nimmt einen Arbeitsordner wieder raus: additionalDirectories-Eintrag und
|
||||
/// zugehörige Edit-Permission. Der Ordner selbst bleibt.
|
||||
pub(crate) fn remove_work_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<(), String> {
|
||||
check_name(name)?;
|
||||
let sp = settings_path(&project_dir(paths, name)?);
|
||||
let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
|
||||
let mut v: serde_json::Value =
|
||||
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?;
|
||||
let perms = v["permissions"]
|
||||
.as_object_mut()
|
||||
.ok_or("permissions ist kein Objekt")?;
|
||||
let dirs = perms["additionalDirectories"]
|
||||
.as_array_mut()
|
||||
.ok_or("additionalDirectories ist kein Array")?;
|
||||
let before = dirs.len();
|
||||
dirs.retain(|d| d.as_str() != Some(dir));
|
||||
if dirs.len() == before {
|
||||
return Err(format!("nicht eingetragen: {dir}"));
|
||||
}
|
||||
if let Some(allow) = perms.get_mut("allow").and_then(|a| a.as_array_mut()) {
|
||||
allow.retain(|p| p.as_str() != Some(&format!("Edit({dir}/**)")));
|
||||
}
|
||||
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
|
||||
fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
|
||||
}
|
||||
|
||||
/// Arbeitsordner des Projekts: additionalDirectories aus der Projekt-settings.json.
|
||||
pub(crate) fn project_work_dirs_in(paths: &Paths, name: &str) -> Result<Vec<String>, String> {
|
||||
check_name(name)?;
|
||||
let sp = settings_path(&project_dir(paths, name)?);
|
||||
if !sp.is_file() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let raw = fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?;
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?;
|
||||
Ok(
|
||||
v["permissions"]["additionalDirectories"]
|
||||
.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|x| x.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Löscht den Projektordner und den Registry-Eintrag; optional auch die
|
||||
/// verknüpften Arbeitsordner. Bei laufender Session wird abgebrochen.
|
||||
pub(crate) fn delete_project_in(
|
||||
paths: &Paths,
|
||||
name: &str,
|
||||
delete_work_dirs: bool,
|
||||
) -> Result<(), String> {
|
||||
check_name(name)?;
|
||||
let dir = project_dir(paths, name)?;
|
||||
if !dir.is_dir() {
|
||||
return Err(format!("Projekt nicht gefunden: {name}"));
|
||||
}
|
||||
if delete_work_dirs {
|
||||
for wd in project_work_dirs_in(paths, name)? {
|
||||
let wd_path = expand_home(paths, &wd);
|
||||
fs::remove_dir_all(&wd_path).map_err(|e| format!("{}: {e}", wd_path.display()))?;
|
||||
}
|
||||
}
|
||||
fs::remove_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
crate::domain::registry::unregister_project(paths, name)?;
|
||||
crate::platform::remove_terminal_desktop(paths, name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn assign_pool_in(paths: &Paths, project: &str, pool: &str) -> Result<(), String> {
|
||||
if !paths.pool_dir(pool).join(POOL_FILE).is_file() {
|
||||
return Err(format!("Pool existiert nicht: {pool}"));
|
||||
}
|
||||
let mut cfg = read_project_config_in(paths, project)?;
|
||||
cfg.pool = Some(pool.to_string());
|
||||
write_project_config_in(paths, project, &cfg)
|
||||
}
|
||||
|
||||
/// Nimmt den Pool raus; die Config-Datei bleibt nur, wenn sie noch
|
||||
/// Terminal-Einstellungen trägt.
|
||||
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_dir.is_none() {
|
||||
let cfg_path = project_config_path(paths, project)?;
|
||||
return fs::remove_file(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()));
|
||||
}
|
||||
write_project_config_in(paths, project, &cfg)
|
||||
}
|
||||
|
||||
pub(crate) fn set_terminal_config_in(
|
||||
paths: &Paths,
|
||||
project: &str,
|
||||
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.
|
||||
if let Some(icon) = terminal.icon.as_deref() {
|
||||
if icon.starts_with('/') {
|
||||
let src = PathBuf::from(icon);
|
||||
let ext = src
|
||||
.extension()
|
||||
.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);
|
||||
if src != dest {
|
||||
fs::copy(&src, &dest).map_err(|e| format!("{}: {e}", src.display()))?;
|
||||
}
|
||||
terminal.icon = Some(name);
|
||||
}
|
||||
}
|
||||
cfg.terminal = terminal;
|
||||
write_project_config_in(paths, project, &cfg)?;
|
||||
crate::platform::write_terminal_desktop(paths, project, &cfg.terminal);
|
||||
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 {
|
||||
if icon.starts_with('/') {
|
||||
PathBuf::from(icon)
|
||||
} else {
|
||||
paths.icons_dir().join(icon)
|
||||
}
|
||||
}
|
||||
|
||||
#[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;
|
||||
|
||||
#[test]
|
||||
fn projekt_wizard_scaffold() {
|
||||
let p = tmp_paths();
|
||||
let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1");
|
||||
create_project_full_in(
|
||||
&p,
|
||||
"neu",
|
||||
None,
|
||||
Some(&kunde),
|
||||
Some("~/projects/neu"),
|
||||
true,
|
||||
TerminalConfig {
|
||||
theme: Some("dracula".into()),
|
||||
icon: None,
|
||||
title: Some("Neu".into()),
|
||||
},
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let dir = p.projects_dir().join("neu");
|
||||
assert!(dir.join("memory").is_dir());
|
||||
assert_eq!(
|
||||
fs::read_to_string(dir.join(".gitignore")).unwrap(),
|
||||
".ai-control-running\n"
|
||||
);
|
||||
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()));
|
||||
assert_eq!(cfg.terminal.title.as_deref(), Some("Neu"));
|
||||
assert_eq!(cfg.terminal.theme.as_deref(), Some("dracula"));
|
||||
|
||||
let settings: serde_json::Value = serde_json::from_str(
|
||||
&fs::read_to_string(dir.join(".claude").join("settings.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(settings["autoMemoryDirectory"], "~/claude-projects/neu/memory");
|
||||
assert_eq!(settings["permissions"]["allow"][0], "Edit(~/projects/neu/**)");
|
||||
assert_eq!(
|
||||
settings["permissions"]["allow"][1],
|
||||
"Edit(~/claude-projects/neu/**)"
|
||||
);
|
||||
assert_eq!(settings["permissions"]["additionalDirectories"][0], "~/projects/neu");
|
||||
// todo=true: einziger SessionStart-Hook (kein pool-guard mehr), Datei da
|
||||
assert!(dir.join(TODO_FILE).is_file());
|
||||
let groups = settings["hooks"]["SessionStart"].as_array().unwrap();
|
||||
assert_eq!(groups.len(), 1);
|
||||
assert!(groups[0]["hooks"][0]["command"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains(TODO_FILE));
|
||||
}
|
||||
|
||||
#[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 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());
|
||||
let settings: serde_json::Value = serde_json::from_str(
|
||||
&fs::read_to_string(dir.join(".claude").join("settings.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(settings["permissions"]["allow"][0], "Edit(~/claude-projects/neu/**)");
|
||||
assert_eq!(settings["permissions"]["additionalDirectories"], serde_json::json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projekt_wizard_doppelt_scheitert() {
|
||||
let p = tmp_paths();
|
||||
create_project(&p, "neu").unwrap();
|
||||
let err =
|
||||
create_project_full_in(&p, "neu", None, None, None, false, TerminalConfig::default(), false)
|
||||
.unwrap_err();
|
||||
assert!(err.contains("existiert bereits"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projekt_wizard_fehlendes_workdir_scheitert() {
|
||||
let p = tmp_paths();
|
||||
let err = create_project_full_in(
|
||||
&p,
|
||||
"neu",
|
||||
None,
|
||||
None,
|
||||
Some("~/projects/gibtsnicht"),
|
||||
false,
|
||||
TerminalConfig::default(),
|
||||
false,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.contains("Arbeitsverzeichnis fehlt"));
|
||||
// kein halbes Projekt zurückgeblieben
|
||||
assert!(!p.projects_dir().join("neu").exists());
|
||||
}
|
||||
|
||||
// -- Projekt zuordnen / rausnehmen / wechseln --
|
||||
|
||||
#[test]
|
||||
fn projekt_zuordnen() {
|
||||
let p = tmp_paths();
|
||||
let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1");
|
||||
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()));
|
||||
|
||||
let pools =
|
||||
crate::domain::pool::list_pools_in(&p, &crate::domain::testutil::FailStore).unwrap();
|
||||
assert_eq!(pools[0].projects, vec!["proj"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projekt_zuordnen_pool_fehlt_scheitert() {
|
||||
let p = tmp_paths();
|
||||
create_project(&p, "proj").unwrap();
|
||||
assert!(assign_pool_in(&p, "proj", "gibtsnicht").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projekt_rausnehmen() {
|
||||
let p = tmp_paths();
|
||||
let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1");
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projekt_rausnehmen_ohne_zuordnung_scheitert() {
|
||||
let p = tmp_paths();
|
||||
create_project(&p, "proj").unwrap();
|
||||
assert!(unassign_pool_in(&p, "proj").is_err());
|
||||
}
|
||||
|
||||
/// Wechsel oauth → apikey: das Projekt sieht nur den Pool-Namen,
|
||||
/// der Credential-Typ ist ihm egal.
|
||||
#[test]
|
||||
fn pool_wechseln_typ_ist_projekt_egal() {
|
||||
let p = tmp_paths();
|
||||
let privat = make_oauth_pool(&p, "privat");
|
||||
let kunde = make_apikey_pool(&p, &map_store(), "kunde", "sk-1");
|
||||
create_project(&p, "proj").unwrap();
|
||||
|
||||
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 }));
|
||||
}
|
||||
|
||||
// -- Projekt anlegen / löschen --
|
||||
|
||||
#[test]
|
||||
fn projekt_anlegen() {
|
||||
let p = tmp_paths();
|
||||
create_project(&p, "proj").unwrap();
|
||||
assert!(p.projects_dir().join("proj").join(".claude").is_dir());
|
||||
|
||||
let projects = list_projects_in(&p).unwrap();
|
||||
assert_eq!(projects.len(), 1);
|
||||
assert_eq!(projects[0].name, "proj");
|
||||
assert_eq!(projects[0].pool, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projekt_doppelt_anlegen_scheitert() {
|
||||
let p = tmp_paths();
|
||||
create_project(&p, "proj").unwrap();
|
||||
assert!(create_project(&p, "proj").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projekt_loeschen() {
|
||||
let p = tmp_paths();
|
||||
create_project(&p, "proj").unwrap();
|
||||
delete_project_in(&p, "proj", false).unwrap();
|
||||
assert!(!p.projects_dir().join("proj").exists());
|
||||
}
|
||||
|
||||
#[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)
|
||||
.unwrap();
|
||||
delete_project_in(&p, "proj", false).unwrap();
|
||||
assert!(!p.projects_dir().join("proj").exists());
|
||||
assert!(p.home.join("projects").join("proj").is_dir());
|
||||
}
|
||||
|
||||
#[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)
|
||||
.unwrap();
|
||||
delete_project_in(&p, "proj", true).unwrap();
|
||||
assert!(!p.projects_dir().join("proj").exists());
|
||||
assert!(!p.home.join("projects").join("proj").exists());
|
||||
}
|
||||
|
||||
#[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)
|
||||
.unwrap();
|
||||
fs::remove_dir_all(p.home.join("projects").join("proj")).unwrap();
|
||||
assert!(delete_project_in(&p, "proj", 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)
|
||||
.unwrap();
|
||||
assert_eq!(project_work_dirs_in(&p, "proj").unwrap(), vec!["~/projects/proj"]);
|
||||
// Projekt ohne settings.json → leer
|
||||
create_project(&p, "alt").unwrap();
|
||||
assert!(project_work_dirs_in(&p, "alt").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)
|
||||
.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();
|
||||
// gespeichert wird ~-kontrahiert, in beiden permissions-Feldern
|
||||
assert_eq!(project_work_dirs_in(&p, "proj").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
|
||||
|
||||
remove_work_dir_in(&p, "proj", "~/projects/extra").unwrap();
|
||||
assert!(project_work_dirs_in(&p, "proj").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
|
||||
assert!(p.home.join("projects/extra").is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projektordner_verlegen() {
|
||||
let p = tmp_paths();
|
||||
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();
|
||||
|
||||
assert_eq!(project_dir(&p, "proj").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/**)"));
|
||||
assert!(!raw.contains("claude-projects/proj"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projekt_loeschen_unbekannt_scheitert() {
|
||||
let p = tmp_paths();
|
||||
assert!(delete_project_in(&p, "gibtsnicht", false).is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user