diff --git a/.idea/Config.iml b/.idea/Config.iml index cf84ae4..bbe0a70 100644 --- a/.idea/Config.iml +++ b/.idea/Config.iml @@ -3,6 +3,7 @@ + diff --git a/AGENTS.md b/AGENTS.md index 464bf98..9f741df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,8 +31,10 @@ Config/ ├── LICENSE # GPL-3.0 Lizenztext ├── README.md # Projektdokumentation & Nutzungsbeispiele ├── AGENTS.md # Entwickler- und Agenten-Richtlinien -└── src/ - └── lib.rs # Hauptimplementierung (load, store, modify, get_config, Pfadauflösung, Tests) +├── src/ +│ └── lib.rs # Hauptimplementierung (load, store, modify, get_config, Pfadauflösung) +└── tests/ + └── integration_tests.rs # Vollständige Integrationstests ``` --- diff --git a/Cargo.lock b/Cargo.lock index a17d98f..c254be4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,6 +13,7 @@ name = "config-ctdra" version = "1.0.3" dependencies = [ "confy", + "program-ctdra", "serde", "sudo-ctdra", ] @@ -93,6 +94,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "program-ctdra" +version = "1.0.0" +source = "sparse+https://gitea.creative-dragonslayer.de/api/packages/Rust-Crates/cargo/" +checksum = "54f20af67f90bf6d10697dd77bb4beb41dafad7fe0fb46aacfc81ad45a8e48bd" + [[package]] name = "quote" version = "1.0.47" diff --git a/Cargo.toml b/Cargo.toml index 81e1e1a..5024bd8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ description = "Einfache, threadsichere Konfigurationsverwaltung für Rust mit au [dependencies] confy = "2.0.0" +program-ctdra = { version = "1.0.0", registry = "gitea" } serde = { version = "1.0.229", features = ["derive"] } sudo-ctdra = { version = "1.0.0", registry = "gitea" } diff --git a/README.md b/README.md index 4475b55..2fc84ae 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ - **Einheitliche Pfadauflösung**: - **Root-Modus** (Linux/Unix UID 0): Konfigurationsdateien werden unter `/etc//.toml` abgelegt. - **Benutzermodus**: Standardmäßiges Benutzerverzeichnis (z. B. `~/.config//.toml` via `confy`). - - **Individuell anpassbar**: Programmnamen (`set_program_name`), Dateinamen (`set_config_name`), Verzeichnisse (`set_custom_dir`) oder explizite Dateipfade (`set_custom_path`). + - **Individuell anpassbar**: Dateinamen (`set_config_name`), Verzeichnisse (`set_custom_dir`) oder explizite Dateipfade (`set_custom_path`). Der Programmname wird automatisch über `program-ctdra` ermittelt. - **Globales Caching (`get_config::()`)**: Einmaliges Laden und threadsicheres Zwischenspeichern statischer Referenzen pro Konfigurationstyp. - **Atomare Operationen**: `load`, `store`, `modify`, `modify_config` für konsistentes Laden, Bearbeiten und Speichern. @@ -97,11 +97,9 @@ impl Default for Database { ### 2. Globales Caching mit `get_config` ```rust -use config_ctdra::{get_config, set_program_name}; +use config_ctdra::get_config; fn main() { - set_program_name("my-service"); - let cfg = get_config::(); println!("Log-Level: {}", cfg.general.log_level); println!("DB URL: {}", cfg.database.url); diff --git a/src/lib.rs b/src/lib.rs index 8473b72..60f6b3f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,13 +5,13 @@ //! - Einheitliche Pfadermittlung: //! - Bei Ausführung als Root (unter Linux/Unix): `/etc//.toml` //! - Bei regulärem Benutzer: Standard-Benutzer-Konfigurationsverzeichnis (z. B. `~/.config//.toml`) -//! - Anpassbar über `set_program_name`, `set_config_name`, `set_custom_dir` und `set_custom_path`. +//! - Anpassbar über `set_config_name`, `set_custom_dir` und `set_custom_path`. //! - Thread-sicheres globales Caching (`get_config::()`). //! - Atomares Laden, Speichern und Modifizieren (`load`, `store`, `modify`, `modify_config`). //! //! # Beispiel //! ```rust -//! use config_ctdra::{get_config, modify_config, set_program_name}; +//! use config_ctdra::{get_config, modify_config}; //! use serde::{Deserialize, Serialize}; //! //! #[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)] @@ -20,8 +20,6 @@ //! pub enable_ssl: bool, //! } //! -//! set_program_name("my-service"); -//! //! // Konfiguration abrufen (wird beim ersten Aufruf geladen und zwischengespeichert) //! let cfg = get_config::(); //! println!("Port: {}", cfg.server_port); @@ -34,13 +32,11 @@ use std::path::PathBuf; use std::sync::{OnceLock, RwLock}; pub use confy::ConfyError; +use program_ctdra::try_program_name; use serde::de::DeserializeOwned; use serde::Serialize; use sudo_ctdra::is_run_as_root; -/// Optionaler benutzerdefinierter Programmname für die Konfigurationspfade. -static PROGRAM_NAME: OnceLock>> = OnceLock::new(); - /// Optionaler benutzerdefinierter Konfigurationsdateiname (Standard: "config"). static CONFIG_NAME: OnceLock>> = OnceLock::new(); @@ -57,28 +53,13 @@ static GLOBAL_CONFIGS: OnceLock) { - let lock = PROGRAM_NAME.get_or_init(|| RwLock::new(None)); - if let Ok(mut guard) = lock.write() { - *guard = Some(name.into()); - } -} - -/// Ermittelt den konfigurierten Programmnamen (aus `set_program_name` oder `std::env::current_exe`). +/// Ermittelt den Programmnamen über `program-ctdra`. +/// +/// # Panics +/// +/// Löst eine Panik aus, wenn der Programmname nicht ermittelt werden kann. pub fn get_program_name() -> String { - if let Some(lock) = PROGRAM_NAME.get() { - if let Ok(guard) = lock.read() { - if let Some(name) = guard.as_ref() { - return name.clone(); - } - } - } - env::current_exe() - .ok() - .and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string())) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| "app".to_string()) + try_program_name().expect("Programmname konnte nicht ermittelt werden") } /// Setzt den Namen der Konfigurationsdatei (ohne Dateiendung `.toml`). @@ -302,181 +283,3 @@ where guard.insert(TypeId::of::(), boxed); boxed } - -#[cfg(test)] -mod tests { - use super::*; - use serde::{Deserialize, Serialize}; - use std::fs; - use std::sync::Mutex; - - static TEST_LOCK: Mutex<()> = Mutex::new(()); - - #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] - struct DummyAppConfig { - general: DummyGeneral, - } - - impl Default for DummyAppConfig { - fn default() -> Self { - Self { - general: DummyGeneral::default(), - } - } - } - - #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] - struct DummyGeneral { - log_level: String, - apps_dir: String, - } - - impl Default for DummyGeneral { - fn default() -> Self { - Self { - log_level: "info".to_string(), - apps_dir: "/var/apps".to_string(), - } - } - } - - #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] - struct CustomServerConfig { - host: String, - port: u16, - } - - impl Default for CustomServerConfig { - fn default() -> Self { - Self { - host: "127.0.0.1".to_string(), - port: 8080, - } - } - } - - #[test] - fn test_default_config_loading() { - let _guard = TEST_LOCK.lock().unwrap(); - let cfg = DummyAppConfig::default(); - assert_eq!(cfg.general.log_level, "info"); - assert_eq!(cfg.general.apps_dir, "/var/apps"); - } - - #[test] - fn test_program_and_config_name() { - let _guard = TEST_LOCK.lock().unwrap(); - set_program_name("test-app"); - assert_eq!(get_program_name(), "test-app"); - - set_config_name("settings"); - assert_eq!(get_config_name(), "settings"); - } - - #[test] - fn test_custom_path_and_dir() { - let _guard = TEST_LOCK.lock().unwrap(); - clear_custom_path(); - clear_custom_dir(); - set_config_name("config"); - - let temp_dir = env::temp_dir().join("test-config-crate-dir"); - let _ = fs::create_dir_all(&temp_dir); - - set_custom_dir(&temp_dir); - assert_eq!(get_custom_dir(), Some(temp_dir.clone())); - - let expected_path = temp_dir.join(format!("{}.toml", get_config_name())); - assert_eq!(get_config_path(), expected_path); - - let explicit_file = temp_dir.join("explicit.toml"); - set_custom_path(&explicit_file); - assert_eq!(get_custom_path(), Some(explicit_file.clone())); - assert_eq!(get_config_path(), explicit_file); - - clear_custom_path(); - clear_custom_dir(); - } - - #[test] - fn test_store_and_load_with_custom_path() { - let _guard = TEST_LOCK.lock().unwrap(); - let temp_file = env::temp_dir().join("test_store_load.toml"); - let _ = fs::remove_file(&temp_file); - - set_custom_path(&temp_file); - - let initial_cfg = CustomServerConfig { - host: "0.0.0.0".to_string(), - port: 9000, - }; - - let store_res = store(&initial_cfg); - assert!(store_res.is_ok()); - - let loaded_cfg: CustomServerConfig = load().expect("Failed to load stored config"); - assert_eq!(loaded_cfg, initial_cfg); - - // Test modify - let mod_res = modify::(|c| { - c.port = 9090; - }); - assert!(mod_res.is_ok()); - - let reloaded_cfg: CustomServerConfig = load().expect("Failed to reload modified config"); - assert_eq!(reloaded_cfg.port, 9090); - - let _ = fs::remove_file(&temp_file); - clear_custom_path(); - } - - #[test] - fn test_multiple_config_types_global() { - let _guard = TEST_LOCK.lock().unwrap(); - let file_a = env::temp_dir().join("test_global_type_a.toml"); - let _ = fs::remove_file(&file_a); - - set_custom_path(&file_a); - - let cfg_app: &'static DummyAppConfig = get_config(); - let cfg_server: &'static CustomServerConfig = get_config(); - - assert_eq!(cfg_app.general.apps_dir, "/var/apps"); - assert_eq!(cfg_server.port, 8080); - assert_eq!(cfg_server.host, "127.0.0.1"); - - let _ = fs::remove_file(&file_a); - clear_custom_path(); - } - - #[test] - fn test_modify_config_convenience() { - let _guard = TEST_LOCK.lock().unwrap(); - let temp_file = env::temp_dir().join("test_modify_convenience.toml"); - let _ = fs::remove_file(&temp_file); - - set_custom_path(&temp_file); - - let initial = CustomServerConfig { - host: "localhost".to_string(), - port: 3000, - }; - save_config(initial); - - modify_config::(|c| { - c.port = 4000; - }); - - let loaded = load_config::(); - assert_eq!(loaded.port, 4000); - assert_eq!(loaded.host, "localhost"); - - let _ = fs::remove_file(&temp_file); - clear_custom_path(); - } - - #[test] - fn test_is_run_as_root() { - let _ = is_run_as_root(); - } -} diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs new file mode 100644 index 0000000..2886996 --- /dev/null +++ b/tests/integration_tests.rs @@ -0,0 +1,404 @@ +use config_ctdra::{ + clear_custom_dir, clear_custom_path, get_config, get_config_name, get_config_path, + get_custom_dir, get_custom_path, get_program_name, load, load_config, modify, modify_config, + save_config, set_config_name, set_custom_dir, set_custom_path, store, ConfyError, +}; +use serde::{Deserialize, Serialize}; +use std::env; +use std::fs; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::thread; + +static TEST_LOCK: Mutex<()> = Mutex::new(()); + +fn lock_test() -> std::sync::MutexGuard<'static, ()> { + TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()) +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +struct DummyAppConfig { + general: DummyGeneral, +} + +impl Default for DummyAppConfig { + fn default() -> Self { + Self { + general: DummyGeneral::default(), + } + } +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +struct DummyGeneral { + log_level: String, + apps_dir: String, +} + +impl Default for DummyGeneral { + fn default() -> Self { + Self { + log_level: "info".to_string(), + apps_dir: "/var/apps".to_string(), + } + } +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +struct CustomServerConfig { + host: String, + port: u16, +} + +impl Default for CustomServerConfig { + fn default() -> Self { + Self { + host: "127.0.0.1".to_string(), + port: 8080, + } + } +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +struct DatabaseConfig { + url: String, + max_connections: u32, +} + +impl Default for DatabaseConfig { + fn default() -> Self { + Self { + url: "postgres://localhost/db".to_string(), + max_connections: 10, + } + } +} + +fn cleanup_temp_file(path: &PathBuf) { + let _ = fs::remove_file(path); +} + +#[test] +fn test_program_name_resolution() { + let _guard = lock_test(); + let name = get_program_name(); + assert!(!name.is_empty(), "Programmname darf nicht leer sein"); +} + +#[test] +fn test_config_name_getter_and_setter() { + let _guard = lock_test(); + set_config_name("custom_app_config"); + assert_eq!(get_config_name(), "custom_app_config"); + + set_config_name("config"); + assert_eq!(get_config_name(), "config"); +} + +#[test] +fn test_custom_dir_and_path_management() { + let _guard = lock_test(); + clear_custom_path(); + clear_custom_dir(); + set_config_name("app"); + + let temp_dir = env::temp_dir().join("test_custom_dir_crate"); + let _ = fs::create_dir_all(&temp_dir); + + // 1. Benutzerdefiniertes Verzeichnis + set_custom_dir(&temp_dir); + assert_eq!(get_custom_dir(), Some(temp_dir.clone())); + assert_eq!(get_config_path(), temp_dir.join("app.toml")); + + // 2. Benutzerdefiniertes Verzeichnis mit .toml im Config-Namen + set_config_name("app.toml"); + assert_eq!(get_config_path(), temp_dir.join("app.toml")); + set_config_name("app"); + + // 3. Expliziter benutzerdefinierter Pfad hat Vorrang vor Verzeichnis + let explicit_path = temp_dir.join("explicit_settings.toml"); + set_custom_path(&explicit_path); + assert_eq!(get_custom_path(), Some(explicit_path.clone())); + assert_eq!(get_config_path(), explicit_path); + + // 4. Zurücksetzen + clear_custom_path(); + assert_eq!(get_custom_path(), None); + assert_eq!(get_config_path(), temp_dir.join("app.toml")); + + clear_custom_dir(); + assert_eq!(get_custom_dir(), None); + set_config_name("config"); + let _ = fs::remove_dir_all(&temp_dir); +} + +#[test] +fn test_default_config_path_resolution() { + let _guard = lock_test(); + clear_custom_path(); + clear_custom_dir(); + set_config_name("config"); + + let path = get_config_path(); + let prog_name = get_program_name(); + let path_str = path.to_string_lossy(); + + assert!( + path_str.contains(&prog_name), + "Pfad {:?} sollte Programmnamen {} enthalten", + path, + prog_name + ); + assert!( + path_str.ends_with("config.toml"), + "Pfad {:?} sollte auf config.toml enden", + path + ); +} + +#[test] +fn test_default_config_loading() { + let _guard = lock_test(); + let cfg = DummyAppConfig::default(); + assert_eq!(cfg.general.log_level, "info"); + assert_eq!(cfg.general.apps_dir, "/var/apps"); +} + +#[test] +fn test_store_and_load_flow() { + let _guard = lock_test(); + let temp_file = env::temp_dir().join("test_store_and_load_flow.toml"); + cleanup_temp_file(&temp_file); + set_custom_path(&temp_file); + + let initial = CustomServerConfig { + host: "192.168.1.100".to_string(), + port: 9090, + }; + + // Speichern + let store_result = store(&initial); + assert!(store_result.is_ok(), "Store sollte erfolgreich sein"); + assert!(temp_file.exists(), "Konfigurationsdatei sollte existieren"); + + // Laden + let loaded: CustomServerConfig = load().expect("Load sollte erfolgreich sein"); + assert_eq!(loaded, initial); + + cleanup_temp_file(&temp_file); + clear_custom_path(); +} + +#[test] +fn test_load_config_fallback_and_invalid_toml() { + let _guard = lock_test(); + let corrupt_file = env::temp_dir().join("corrupt_config_test.toml"); + cleanup_temp_file(&corrupt_file); + fs::write(&corrupt_file, "INVALID_TOML_CONTENT = [[[[[").unwrap(); + set_custom_path(&corrupt_file); + + // load::() sollte bei ungültigem TOML fehlschlagen + let res: Result = load(); + assert!(res.is_err(), "Laden von korruptem TOML sollte mit ConfyError fehlschlagen"); + + // load_config::() fällt im Fehlerfall auf Default zurück + let loaded_default: CustomServerConfig = load_config(); + assert_eq!(loaded_default, CustomServerConfig::default()); + + cleanup_temp_file(&corrupt_file); + clear_custom_path(); +} + +#[test] +fn test_save_config_and_load_config() { + let _guard = lock_test(); + let temp_file = env::temp_dir().join("test_save_config_convenience.toml"); + cleanup_temp_file(&temp_file); + set_custom_path(&temp_file); + + let server_cfg = CustomServerConfig { + host: "0.0.0.0".to_string(), + port: 443, + }; + + save_config(server_cfg.clone()); + assert!(temp_file.exists()); + + let reloaded: CustomServerConfig = load_config(); + assert_eq!(reloaded, server_cfg); + + cleanup_temp_file(&temp_file); + clear_custom_path(); +} + +#[test] +fn test_modify_function() { + let _guard = lock_test(); + let temp_file = env::temp_dir().join("test_modify_function.toml"); + cleanup_temp_file(&temp_file); + set_custom_path(&temp_file); + + let initial = CustomServerConfig { + host: "10.0.0.1".to_string(), + port: 80, + }; + store(&initial).expect("Store initial config failed"); + + let modified_result = modify::(|cfg| { + cfg.port = 8081; + cfg.host = "10.0.0.2".to_string(); + }); + + assert!(modified_result.is_ok()); + let modified = modified_result.unwrap(); + assert_eq!(modified.port, 8081); + assert_eq!(modified.host, "10.0.0.2"); + + let loaded: CustomServerConfig = load().expect("Reload failed"); + assert_eq!(loaded.port, 8081); + assert_eq!(loaded.host, "10.0.0.2"); + + cleanup_temp_file(&temp_file); + clear_custom_path(); +} + +#[test] +fn test_modify_config_convenience() { + let _guard = lock_test(); + let temp_file = env::temp_dir().join("test_modify_config_convenience.toml"); + cleanup_temp_file(&temp_file); + set_custom_path(&temp_file); + + let initial = DatabaseConfig { + url: "sqlite://memory".to_string(), + max_connections: 5, + }; + save_config(initial); + + modify_config::(|cfg| { + cfg.max_connections = 25; + }); + + let loaded: DatabaseConfig = load_config(); + assert_eq!(loaded.max_connections, 25); + assert_eq!(loaded.url, "sqlite://memory"); + + cleanup_temp_file(&temp_file); + clear_custom_path(); +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +struct GlobalAppConfig { + service_name: String, +} + +impl Default for GlobalAppConfig { + fn default() -> Self { + Self { + service_name: "global_service".to_string(), + } + } +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +struct GlobalServerConfig { + host: String, + port: u16, +} + +impl Default for GlobalServerConfig { + fn default() -> Self { + Self { + host: "127.0.0.1".to_string(), + port: 8080, + } + } +} + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] +struct ConcurrentTestConfig { + worker_id: u32, +} + +impl Default for ConcurrentTestConfig { + fn default() -> Self { + Self { worker_id: 1 } + } +} + +#[test] +fn test_get_config_singleton_cache() { + let _guard = lock_test(); + let temp_file = env::temp_dir().join("test_get_config_singleton.toml"); + cleanup_temp_file(&temp_file); + set_custom_path(&temp_file); + + let initial = DatabaseConfig { + url: "postgres://prod-db:5432/main".to_string(), + max_connections: 50, + }; + save_config(initial.clone()); + + // Erster Aufruf: lädt und speichert im Cache + let ref1: &'static DatabaseConfig = get_config(); + assert_eq!(ref1.url, "postgres://prod-db:5432/main"); + assert_eq!(ref1.max_connections, 50); + + // Zweiter Aufruf: liefert dieselbe statische Referenz + let ref2: &'static DatabaseConfig = get_config(); + assert!(std::ptr::eq(ref1, ref2), "get_config muss dieselbe Referenz zurückgeben"); + + cleanup_temp_file(&temp_file); + clear_custom_path(); +} + +#[test] +fn test_multiple_config_types_in_global_cache() { + let _guard = lock_test(); + let temp_file = env::temp_dir().join("test_multiple_types_cache.toml"); + cleanup_temp_file(&temp_file); + set_custom_path(&temp_file); + + let app_cfg: &'static GlobalAppConfig = get_config(); + let srv_cfg: &'static GlobalServerConfig = get_config(); + + assert_eq!(app_cfg.service_name, "global_service"); + assert_eq!(srv_cfg.port, 8080); + assert_eq!(srv_cfg.host, "127.0.0.1"); + + cleanup_temp_file(&temp_file); + clear_custom_path(); +} + +#[test] +fn test_concurrent_access() { + let _guard = lock_test(); + let temp_file = Arc::new(env::temp_dir().join("test_concurrent_access.toml")); + cleanup_temp_file(&temp_file); + set_custom_path(&*temp_file); + + let initial = ConcurrentTestConfig { worker_id: 42 }; + save_config(initial); + + let mut handles = Vec::new(); + for i in 0..10 { + let handle = thread::spawn(move || { + let cfg: &'static ConcurrentTestConfig = get_config(); + assert_eq!(cfg.worker_id, 42); + let _ = load_config::(); + if i % 2 == 0 { + modify_config::(|c| { + c.worker_id = 1000 + i; + }); + } + }); + handles.push(handle); + } + + for handle in handles { + handle.join().unwrap(); + } + + cleanup_temp_file(&temp_file); + clear_custom_path(); +}