use std::fs; use std::path::{Path, PathBuf}; use toml::Value; use toml::map::Map; const DEFAULT_VERSION: &str = "0.1.0"; const DEFAULT_LANGUAGE: &str = "c"; const DEFAULT_STANDARD: &str = "c11"; const DEFAULT_COMPILER: &str = "clang"; const DEFAULT_KIND: &str = "bin"; #[derive(Debug)] pub enum ConfigError { Io(std::io::Error), TomlDe(toml::de::Error), TomlSer(toml::ser::Error), Invalid(String), } impl std::fmt::Display for ConfigError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ConfigError::Io(err) => write!(f, "I/O error: {err}"), ConfigError::TomlDe(err) => write!(f, "TOML parse error: {err}"), ConfigError::TomlSer(err) => write!(f, "TOML serialize error: {err}"), ConfigError::Invalid(msg) => write!(f, "Invalid config: {msg}"), } } } impl std::error::Error for ConfigError {} impl From for ConfigError { fn from(err: std::io::Error) -> Self { ConfigError::Io(err) } } impl From for ConfigError { fn from(err: toml::de::Error) -> Self { ConfigError::TomlDe(err) } } impl From for ConfigError { fn from(err: toml::ser::Error) -> Self { ConfigError::TomlSer(err) } } pub struct Config { path: PathBuf, data: Value, } impl Config { pub fn new(path: &str) -> Result { let path = PathBuf::from(path); let data = if path.exists() { read_toml(&path)? } else { let default_value = default_toml()?; write_toml(&path, &default_value)?; default_value }; let cfg = Self { path, data }; cfg.validate()?; Ok(cfg) } pub fn open(path: &str) -> Result { let path = PathBuf::from(path); if !path.exists() { return Err(ConfigError::Invalid("dcr.toml not found".into())); } let data = read_toml(&path)?; let cfg = Self { path, data }; cfg.validate()?; Ok(cfg) } pub fn get(&self, key: &str) -> Option<&Value> { let parts: Vec<&str> = key.split('.').collect(); get_path(&self.data, &parts) } #[allow(dead_code)] pub fn add(&mut self, key: &str, value: Value) -> Result<(), ConfigError> { self.set(key, value) } pub fn edit(&mut self, key: &str, value: Value) -> Result<(), ConfigError> { self.set(key, value) } #[allow(dead_code)] pub fn check(&self) -> bool { self.validate().is_ok() } pub fn validate(&self) -> Result<(), ConfigError> { let package = self .get("package") .and_then(|v| v.as_table()) .ok_or_else(|| ConfigError::Invalid("missing [package]".into()))?; for key in ["name", "version"] { let value = package.get(key).and_then(|v| v.as_str()).unwrap_or(""); if value.trim().is_empty() { return Err(ConfigError::Invalid(format!("package.{key} is empty"))); } } let build = self .get("build") .and_then(|v| v.as_table()) .ok_or_else(|| ConfigError::Invalid("missing [build]".into()))?; if let Some(language) = build.get("language") { self.validate_language(language)?; } else { return Err(ConfigError::Invalid("build.language is empty".into())); } for key in ["standard", "compiler"] { let value = build.get(key).and_then(|v| v.as_str()).unwrap_or(""); if value.trim().is_empty() { return Err(ConfigError::Invalid(format!("build.{key} is empty"))); } } if let Some(platform) = build.get("platform") && !platform .as_str() .map(|v| !v.trim().is_empty()) .unwrap_or(false) { return Err(ConfigError::Invalid("build.platform is empty".into())); } if let Some(toolchain) = self.get("toolchain").and_then(|v| v.as_table()) { for key in ["cc", "cxx", "as", "ar", "ld"] { if let Some(v) = toolchain.get(key) && !v.as_str().map(|s| !s.trim().is_empty()).unwrap_or(false) { return Err(ConfigError::Invalid(format!("toolchain.{key} is invalid"))); } } } if let Some(kind) = build.get("kind").and_then(|v| v.as_str()) { let kind = kind.trim(); if !kind.is_empty() && kind != "bin" && kind != "staticlib" && kind != "sharedlib" { return Err(ConfigError::Invalid("build.kind is invalid".into())); } } if let Some(exclude) = build.get("exclude") { let arr = exclude.as_array().ok_or_else(|| { ConfigError::Invalid("build.exclude must be an array of strings".into()) })?; for item in arr { let s = item.as_str().unwrap_or(""); if s.trim().is_empty() { return Err(ConfigError::Invalid( "build.exclude contains empty value".into(), )); } } } if let Some(include) = build.get("include") { let arr = include.as_array().ok_or_else(|| { ConfigError::Invalid("build.include must be an array of strings".into()) })?; for item in arr { let s = item.as_str().unwrap_or(""); if s.trim().is_empty() { return Err(ConfigError::Invalid( "build.include contains empty value".into(), )); } } } if let Some(roots) = build.get("roots") { let arr = roots.as_array().ok_or_else(|| { ConfigError::Invalid("build.roots must be an array of strings".into()) })?; for item in arr { let s = item.as_str().unwrap_or(""); if s.trim().is_empty() { return Err(ConfigError::Invalid( "build.roots contains empty value".into(), )); } } } if let Some(src_disable) = build.get("src_disable") && !src_disable.is_bool() { return Err(ConfigError::Invalid( "build.src_disable must be a boolean".into(), )); } if let Some(clean) = build.get("clean") { let arr = clean.as_array().ok_or_else(|| { ConfigError::Invalid("build.clean must be an array of strings".into()) })?; for item in arr { let s = item.as_str().unwrap_or(""); if s.trim().is_empty() { return Err(ConfigError::Invalid( "build.clean contains empty value".into(), )); } } } for profile in ["release", "debug"] { if let Some(section) = build.get(profile) { let table = section.as_table().ok_or_else(|| { ConfigError::Invalid(format!("build.{profile} must be a table")) })?; self.validate_profile_section(profile, table)?; } } self.validate_workspace()?; Ok(()) } pub fn save(&self) -> Result<(), ConfigError> { write_toml(&self.path, &self.data) } fn set(&mut self, key: &str, value: Value) -> Result<(), ConfigError> { let parts: Vec<&str> = key.split('.').collect(); set_path(&mut self.data, &parts, value)?; self.save()?; Ok(()) } } impl Config { fn validate_language(&self, value: &Value) -> Result<(), ConfigError> { if let Some(s) = value.as_str() { if s.trim().is_empty() { return Err(ConfigError::Invalid("build.language is empty".into())); } return Ok(()); } let arr = value .as_array() .ok_or_else(|| ConfigError::Invalid("build.language must be string or array".into()))?; if arr.is_empty() { return Err(ConfigError::Invalid("build.language is empty".into())); } for item in arr { let s = item.as_str().unwrap_or(""); if s.trim().is_empty() { return Err(ConfigError::Invalid( "build.language contains empty value".into(), )); } } Ok(()) } fn validate_profile_section( &self, profile: &str, table: &toml::value::Table, ) -> Result<(), ConfigError> { if let Some(lang) = table.get("language") { self.validate_language(lang)?; } for key in ["standard", "compiler", "kind", "target", "platform"] { if let Some(value) = table.get(key) { let s = value.as_str().unwrap_or(""); if s.trim().is_empty() { return Err(ConfigError::Invalid(format!( "build.{profile}.{key} is empty" ))); } } } if let Some(kind) = table.get("kind").and_then(|v| v.as_str()) { let kind = kind.trim(); if !kind.is_empty() && kind != "bin" && kind != "staticlib" && kind != "sharedlib" { return Err(ConfigError::Invalid(format!( "build.{profile}.kind is invalid" ))); } } if let Some(src_disable) = table.get("src_disable") && !src_disable.is_bool() { return Err(ConfigError::Invalid(format!( "build.{profile}.src_disable must be boolean" ))); } for key in [ "cflags", "ldflags", "exclude", "include", "roots", "pkg_config", "generated", "expect", "clean", "targets", ] { if let Some(val) = table.get(key) { let arr = val.as_array().ok_or_else(|| { ConfigError::Invalid(format!( "build.{profile}.{key} must be an array of strings" )) })?; for item in arr { let s = item.as_str().unwrap_or(""); if s.trim().is_empty() { return Err(ConfigError::Invalid(format!( "build.{profile}.{key} contains empty value" ))); } } } } for key in ["steps", "post_steps"] { if let Some(val) = table.get(key) { let arr = val.as_array().ok_or_else(|| { ConfigError::Invalid(format!("build.{profile}.{key} must be an array")) })?; for item in arr { let tbl = item.as_table().ok_or_else(|| { ConfigError::Invalid(format!( "build.{profile}.{key} entries must be tables" )) })?; for req in ["name", "in", "out", "cmd"] { let s = tbl.get(req).and_then(|v| v.as_str()).unwrap_or(""); if s.trim().is_empty() { return Err(ConfigError::Invalid(format!( "build.{profile}.{key} missing {req}" ))); } } } } } Ok(()) } fn validate_workspace(&self) -> Result<(), ConfigError> { let Some(workspace) = self.get("workspace").and_then(|v| v.as_table()) else { return Ok(()); }; for (name, value) in workspace { let tbl = value .as_table() .ok_or_else(|| ConfigError::Invalid(format!("workspace.{name} must be a table")))?; let path = tbl.get("path").and_then(|v| v.as_str()).unwrap_or(""); if path.trim().is_empty() { return Err(ConfigError::Invalid(format!( "workspace.{name}.path is empty" ))); } if let Some(deps) = tbl.get("deps") { let arr = deps.as_array().ok_or_else(|| { ConfigError::Invalid(format!("workspace.{name}.deps must be array")) })?; for item in arr { let s = item.as_str().unwrap_or(""); if s.trim().is_empty() { return Err(ConfigError::Invalid(format!( "workspace.{name}.deps contains empty value" ))); } } } } Ok(()) } } fn read_toml(path: &Path) -> Result { let content = fs::read_to_string(path)?; Ok(toml::from_str(&content)?) } fn write_toml(path: &Path, value: &Value) -> Result<(), ConfigError> { let content = format_toml(value)?; fs::write(path, content)?; Ok(()) } fn default_toml() -> Result { let name = std::env::current_dir() .ok() .and_then(|p| p.file_name().map(|v| v.to_string_lossy().to_string())) .unwrap_or_else(|| "project".to_string()); let mut package = Map::new(); package.insert("name".to_string(), Value::String(name)); package.insert( "version".to_string(), Value::String(DEFAULT_VERSION.to_string()), ); let mut build = Map::new(); build.insert( "language".to_string(), Value::String(DEFAULT_LANGUAGE.to_string()), ); build.insert( "standard".to_string(), Value::String(DEFAULT_STANDARD.to_string()), ); build.insert( "compiler".to_string(), Value::String(DEFAULT_COMPILER.to_string()), ); build.insert("kind".to_string(), Value::String(DEFAULT_KIND.to_string())); let mut root = Map::new(); root.insert("package".to_string(), Value::Table(package)); root.insert("build".to_string(), Value::Table(build)); root.insert("dependencies".to_string(), Value::Table(Map::new())); Ok(Value::Table(root)) } fn format_toml(value: &Value) -> Result { let root = value .as_table() .ok_or_else(|| ConfigError::Invalid("root is not a table".into()))?; let package = root .get("package") .and_then(|v| v.as_table()) .ok_or_else(|| ConfigError::Invalid("missing [package]".into()))?; let build = root .get("build") .and_then(|v| v.as_table()) .ok_or_else(|| ConfigError::Invalid("missing [build]".into()))?; let deps = root .get("dependencies") .and_then(|v| v.as_table()) .ok_or_else(|| ConfigError::Invalid("missing [dependencies]".into()))?; let toolchain = root.get("toolchain").and_then(|v| v.as_table()); let name = package.get("name").and_then(|v| v.as_str()).unwrap_or(""); let version = package .get("version") .and_then(|v| v.as_str()) .unwrap_or(""); let language_value = build.get("language"); let language = match language_value { Some(Value::String(s)) => s.to_string(), Some(Value::Array(arr)) => format_string_array(&Value::Array(arr.clone())), _ => "".to_string(), }; let standard = build.get("standard").and_then(|v| v.as_str()).unwrap_or(""); let compiler = build.get("compiler").and_then(|v| v.as_str()).unwrap_or(""); let mut out = String::new(); out.push_str("[package]\n"); out.push_str(&format!("name = \"{name}\"\n")); out.push_str(&format!("version = \"{version}\"\n\n")); out.push_str("[build]\n"); if language.starts_with('[') { out.push_str(&format!("language = {language}\n")); } else { out.push_str(&format!("language = \"{language}\"\n")); } out.push_str(&format!("standard = \"{standard}\"\n")); out.push_str(&format!("compiler = \"{compiler}\"\n")); let kind = build .get("kind") .and_then(|v| v.as_str()) .unwrap_or(DEFAULT_KIND); out.push_str(&format!("kind = \"{kind}\"\n")); if let Some(target) = build.get("target").and_then(|v| v.as_str()) && !target.trim().is_empty() { out.push_str(&format!("target = \"{target}\"\n")); } if let Some(cflags) = build.get("cflags") { out.push_str(&format!("cflags = {}\n", format_string_array(cflags))); } if let Some(ldflags) = build.get("ldflags") { out.push_str(&format!("ldflags = {}\n", format_string_array(ldflags))); } out.push('\n'); if let Some(toolchain) = toolchain { let mut lines = Vec::new(); for key in ["cc", "cxx", "as", "ar", "ld"] { if let Some(value) = toolchain.get(key).and_then(|v| v.as_str()) && !value.trim().is_empty() { lines.push(format!("{key} = \"{value}\"")); } } if !lines.is_empty() { out.push_str("[toolchain]\n"); for line in lines { out.push_str(&format!("{line}\n")); } out.push('\n'); } } out.push_str("[dependencies]\n"); if !deps.is_empty() { let mut keys: Vec<&String> = deps.keys().collect(); keys.sort(); for key in keys { if let Some(val) = deps.get(key) { out.push_str(&format!("{key} = {}\n", format_dep_value(val))); } } } Ok(out) } fn format_dep_value(value: &Value) -> String { match value { Value::String(s) => format!("\"{s}\""), Value::Table(tbl) => { let mut parts = Vec::new(); if let Some(v) = tbl.get("path").and_then(|v| v.as_str()) { parts.push(format!("path = \"{v}\"")); } if let Some(v) = tbl.get("system").and_then(|v| v.as_bool()) { parts.push(format!("system = {}", if v { "true" } else { "false" })); } if let Some(v) = tbl.get("include") { parts.push(format!("include = {}", format_string_array(v))); } if let Some(v) = tbl.get("lib") { parts.push(format!("lib = {}", format_string_array(v))); } if let Some(v) = tbl.get("libs") { parts.push(format!("libs = {}", format_string_array(v))); } format!("{{ {} }}", parts.join(", ")) } _ => "\"\"".to_string(), } } fn format_string_array(value: &Value) -> String { if let Some(arr) = value.as_array() { let items: Vec = arr .iter() .filter_map(|v| v.as_str().map(|s| format!("\"{s}\""))) .collect(); return format!("[{}]", items.join(", ")); } "[]".to_string() } fn get_path<'a>(value: &'a Value, path: &[&str]) -> Option<&'a Value> { let mut current = value; for key in path { current = current.as_table()?.get(*key)?; } Some(current) } fn set_path(value: &mut Value, path: &[&str], new_value: Value) -> Result<(), ConfigError> { let mut current = value .as_table_mut() .ok_or_else(|| ConfigError::Invalid("root is not a table".into()))?; for key in &path[..path.len().saturating_sub(1)] { if !current.contains_key(*key) { current.insert((*key).to_string(), Value::Table(Map::new())); } current = current .get_mut(*key) .and_then(|v| v.as_table_mut()) .ok_or_else(|| ConfigError::Invalid(format!("'{key}' is not a table")))?; } if let Some(last) = path.last() { current.insert((*last).to_string(), new_value); Ok(()) } else { Err(ConfigError::Invalid("empty key".into())) } } #[cfg(test)] mod tests { use super::*; use std::sync::atomic::{AtomicUsize, Ordering}; static TEST_COUNTER: AtomicUsize = AtomicUsize::new(0); fn temp_dir(prefix: &str) -> PathBuf { let n = TEST_COUNTER.fetch_add(1, Ordering::SeqCst); let dir = std::env::temp_dir().join(format!("dcr_cfg_test_{prefix}_{n}")); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(&dir).unwrap(); dir } fn write_toml_file(dir: &Path, content: &str) -> PathBuf { let path = dir.join("dcr.toml"); fs::write(&path, content).unwrap(); path } fn minimal_valid_toml() -> &'static str { "[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n[build]\nlanguage = \"c\"\nstandard = \"c11\"\ncompiler = \"clang\"\nkind = \"bin\"\n\n[dependencies]\n" } #[test] fn open_valid_toml() { let dir = temp_dir("open_valid"); let path = write_toml_file(&dir, minimal_valid_toml()); let config = Config::open(&path.to_string_lossy()); assert!(config.is_ok(), "should open valid toml"); } #[test] fn open_invalid_toml_syntax() { let dir = temp_dir("open_invalid"); let path = write_toml_file(&dir, "this is not [valid toml !!!"); let config = Config::open(&path.to_string_lossy()); assert!(config.is_err(), "should fail on invalid TOML syntax"); } #[test] fn open_nonexistent_fails() { let result = Config::open("/tmp/dcr_nonexistent_file_12345.toml"); assert!(result.is_err(), "should fail on nonexistent file"); } #[test] fn validate_missing_package_fails() { let dir = temp_dir("no_package"); let path = write_toml_file( &dir, "[build]\nlanguage = \"c\"\nstandard = \"c11\"\ncompiler = \"clang\"\n\n[dependencies]\n", ); let result = Config::open(&path.to_string_lossy()); assert!(result.is_err(), "missing [package] should fail validation"); } #[test] fn validate_missing_build_fails() { let dir = temp_dir("no_build"); let path = write_toml_file( &dir, "[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n[dependencies]\n", ); let result = Config::open(&path.to_string_lossy()); assert!(result.is_err(), "missing [build] should fail validation"); } #[test] fn validate_empty_language_fails() { let dir = temp_dir("empty_lang"); let path = write_toml_file( &dir, "[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n[build]\nlanguage = \"\"\nstandard = \"c11\"\ncompiler = \"clang\"\n\n[dependencies]\n", ); let result = Config::open(&path.to_string_lossy()); assert!(result.is_err(), "empty language should fail validation"); } #[test] fn validate_language_array() { let dir = temp_dir("lang_array"); let path = write_toml_file( &dir, "[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n[build]\nlanguage = [\"c\", \"c++\", \"asm\"]\nstandard = \"c11\"\ncompiler = \"clang\"\n\n[dependencies]\n", ); let result = Config::open(&path.to_string_lossy()); assert!(result.is_ok(), "language array should be valid"); } #[test] fn validate_language_array_empty_fails() { let dir = temp_dir("lang_array_empty"); let path = write_toml_file( &dir, "[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n[build]\nlanguage = []\nstandard = \"c11\"\ncompiler = \"clang\"\n\n[dependencies]\n", ); let result = Config::open(&path.to_string_lossy()); assert!(result.is_err(), "empty language array should fail"); } #[test] fn validate_unknown_kind_fails() { let dir = temp_dir("bad_kind"); let path = write_toml_file( &dir, "[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n[build]\nlanguage = \"c\"\nstandard = \"c11\"\ncompiler = \"clang\"\nkind = \"exe\"\n\n[dependencies]\n", ); let result = Config::open(&path.to_string_lossy()); assert!(result.is_err(), "unknown kind 'exe' should fail validation"); } #[test] fn validate_valid_kinds() { for kind in ["bin", "staticlib", "sharedlib"] { let dir = temp_dir(&format!("kind_{kind}")); let content = format!( "[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n[build]\nlanguage = \"c\"\nstandard = \"c11\"\ncompiler = \"clang\"\nkind = \"{kind}\"\n\n[dependencies]\n" ); let path = write_toml_file(&dir, &content); let result = Config::open(&path.to_string_lossy()); assert!(result.is_ok(), "kind '{kind}' should be valid"); } } #[test] fn get_values() { let dir = temp_dir("get_values"); let path = write_toml_file(&dir, minimal_valid_toml()); let config = Config::open(&path.to_string_lossy()).unwrap(); assert_eq!( config.get("package.name").and_then(|v| v.as_str()), Some("test") ); assert_eq!( config.get("build.language").and_then(|v| v.as_str()), Some("c") ); assert_eq!( config.get("build.kind").and_then(|v| v.as_str()), Some("bin") ); assert!(config.get("nonexistent.key").is_none()); } #[test] fn set_and_read_back() { let dir = temp_dir("set_value"); let path = write_toml_file(&dir, minimal_valid_toml()); let mut config = Config::open(&path.to_string_lossy()).unwrap(); config .set("package.name", Value::String("newname".to_string())) .unwrap(); assert_eq!( config.get("package.name").and_then(|v| v.as_str()), Some("newname") ); // Verify persisted to disk let config2 = Config::open(&path.to_string_lossy()).unwrap(); assert_eq!( config2.get("package.name").and_then(|v| v.as_str()), Some("newname") ); } #[test] fn new_creates_default_config() { let dir = temp_dir("new_default"); let path = dir.join("dcr.toml"); let config = Config::new(&path.to_string_lossy()).unwrap(); assert!(path.exists(), "dcr.toml should be created"); assert_eq!( config.get("build.language").and_then(|v| v.as_str()), Some("c") ); assert_eq!( config.get("build.standard").and_then(|v| v.as_str()), Some("c11") ); assert_eq!( config.get("build.compiler").and_then(|v| v.as_str()), Some("clang") ); assert_eq!( config.get("build.kind").and_then(|v| v.as_str()), Some("bin") ); } #[test] fn validate_workspace_empty_path_fails() { let dir = temp_dir("ws_empty_path"); let path = write_toml_file( &dir, "[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n[build]\nlanguage = \"c\"\nstandard = \"c11\"\ncompiler = \"clang\"\n\n[workspace]\n[workspace.member1]\npath = \"\"\n\n[dependencies]\n", ); let result = Config::open(&path.to_string_lossy()); assert!( result.is_err(), "workspace member with empty path should fail" ); } #[test] fn check_returns_bool() { let dir = temp_dir("check_bool"); let path = write_toml_file(&dir, minimal_valid_toml()); let config = Config::open(&path.to_string_lossy()).unwrap(); assert!(config.check(), "valid config should return true"); } }