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
+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.