use std::fmt;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Dialect {
#[default]
StrictInk,
Brink,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TypePolicy {
#[default]
Gradual,
Strict,
}
use thiserror::Error;
use toml::Value;
pub const CONFIG_FILE_NAME: &str = "brink.toml";
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ProjectConfig {
pub dialect: Option<Dialect>,
pub types: Option<TypePolicy>,
}
impl ProjectConfig {
#[must_use]
pub fn is_empty(&self) -> bool {
self.dialect.is_none() && self.types.is_none()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigWarning(pub String);
impl fmt::Display for ConfigWarning {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("failed to read {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("invalid TOML syntax: {0}")]
Toml(#[from] toml::de::Error),
#[error("`{key}` must be a table, found {found}")]
NotATable { key: String, found: &'static str },
#[error("`{key}` must be a string, found {found}")]
WrongType { key: String, found: &'static str },
#[error("`{key}` must be one of {expected:?}, found {found:?}")]
InvalidValue {
key: String,
expected: &'static [&'static str],
found: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoadedConfig {
pub path: PathBuf,
pub config: ProjectConfig,
pub warnings: Vec<ConfigWarning>,
}
pub fn parse_str(text: &str) -> Result<(ProjectConfig, Vec<ConfigWarning>), ConfigError> {
let doc: Value = toml::from_str(text)?;
let root = match doc {
Value::Table(t) => t,
other => {
return Err(ConfigError::NotATable {
key: "<root>".to_owned(),
found: value_type_name(&other),
});
}
};
let mut config = ProjectConfig::default();
let mut warnings = Vec::new();
for (key, value) in &root {
if key == "project" {
let project = match value {
Value::Table(t) => t,
other => {
return Err(ConfigError::NotATable {
key: "project".to_owned(),
found: value_type_name(other),
});
}
};
for (pkey, pvalue) in project {
match pkey.as_str() {
"dialect" => config.dialect = Some(parse_dialect(pkey, pvalue)?),
"types" => config.types = Some(parse_types(pkey, pvalue)?),
_ => warnings.push(ConfigWarning(format!(
"unknown key `project.{pkey}` in {CONFIG_FILE_NAME} (ignored)"
))),
}
}
} else {
warnings.push(ConfigWarning(format!(
"unknown top-level key `{key}` in {CONFIG_FILE_NAME} (ignored)"
)));
}
}
Ok((config, warnings))
}
fn parse_dialect(key: &str, value: &Value) -> Result<Dialect, ConfigError> {
let s = value.as_str().ok_or_else(|| ConfigError::WrongType {
key: format!("project.{key}"),
found: value_type_name(value),
})?;
match s {
"brink" => Ok(Dialect::Brink),
"strict-ink" => Ok(Dialect::StrictInk),
other => Err(ConfigError::InvalidValue {
key: format!("project.{key}"),
expected: &["brink", "strict-ink"],
found: other.to_owned(),
}),
}
}
fn parse_types(key: &str, value: &Value) -> Result<TypePolicy, ConfigError> {
let s = value.as_str().ok_or_else(|| ConfigError::WrongType {
key: format!("project.{key}"),
found: value_type_name(value),
})?;
match s {
"gradual" => Ok(TypePolicy::Gradual),
"strict" => Ok(TypePolicy::Strict),
other => Err(ConfigError::InvalidValue {
key: format!("project.{key}"),
expected: &["gradual", "strict"],
found: other.to_owned(),
}),
}
}
fn value_type_name(value: &Value) -> &'static str {
match value {
Value::String(_) => "string",
Value::Integer(_) => "integer",
Value::Float(_) => "float",
Value::Boolean(_) => "boolean",
Value::Datetime(_) => "datetime",
Value::Array(_) => "array",
Value::Table(_) => "table",
}
}
#[must_use]
pub fn find_config(start_dir: &Path) -> Option<PathBuf> {
let mut dir = Some(start_dir);
while let Some(d) = dir {
let candidate = d.join(CONFIG_FILE_NAME);
if candidate.is_file() {
return Some(candidate);
}
dir = d.parent();
}
None
}
#[must_use]
pub fn discover_from_entry(entry_file: &Path) -> Option<PathBuf> {
let start = entry_file.parent().unwrap_or_else(|| Path::new("."));
find_config(start)
}
pub fn load_from_entry(entry_file: &Path) -> Result<Option<LoadedConfig>, ConfigError> {
let Some(path) = discover_from_entry(entry_file) else {
return Ok(None);
};
let text = std::fs::read_to_string(&path).map_err(|source| ConfigError::Io {
path: path.clone(),
source,
})?;
let (config, warnings) = parse_str(&text)?;
Ok(Some(LoadedConfig {
path,
config,
warnings,
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_document_is_empty_config_no_warnings() {
let (config, warnings) = parse_str("").unwrap();
assert_eq!(config, ProjectConfig::default());
assert!(config.is_empty());
assert!(warnings.is_empty());
}
#[test]
fn parses_dialect_and_types() {
let (config, warnings) = parse_str(
r#"
[project]
dialect = "brink"
types = "strict"
"#,
)
.unwrap();
assert_eq!(config.dialect, Some(Dialect::Brink));
assert_eq!(config.types, Some(TypePolicy::Strict));
assert!(warnings.is_empty());
}
#[test]
fn parses_strict_ink_and_gradual() {
let (config, warnings) = parse_str(
r#"
[project]
dialect = "strict-ink"
types = "gradual"
"#,
)
.unwrap();
assert_eq!(config.dialect, Some(Dialect::StrictInk));
assert_eq!(config.types, Some(TypePolicy::Gradual));
assert!(warnings.is_empty());
}
#[test]
fn partial_project_table_leaves_other_field_none() {
let (config, _) = parse_str("[project]\ndialect = \"brink\"\n").unwrap();
assert_eq!(config.dialect, Some(Dialect::Brink));
assert_eq!(config.types, None);
}
#[test]
fn unknown_top_level_key_warns_not_errors() {
let (config, warnings) = parse_str("future_section = 1\n").unwrap();
assert!(config.is_empty());
assert_eq!(warnings.len(), 1);
assert!(warnings[0].0.contains("future_section"));
}
#[test]
fn unknown_project_key_warns_not_errors() {
let (config, warnings) =
parse_str("[project]\ndialect = \"brink\"\nfuture_key = \"x\"\n").unwrap();
assert_eq!(config.dialect, Some(Dialect::Brink));
assert_eq!(warnings.len(), 1);
assert!(warnings[0].0.contains("project.future_key"));
}
#[test]
fn invalid_dialect_value_is_an_error() {
let err = parse_str("[project]\ndialect = \"sideways\"\n").unwrap_err();
assert!(matches!(err, ConfigError::InvalidValue { .. }));
}
#[test]
fn invalid_types_value_is_an_error() {
let err = parse_str("[project]\ntypes = \"loose\"\n").unwrap_err();
assert!(matches!(err, ConfigError::InvalidValue { .. }));
}
#[test]
fn wrong_type_value_is_an_error() {
let err = parse_str("[project]\ndialect = 1\n").unwrap_err();
assert!(matches!(err, ConfigError::WrongType { .. }));
}
#[test]
fn malformed_toml_is_an_error() {
let err = parse_str("this is not [ toml").unwrap_err();
assert!(matches!(err, ConfigError::Toml(_)));
}
#[test]
fn non_table_root_is_an_error() {
let err = parse_str("\"just a string\"").unwrap_err();
assert!(matches!(
err,
ConfigError::NotATable { .. } | ConfigError::Toml(_)
));
}
fn unique_tmp_dir(tag: &str) -> PathBuf {
let mut dir = std::env::temp_dir();
dir.push(format!(
"brink-project-config-test-{tag}-{}-{:?}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
));
dir
}
#[test]
fn find_config_walks_up_from_start_dir() {
let root = unique_tmp_dir("walk-up");
let nested = root.join("a").join("b");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(
root.join(CONFIG_FILE_NAME),
"[project]\ndialect = \"brink\"\n",
)
.unwrap();
let found = find_config(&nested).expect("should find brink.toml in an ancestor");
assert_eq!(found, root.join(CONFIG_FILE_NAME));
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn find_config_returns_none_when_absent() {
let root = unique_tmp_dir("absent");
std::fs::create_dir_all(&root).unwrap();
assert_eq!(find_config(&root), None);
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn discover_from_entry_starts_at_entry_parent() {
let root = unique_tmp_dir("entry-parent");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(
root.join(CONFIG_FILE_NAME),
"[project]\ntypes = \"strict\"\n",
)
.unwrap();
let entry = root.join("story.ink");
std::fs::write(&entry, "content").unwrap();
let found = discover_from_entry(&entry).expect("should find brink.toml beside entry");
assert_eq!(found, root.join(CONFIG_FILE_NAME));
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn load_from_entry_none_when_no_config() {
let root = unique_tmp_dir("load-none");
std::fs::create_dir_all(&root).unwrap();
let entry = root.join("story.ink");
std::fs::write(&entry, "content").unwrap();
assert!(load_from_entry(&entry).unwrap().is_none());
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn load_from_entry_reads_and_parses() {
let root = unique_tmp_dir("load-some");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(
root.join(CONFIG_FILE_NAME),
"[project]\ndialect = \"brink\"\ntypes = \"strict\"\n",
)
.unwrap();
let entry = root.join("story.ink");
std::fs::write(&entry, "content").unwrap();
let loaded = load_from_entry(&entry).unwrap().expect("config found");
assert_eq!(loaded.path, root.join(CONFIG_FILE_NAME));
assert_eq!(loaded.config.dialect, Some(Dialect::Brink));
assert_eq!(loaded.config.types, Some(TypePolicy::Strict));
assert!(loaded.warnings.is_empty());
std::fs::remove_dir_all(&root).unwrap();
}
}