Dopplungen konsolidiert: settings.json-Permissions-Helfer, pool.json-Lesen über read_pool, gemeinsame Helfer für Pool-Anlage, Projekt-Config, Icon-Übernahme und Running-Guard

This commit is contained in:
marcus hinz
2026-07-22 12:58:38 +02:00
parent f7a99b1176
commit 513e1a768f
5 changed files with 208 additions and 231 deletions
+11 -8
View File
@@ -61,13 +61,19 @@ pub(crate) fn add_project(path: String) -> Result<(), String> {
add_project_in(&Paths::real(), &path)
}
/// Umbau-Sperre: eine laufende Session des Projekts zuerst beenden.
fn ensure_not_running(project: &str) -> Result<(), String> {
if is_running(project) {
let name = display_name_in(&Paths::real(), project)?;
return Err(format!("{name} läuft noch — erst beenden"));
}
Ok(())
}
/// Projektordner neu zuordnen; bei laufender Session gesperrt.
#[tauri::command]
pub(crate) fn set_project_dir(project: String, dir: String) -> Result<(), String> {
if is_running(&project) {
let name = display_name_in(&Paths::real(), &project)?;
return Err(format!("{name} läuft noch — erst beenden"));
}
ensure_not_running(&project)?;
set_project_dir_in(&Paths::real(), &project, &dir)
}
@@ -96,10 +102,7 @@ pub(crate) fn delete_project_scoped(
scope: String,
delete_work_dirs: bool,
) -> Result<(), String> {
if is_running(&project) {
let name = display_name_in(&Paths::real(), &project)?;
return Err(format!("{name} läuft noch — erst beenden"));
}
ensure_not_running(&project)?;
delete_project_scoped_in(&Paths::real(), &project, &scope, delete_work_dirs)
}
+22 -48
View File
@@ -60,39 +60,18 @@ pub(crate) fn add_archive_permission(
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()))?)
.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)) {
dirs.push(serde_json::json!(dir));
}
let edit = format!("Edit({dir}/**)");
let allow = perms
.entry("allow")
.or_insert_with(|| serde_json::json!([]))
.as_array_mut()
.ok_or("allow ist kein Array")?;
if !allow.iter().any(|p| p.as_str() == Some(&edit)) {
allow.push(serde_json::json!(edit));
}
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())?;
crate::domain::write_atomic(&sp, &(raw + "\n"))
crate::domain::update_settings_permissions(&sp, true, |perms| {
let dirs = crate::domain::perm_array(perms, "additionalDirectories")?;
if !dirs.iter().any(|d| d.as_str() == Some(dir)) {
dirs.push(serde_json::json!(dir));
}
let edit = format!("Edit({dir}/**)");
let allow = crate::domain::perm_array(perms, "allow")?;
if !allow.iter().any(|p| p.as_str() == Some(&edit)) {
allow.push(serde_json::json!(edit));
}
Ok(())
})
}
/// Wechselt das Archiv-Home: neues Home setzen (validieren, anlegen, Rechte,
@@ -186,21 +165,16 @@ pub(crate) fn remove_archive_permission(
if !sp.is_file() {
return Ok(());
}
let mut v: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&sp).map_err(|e| format!("{}: {e}", sp.display()))?)
.map_err(|e| format!("{}: {e}", sp.display()))?;
let Some(perms) = v.get_mut("permissions").and_then(|p| p.as_object_mut()) else {
return Ok(());
};
if let Some(dirs) = perms.get_mut("additionalDirectories").and_then(|d| d.as_array_mut()) {
dirs.retain(|d| d.as_str() != Some(dir));
}
let edit = format!("Edit({dir}/**)");
if let Some(allow) = perms.get_mut("allow").and_then(|a| a.as_array_mut()) {
allow.retain(|p| p.as_str() != Some(&edit));
}
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
crate::domain::write_atomic(&sp, &(raw + "\n"))
crate::domain::update_settings_permissions(&sp, false, |perms| {
if let Some(dirs) = perms.get_mut("additionalDirectories").and_then(|d| d.as_array_mut()) {
dirs.retain(|d| d.as_str() != Some(dir));
}
let edit = format!("Edit({dir}/**)");
if let Some(allow) = perms.get_mut("allow").and_then(|a| a.as_array_mut()) {
allow.retain(|p| p.as_str() != Some(&edit));
}
Ok(())
})
}
/// Metadaten beim Archivieren: Unterordner im Archiv-Home plus Frontmatter-Felder.
+54
View File
@@ -38,6 +38,60 @@ pub(crate) fn write_atomic(path: &std::path::Path, content: &str) -> Result<(),
})
}
/// Read-modify-write auf dem `permissions`-Objekt einer Claude-settings.json
/// (Projekt wie Pool). `create` behandelt eine fehlende Datei als leeres
/// Objekt — für Einträge, die die Datei erst anlegen; ohne `create` scheitert
/// das Lesen laut. Geschrieben wird nur, wenn `f` den Inhalt tatsächlich
/// verändert hat — die Datei gehört claude, unnötige Writes unterbleiben.
pub(crate) fn update_settings_permissions(
sp: &std::path::Path,
create: bool,
f: impl FnOnce(&mut serde_json::Map<String, serde_json::Value>) -> Result<(), String>,
) -> Result<(), String> {
let mut v: serde_json::Value = if !sp.is_file() && create {
serde_json::json!({})
} else {
let raw = std::fs::read_to_string(sp).map_err(|e| format!("{}: {e}", sp.display()))?;
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", sp.display()))?
};
let before = v.clone();
{
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")?;
f(perms)?;
}
// Ein hier erst angelegtes, leer gebliebenes permissions-Objekt wieder
// entfernen — sonst zählte ein No-op als Änderung.
if before.get("permissions").is_none()
&& v["permissions"].as_object().is_some_and(|o| o.is_empty())
{
v.as_object_mut().unwrap().remove("permissions");
}
if v == before {
return Ok(());
}
let parent = sp.parent().ok_or("settings.json ohne Elternordner")?;
std::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())?;
write_atomic(sp, &(raw + "\n"))
}
/// Array-Feld im permissions-Objekt, bei Bedarf angelegt.
pub(crate) fn perm_array<'a>(
perms: &'a mut serde_json::Map<String, serde_json::Value>,
key: &str,
) -> Result<&'a mut Vec<serde_json::Value>, String> {
perms
.entry(key)
.or_insert_with(|| serde_json::json!([]))
.as_array_mut()
.ok_or_else(|| format!("{key} ist kein Array"))
}
/// Namensprüfung für Projekte und Pool-Anzeigenamen.
///
/// Der Name wird als Pfadsegment verwendet (Pool-Verzeichnis, Panel-Dateien)
+66 -84
View File
@@ -86,33 +86,38 @@ pub(crate) fn read_pool(paths: &Paths, pool: &str) -> Result<Pool, String> {
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))
}
/// IDs aller Pool-Ordner: Einträge unter pools/ mit pool.json. Beim ersten
/// Pool existiert pools/ noch nicht — dann ist die Liste leer.
fn pool_ids(paths: &Paths) -> Result<Vec<String>, String> {
let mut out = Vec::new();
if !paths.pools_dir().is_dir() {
return Ok(out);
}
for entry in
fs::read_dir(paths.pools_dir()).map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?
{
let entry = entry.map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?;
if entry.path().join(POOL_FILE).is_file() {
out.push(entry.file_name().to_string_lossy().into_owned());
}
}
Ok(out)
}
pub(crate) fn list_pools_in(
paths: &Paths,
store: &dyn ApikeyStore,
) -> Result<Vec<PoolInfo>, String> {
let mut pools = Vec::new();
if !paths.pools_dir().is_dir() {
return Ok(pools);
}
let entries =
fs::read_dir(paths.pools_dir()).map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?;
for entry in entries {
let entry = entry.map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?;
let cfg_path = entry.path().join(POOL_FILE);
if !cfg_path.is_file() {
continue;
}
let raw = fs::read_to_string(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()))?;
let pool: Pool =
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))?;
let id = entry.file_name().to_string_lossy().into_owned();
for id in pool_ids(paths)? {
let pool = read_pool(paths, &id)?;
// oauth: Credentials liegen in claudes eigenem Keychain-Eintrag, dessen
// Prüfung wäre ein security-Aufruf pro Pool im 3-s-Polling — immer true.
// apikey: Store-Eintrag (nativer API-Call) oder Fallback-Datei.
let has_credentials = match pool.credential_type.as_str() {
"apikey" => {
store.has(&id)?
|| fs::read_to_string(entry.path().join(APIKEY_FILE))
|| fs::read_to_string(paths.pool_dir(&id).join(APIKEY_FILE))
.map(|s| !s.trim().is_empty())
.unwrap_or(false)
}
@@ -142,27 +147,12 @@ pub(crate) fn list_pools_in(
Ok(pools)
}
/// (ID, Anzeigename) aller Pools. Beim ersten Pool existiert pools/ noch
/// nicht — dann ist die Liste leer.
/// (ID, Anzeigename) aller Pools.
pub(crate) fn pool_names(paths: &Paths) -> Result<Vec<(String, String)>, String> {
let mut out = Vec::new();
if !paths.pools_dir().is_dir() {
return Ok(out);
}
for entry in
fs::read_dir(paths.pools_dir()).map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?
{
let entry = entry.map_err(|e| format!("{}: {e}", paths.pools_dir().display()))?;
let cfg_path = entry.path().join(POOL_FILE);
if !cfg_path.is_file() {
continue;
}
let raw = fs::read_to_string(&cfg_path).map_err(|e| format!("{}: {e}", cfg_path.display()))?;
let pool: Pool =
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", cfg_path.display()))?;
out.push((entry.file_name().to_string_lossy().into_owned(), pool.name));
}
Ok(out)
pool_ids(paths)?
.into_iter()
.map(|id| read_pool(paths, &id).map(|p| (id, p.name)))
.collect()
}
/// Prüft den Anzeigenamen (gültig + noch nicht vergeben) und liefert den
@@ -175,12 +165,37 @@ fn check_new_pool(paths: &Paths, name: &str) -> Result<PathBuf, String> {
Ok(paths.pool_dir(&uuid::Uuid::new_v4().to_string()))
}
fn write_pool_json(dir: &PathBuf, pool: &Pool) -> Result<(), String> {
fn write_pool_json(dir: &std::path::Path, pool: &Pool) -> Result<(), String> {
fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let raw = serde_json::to_string_pretty(pool).map_err(|e| e.to_string())?;
crate::domain::write_atomic(&dir.join(POOL_FILE), &(raw + "\n"))
}
/// Gemeinsamer Abschluss der Pool-Anlage (apikey wie oauth): pool.json
/// schreiben, bei konfiguriertem poolSyncDir die Runtime verlinken;
/// liefert die Pool-ID.
fn finish_pool_create(
paths: &Paths,
dir: &std::path::Path,
name: &str,
credential_type: &str,
) -> Result<String, String> {
write_pool_json(
dir,
&Pool {
name: name.to_string(),
credential_type: credential_type.into(),
dir: None,
rest: Default::default(),
},
)?;
let id = dir.file_name().unwrap().to_string_lossy().into_owned();
if pool_sync_dir(paths).is_some() {
link_pool_runtime_in(paths, &id)?;
}
Ok(id)
}
/// Grundausstattung eines Pool-Ordners (= CLAUDE_CONFIG_DIR): settings.json
/// (aufgeräumte UI-Defaults + `extra`) und eine CLAUDE.md, die claude als
/// User-Scope liest. CLAUDE.md wird nur angelegt, wenn sie fehlt.
@@ -244,42 +259,20 @@ const STALE_PANEL_PERMISSIONS: [&str; 3] =
/// Trägt die MCP-Freigabe in die settings.json eines Pools ein und entfernt
/// die alten Bash-Freigaben — idempotent, ohne sonstige Einträge zu verändern.
/// Fehler (fehlende/kaputte Datei) bleiben bewusst still: das läuft beim
/// App-Start über alle Pools, auch referenzierte fremde Verzeichnisse.
fn ensure_panel_permission(pool_dir: &std::path::Path) {
let sp = pool_dir.join("settings.json");
let Ok(raw) = fs::read_to_string(&sp) else {
return;
};
let Ok(mut v) = serde_json::from_str::<serde_json::Value>(&raw) else {
return;
};
let Some(obj) = v.as_object_mut() else { return };
let Some(perms) = obj
.entry("permissions")
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
else {
return;
};
let Some(allow) = perms
.entry("allow")
.or_insert_with(|| serde_json::json!([]))
.as_array_mut()
else {
return;
};
let before = allow.clone();
allow.retain(|e| !e.as_str().is_some_and(|s| STALE_PANEL_PERMISSIONS.contains(&s)));
for perm in PANEL_PERMISSIONS {
if !allow.iter().any(|e| e.as_str() == Some(perm)) {
allow.push(serde_json::json!(perm));
let _ = crate::domain::update_settings_permissions(&sp, false, |perms| {
let allow = crate::domain::perm_array(perms, "allow")?;
allow.retain(|e| !e.as_str().is_some_and(|s| STALE_PANEL_PERMISSIONS.contains(&s)));
for perm in PANEL_PERMISSIONS {
if !allow.iter().any(|e| e.as_str() == Some(perm)) {
allow.push(serde_json::json!(perm));
}
}
}
if *allow == before {
return; // nichts geändert
}
if let Ok(out) = serde_json::to_string_pretty(&v) {
let _ = crate::domain::write_atomic(&sp, &(out + "\n"));
}
Ok(())
});
}
/// Registriert den MCP-Server (dieses Binary mit `--mcp-panel`) in der
@@ -423,11 +416,7 @@ pub(crate) fn create_apikey_pool_in(
&dir,
serde_json::json!({ "apiKeyHelper": crate::platform::apikey_helper_command(&dir, &id) }),
)?;
write_pool_json(&dir, &Pool { name: name.to_string(), credential_type: "apikey".into(), dir: None, rest: Default::default() })?;
if pool_sync_dir(paths).is_some() {
link_pool_runtime_in(paths, &id)?;
}
Ok(id)
finish_pool_create(paths, &dir, name, "apikey")
}
/// Legt einen oauth-Pool an: Grundausstattung (leere settings.json + CLAUDE.md)
@@ -437,12 +426,7 @@ pub(crate) fn create_apikey_pool_in(
pub(crate) fn create_oauth_pool_in(paths: &Paths, name: &str) -> Result<String, String> {
let dir = check_new_pool(paths, name)?;
init_pool_config(&dir, serde_json::json!({}))?;
write_pool_json(&dir, &Pool { name: name.to_string(), credential_type: "oauth".into(), dir: None, rest: Default::default() })?;
let id = dir.file_name().unwrap().to_string_lossy().into_owned();
if pool_sync_dir(paths).is_some() {
link_pool_runtime_in(paths, &id)?;
}
Ok(id)
finish_pool_create(paths, &dir, name, "oauth")
}
/// Legt einen Pool an, der auf ein bestehendes Config-Verzeichnis verweist —
@@ -524,9 +508,7 @@ pub(crate) fn delete_pool_in(
name: &str,
) -> Result<(), String> {
let dir = paths.pool_dir(name);
if !dir.join(POOL_FILE).is_file() {
return Err(format!("Pool nicht gefunden: {name}"));
}
// Fehlende pool.json scheitert hier — read_pool nennt den Pfad.
if read_pool(paths, name)?.credential_type == "apikey" {
store.delete(name)?;
}
+55 -91
View File
@@ -93,15 +93,18 @@ 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_CONFIG_DIR).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)?;
read_config_at(&project_dir(paths, project)?)
}
/// config.json unter `<dir>/.ai-control` lesen; ohne Datei der Default.
/// Für Fälle, in denen der Ordner vor der Registrierung steht (Import,
/// Migration) — sonst über `read_project_config_in`.
pub(crate) fn read_config_at(dir: &std::path::Path) -> Result<ProjectConfig, String> {
let cfg_path = dir.join(PROJECT_CONFIG_DIR).join(PROJECT_FILE);
if !cfg_path.is_file() {
return Ok(ProjectConfig::default());
}
@@ -384,13 +387,7 @@ pub(crate) fn add_project_in(paths: &Paths, path: &str) -> Result<(), String> {
.into_owned();
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()
};
let mut cfg = read_config_at(&dir)?;
if let Some(id) = cfg.id.as_deref() {
if load_registry(paths)?.contains_key(id) {
return Err(format!("Projekt ist schon registriert: {id}"));
@@ -425,37 +422,16 @@ pub(crate) fn add_work_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<()
}
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())?;
crate::domain::write_atomic(&sp, &(raw + "\n"))
crate::domain::update_settings_permissions(&sp, true, |perms| {
let dirs = crate::domain::perm_array(perms, "additionalDirectories")?;
if dirs.iter().any(|d| d.as_str() == Some(&dir)) {
return Err(format!("schon eingetragen: {dir}"));
}
dirs.push(serde_json::json!(dir));
let allow = crate::domain::perm_array(perms, "allow")?;
allow.insert(0, serde_json::json!(format!("Edit({dir}/**)")));
Ok(())
})
}
/// Nimmt einen Arbeitsordner wieder raus: additionalDirectories-Eintrag und
@@ -463,25 +439,18 @@ pub(crate) fn add_work_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<()
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())?;
crate::domain::write_atomic(&sp, &(raw + "\n"))
crate::domain::update_settings_permissions(&sp, false, |perms| {
let dirs = crate::domain::perm_array(perms, "additionalDirectories")?;
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}/**)")));
}
Ok(())
})
}
/// Arbeitsordner des Projekts: additionalDirectories aus der Projekt-settings.json.
@@ -653,20 +622,8 @@ pub(crate) fn set_terminal_config_in(
// 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);
let ext = src
.extension()
.and_then(|e| e.to_str())
.unwrap_or("png")
.to_lowercase();
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()))?;
}
terminal.icon = Some(name);
terminal.icon = Some(adopt_icon(std::path::Path::new(icon), &cfg_dir)?);
}
}
// Die Terminal-Config kommt aus der Oberfläche und kennt nur die drei Felder;
@@ -678,6 +635,23 @@ pub(crate) fn set_terminal_config_in(
Ok(())
}
/// Icon-Datei als `icon.<ext>` (kleingeschrieben, Default png) in den
/// Projekt-Config-Ordner kopieren; liefert den gespeicherten Dateinamen.
fn adopt_icon(src: &std::path::Path, cfg_dir: &std::path::Path) -> Result<String, String> {
let ext = src
.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()))?;
let dest = cfg_dir.join(&name);
if src != dest {
fs::copy(src, &dest).map_err(|e| format!("{}: {e}", src.display()))?;
}
Ok(name)
}
/// Icon-Pfad einer Projekt-Config auflösen: relative Namen liegen im
/// Projekt-Config-Ordner (.ai-control) des Projekts.
pub(crate) fn resolve_icon_path(
@@ -771,13 +745,7 @@ pub(crate) fn migrate_layout_in(paths: &Paths) -> Result<(), String> {
// 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 = read_config_at(&entry.dir)?;
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());
@@ -789,16 +757,8 @@ pub(crate) fn migrate_layout_in(paths: &Paths) -> Result<(), String> {
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()))?;
cfg.terminal.icon = Some(adopt_icon(&old, &cfg_dir)?);
fs::remove_file(&old).map_err(|e| format!("{}: {e}", old.display()))?;
cfg.terminal.icon = Some(name);
cfg_dirty = true;
}
}
@@ -826,6 +786,10 @@ mod tests {
use crate::domain::testutil::{create_project, make_apikey_pool, make_oauth_pool, map_store, tmp_paths};
use crate::domain::todo::TODO_FILE;
fn project_config_path(paths: &Paths, project: &str) -> Result<PathBuf, String> {
Ok(project_dir(paths, project)?.join(PROJECT_CONFIG_DIR).join(PROJECT_FILE))
}
/// 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.