use serde::Deserialize; use std::collections::BTreeMap; 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"; const VALID_KINDS: &[&str] = &["bin", "staticlib", "sharedlib", "efi", "elf"]; #[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, typed: DcrConfig, } #[allow(dead_code)] #[derive(Debug, Clone, Deserialize)] pub struct DcrConfig { pub package: PackageConfig, pub build: BuildConfig, #[serde(default)] pub dependencies: BTreeMap, #[serde(default)] pub toolchain: Option, #[serde(default)] pub workspace: BTreeMap, } #[derive(Debug, Clone, Deserialize)] pub struct PackageConfig { pub name: String, pub version: String, #[serde(default, rename = "type")] pub pkg_type: Option, } #[allow(dead_code)] #[derive(Debug, Clone, Deserialize)] pub struct BuildConfig { pub language: LanguageConfig, pub standard: String, pub compiler: String, #[serde(default)] pub kind: Option, #[serde(default)] pub target: Option, #[serde(default)] pub platform: Option, #[serde(default)] pub cflags: Vec, #[serde(default)] pub ldflags: Vec, #[serde(default)] pub exclude: Vec, #[serde(default)] pub include: Vec, #[serde(default)] pub roots: Vec, #[serde(default)] pub clean: Vec, #[serde(default)] pub src_disable: Option, } #[derive(Debug, Clone, Deserialize)] #[serde(untagged)] pub enum LanguageConfig { One(String), Many(Vec), } impl LanguageConfig { fn values(&self) -> Vec<&str> { match self { LanguageConfig::One(value) => vec![value.as_str()], LanguageConfig::Many(values) => values.iter().map(String::as_str).collect(), } } } #[allow(dead_code)] #[derive(Debug, Clone, Deserialize)] #[serde(untagged)] pub enum DependencyConfig { Version(String), Table(BTreeMap), } #[derive(Debug, Clone, Deserialize, Default)] pub struct ToolchainConfig { pub cc: Option, pub cxx: Option, #[serde(rename = "as")] pub assembler: Option, pub ar: Option, pub ld: Option, } #[allow(dead_code)] #[derive(Debug, Clone, Deserialize)] pub struct WorkspaceMemberConfig { pub path: String, #[serde(default)] pub deps: Vec, } 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 typed = parse_typed_config(&data)?; let cfg = Self { path, data, typed }; 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 typed = parse_typed_config(&data)?; let cfg = Self { path, data, typed }; cfg.validate()?; Ok(cfg) } #[allow(dead_code)] pub fn typed(&self) -> &DcrConfig { &self.typed } #[allow(dead_code)] pub fn package(&self) -> &PackageConfig { &self.typed.package } #[allow(dead_code)] pub fn build_config(&self) -> &BuildConfig { &self.typed.build } 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> { if self.typed.package.name.trim().is_empty() { return Err(ConfigError::Invalid("package.name is empty".into())); } if self.typed.package.version.trim().is_empty() { return Err(ConfigError::Invalid("package.version is empty".into())); } validate_package_name(&self.typed.package.name)?; if let Some(pkg_type) = &self.typed.package.pkg_type { let pkg_type = pkg_type.trim(); if !pkg_type.is_empty() && pkg_type != "lib" && pkg_type != "app" && pkg_type != "none" { return Err(ConfigError::Invalid( "package.type must be 'lib', 'app', or 'none'".into(), )); } } validate_language_config(&self.typed.build.language, "build.language")?; if self.typed.build.standard.trim().is_empty() { return Err(ConfigError::Invalid("build.standard is empty".into())); } if self.typed.build.compiler.trim().is_empty() { return Err(ConfigError::Invalid("build.compiler is empty".into())); } if let Some(platform) = &self.typed.build.platform && platform.trim().is_empty() { return Err(ConfigError::Invalid("build.platform is empty".into())); } validate_toolchain(self.typed.toolchain.as_ref())?; if let Some(kind) = &self.typed.build.kind { let kind = kind.trim(); if !kind.is_empty() && !VALID_KINDS.contains(&kind) { return Err(ConfigError::Invalid("build.kind is invalid".into())); } } validate_string_list(&self.typed.build.exclude, "build.exclude")?; validate_string_list(&self.typed.build.include, "build.include")?; validate_string_list(&self.typed.build.roots, "build.roots")?; validate_string_list(&self.typed.build.clean, "build.clean")?; validate_string_list(&self.typed.build.cflags, "build.cflags")?; validate_string_list(&self.typed.build.ldflags, "build.ldflags")?; if let Some(target) = &self.typed.build.target { validate_non_empty_string(target, "build.target")?; } for profile in ["release", "debug"] { if let Some(section) = self .data .get("build") .and_then(|v| v.as_table()) .and_then(|build| 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(); let previous = self.data.clone(); set_path(&mut self.data, &parts, value)?; self.typed = match parse_typed_config(&self.data) { Ok(typed) => typed, Err(err) => { self.data = previous; return Err(err); } }; if let Err(err) = self.validate() { self.data = previous; self.typed = parse_typed_config(&self.data)?; return Err(err); } self.save()?; Ok(()) } } fn parse_typed_config(value: &Value) -> Result { value.clone().try_into().map_err(ConfigError::TomlDe) } fn validate_package_name(name: &str) -> Result<(), ConfigError> { let trimmed = name.trim(); if trimmed != name { return Err(ConfigError::Invalid( "package.name must not contain leading or trailing whitespace".into(), )); } if trimmed == "." || trimmed == ".." || trimmed.contains("..") { return Err(ConfigError::Invalid("package.name is invalid".into())); } if !trimmed .chars() .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') { return Err(ConfigError::Invalid( "package.name must contain only ASCII letters, digits, '_' or '-'".into(), )); } Ok(()) } fn validate_language_config(language: &LanguageConfig, key: &str) -> Result<(), ConfigError> { let values = language.values(); if values.is_empty() { return Err(ConfigError::Invalid(format!("{key} is empty"))); } for value in values { validate_non_empty_string(value, key)?; } Ok(()) } fn validate_toolchain(toolchain: Option<&ToolchainConfig>) -> Result<(), ConfigError> { let Some(toolchain) = toolchain else { return Ok(()); }; for (key, value) in [ ("cc", toolchain.cc.as_deref()), ("cxx", toolchain.cxx.as_deref()), ("as", toolchain.assembler.as_deref()), ("ar", toolchain.ar.as_deref()), ("ld", toolchain.ld.as_deref()), ] { if let Some(value) = value { validate_non_empty_string(value, &format!("toolchain.{key}"))?; } } Ok(()) } fn validate_string_list(values: &[String], key: &str) -> Result<(), ConfigError> { for value in values { validate_non_empty_string(value, key)?; } Ok(()) } fn validate_non_empty_string(value: &str, key: &str) -> Result<(), ConfigError> { if value.trim().is_empty() { return Err(ConfigError::Invalid(format!("{key} contains empty value"))); } 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() && !VALID_KINDS.contains(&kind) { 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()), ); package.insert("type".to_string(), Value::String("none".to_string())); package.insert("description".to_string(), Value::String("".to_string())); package.insert("author".to_string(), Value::String("".to_string())); package.insert("license".to_string(), Value::String("MIT".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")); for key in [ "type", "description", "author", "authors", "homepage", "license", "repository", "readme", "keywords", "categories", ] { if let Some(val) = package.get(key) { match val { Value::String(s) => out.push_str(&format!("{key} = \"{s}\"\n")), Value::Array(_) => out.push_str(&format!("{key} = {}\n", format_string_array(val))), _ => {} } } } out.push('\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("version").and_then(|v| v.as_str()) { parts.push(format!("version = \"{v}\"")); } if let Some(v) = tbl.get("path").and_then(|v| v.as_str()) { parts.push(format!("path = \"{v}\"")); } if let Some(v) = tbl.get("git").and_then(|v| v.as_str()) { parts.push(format!("git = \"{v}\"")); } if let Some(v) = tbl.get("branch").and_then(|v| v.as_str()) { parts.push(format!("branch = \"{v}\"")); } if let Some(v) = tbl.get("tag").and_then(|v| v.as_str()) { parts.push(format!("tag = \"{v}\"")); } if let Some(v) = tbl.get("rev").and_then(|v| v.as_str()) { parts.push(format!("rev = \"{v}\"")); } if let Some(v) = tbl.get("default-features").and_then(|v| v.as_bool()) { parts.push(format!( "default-features = {}", if v { "true" } else { "false" } )); } if let Some(v) = tbl.get("features") { parts.push(format!("features = {}", format_string_array(v))); } if let Some(v) = tbl.get("system").and_then(|v| v.as_bool()) { parts.push(format!("system = {}", if v { "true" } else { "false" })); } 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 exposes_typed_config() { let dir = temp_dir("typed_config"); let path = write_toml_file( &dir, "[package]\nname = \"typed\"\nversion = \"1.2.3\"\ntype = \"lib\"\n\n[build]\nlanguage = [\"c\", \"c++\"]\nstandard = \"c11\"\ncompiler = \"clang\"\nkind = \"staticlib\"\ncflags = [\"-Wall\"]\n\n[dependencies]\nfoo = \"1.0.0\"\n", ); let config = Config::open(&path.to_string_lossy()).unwrap(); assert_eq!(config.package().name, "typed"); assert_eq!(config.typed().package.version, "1.2.3"); assert_eq!(config.build_config().compiler, "clang"); assert_eq!(config.build_config().cflags, ["-Wall"]); assert!(config.typed().dependencies.contains_key("foo")); } #[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_wrong_field_type_fails() { let dir = temp_dir("wrong_type"); let path = write_toml_file( &dir, "[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n[build]\nlanguage = \"c\"\nstandard = \"c11\"\ncompiler = [\"clang\"]\nkind = \"bin\"\n\n[dependencies]\n", ); let result = Config::open(&path.to_string_lossy()); assert!( result.is_err(), "typed config should reject wrong field types" ); } #[test] fn validate_invalid_package_names_fail() { for name in [ "../evil", "bad/name", "bad name", "", " name", "name ", "a.b", ] { let dir = temp_dir("bad_name"); let content = format!( "[package]\nname = \"{name}\"\nversion = \"0.1.0\"\n\n[build]\nlanguage = \"c\"\nstandard = \"c11\"\ncompiler = \"clang\"\nkind = \"bin\"\n\n[dependencies]\n" ); let path = write_toml_file(&dir, &content); let result = Config::open(&path.to_string_lossy()); assert!(result.is_err(), "package name `{name}` should fail"); } } #[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 VALID_KINDS { let dir = temp_dir("valid_kind"); let toml = 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, &toml); assert!( Config::open(&path.to_string_lossy()).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"); } }