use std::collections::BTreeMap;
use std::fmt;
use std::io;
use std::path::{Path, PathBuf};
use brink_source_tree::{IGNORED_DIR_NAMES, SourceTree};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub enum Dialect {
#[default]
StrictInk,
Brink,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub enum TypePolicy {
#[default]
Gradual,
Strict,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub enum LintLevel {
Allow,
#[default]
Warn,
Deny,
Info,
Hint,
}
use thiserror::Error;
use toml::Value;
pub const CONFIG_FILE_NAME: &str = "brink.toml";
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ProjectConfig {
pub dialect: Option<Dialect>,
pub types: Option<TypePolicy>,
pub lints: BTreeMap<String, LintLevel>,
pub deny_warnings: Option<bool>,
pub unprune_dirs: Vec<String>,
pub conventions: Option<String>,
pub entry: Option<String>,
}
impl ProjectConfig {
#[must_use]
pub fn is_empty(&self) -> bool {
self.dialect.is_none()
&& self.types.is_none()
&& self.lints.is_empty()
&& self.deny_warnings.is_none()
&& self.unprune_dirs.is_empty()
&& self.conventions.is_none()
&& self.entry.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 in {path}: {source}")]
Toml {
path: String,
#[source]
source: toml::de::Error,
},
#[error("`{key}` must be a table, found {found} (in {path})")]
NotATable {
path: String,
key: String,
found: &'static str,
},
#[error("`{key}` must be a string, found {found} (in {path})")]
WrongType {
path: String,
key: String,
found: &'static str,
},
#[error("`{key}` must be one of {expected:?}, found {found:?} (in {path})")]
InvalidValue {
path: String,
key: String,
expected: &'static [&'static str],
found: String,
},
}
impl ConfigError {
#[must_use]
pub fn path(&self) -> &str {
match self {
ConfigError::Io { path, .. } => path.to_str().unwrap_or_default(),
ConfigError::Toml { path, .. }
| ConfigError::NotATable { path, .. }
| ConfigError::WrongType { path, .. }
| ConfigError::InvalidValue { path, .. } => path,
}
}
#[must_use]
pub fn span(&self) -> Option<std::ops::Range<usize>> {
match self {
ConfigError::Toml { source, .. } => source.span(),
_ => None,
}
}
}
#[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> {
parse_str_at(CONFIG_FILE_NAME, text)
}
pub fn parse_str_at(
path: impl Into<String>,
text: &str,
) -> Result<(ProjectConfig, Vec<ConfigWarning>), ConfigError> {
let path = path.into();
let doc: Value = toml::from_str(text).map_err(|source| ConfigError::Toml {
path: path.clone(),
source,
})?;
let root = match doc {
Value::Table(t) => t,
other => {
return Err(ConfigError::NotATable {
path,
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 {
path,
key: "project".to_owned(),
found: value_type_name(other),
});
}
};
parse_project_table(&path, project, &mut config, &mut warnings)?;
} else if key == "lints" {
let lints = match value {
Value::Table(t) => t,
other => {
return Err(ConfigError::NotATable {
path,
key: "lints".to_owned(),
found: value_type_name(other),
});
}
};
for (lkey, lvalue) in lints {
if lkey == "deny-warnings" {
config.deny_warnings = Some(parse_deny_warnings(&path, lkey, lvalue)?);
} else {
config
.lints
.insert(lkey.clone(), parse_lint_level(&path, lkey, lvalue)?);
}
}
} else {
warnings.push(ConfigWarning(format!(
"unknown top-level key `{key}` in {CONFIG_FILE_NAME} (ignored)"
)));
}
}
Ok((config, warnings))
}
fn parse_project_table(
path: &str,
project: &toml::map::Map<String, Value>,
config: &mut ProjectConfig,
warnings: &mut Vec<ConfigWarning>,
) -> Result<(), ConfigError> {
let mut conventions_value: Option<String> = None;
let mut elements_value: Option<String> = None;
for (pkey, pvalue) in project {
match pkey.as_str() {
"dialect" => config.dialect = Some(parse_dialect(path, pkey, pvalue)?),
"types" => config.types = Some(parse_types(path, pkey, pvalue)?),
"unprune-dirs" => {
let dirs = parse_string_list(path, pkey, pvalue)?;
for dir in &dirs {
if !IGNORED_DIR_NAMES.contains(&dir.as_str()) {
warnings.push(ConfigWarning(format!(
"`project.unprune-dirs` entry `{dir}` in {CONFIG_FILE_NAME} is not \
one of {IGNORED_DIR_NAMES:?} — it was never pruned, so this has no \
effect (check for a typo)"
)));
}
}
config.unprune_dirs = dirs;
}
"conventions" => {
let s = parse_path_like_string(path, pkey, pvalue)?;
if s.is_empty() {
warnings.push(ConfigWarning(format!(
"`project.conventions` in {CONFIG_FILE_NAME} is an empty string \
(ignored) — expected a built-in preset name (e.g. \"screenplay\") or a \
path to a conventions module (e.g. \"conventions.brink\")"
)));
} else {
conventions_value = Some(s);
}
}
"elements" => {
let s = parse_path_like_string(path, pkey, pvalue)?;
if s.is_empty() {
warnings.push(ConfigWarning(format!(
"`project.elements` in {CONFIG_FILE_NAME} is an empty string (ignored) \
— expected a built-in preset name (e.g. \"screenplay\") or a path to a \
conventions module (e.g. \"conventions.brink\")"
)));
} else {
elements_value = Some(s);
}
}
"entry" => {
let s = parse_path_like_string(path, pkey, pvalue)?;
if s.is_empty() {
warnings.push(ConfigWarning(format!(
"`project.entry` in {CONFIG_FILE_NAME} is an empty string (ignored) — \
expected a project-relative path to the entry file (e.g. \
\"story.ink\")"
)));
} else {
config.entry = Some(s);
}
}
_ => warnings.push(ConfigWarning(format!(
"unknown key `project.{pkey}` in {CONFIG_FILE_NAME} (ignored)"
))),
}
}
config.conventions = resolve_conventions_key(conventions_value, elements_value, warnings);
Ok(())
}
fn parse_dialect(path: &str, key: &str, value: &Value) -> Result<Dialect, ConfigError> {
let s = value.as_str().ok_or_else(|| ConfigError::WrongType {
path: path.to_owned(),
key: format!("project.{key}"),
found: value_type_name(value),
})?;
match s {
"brink" => Ok(Dialect::Brink),
"strict-ink" => Ok(Dialect::StrictInk),
other => Err(ConfigError::InvalidValue {
path: path.to_owned(),
key: format!("project.{key}"),
expected: &["brink", "strict-ink"],
found: other.to_owned(),
}),
}
}
fn resolve_conventions_key(
conventions_value: Option<String>,
elements_value: Option<String>,
warnings: &mut Vec<ConfigWarning>,
) -> Option<String> {
match (conventions_value, elements_value) {
(Some(c), Some(_)) => {
warnings.push(ConfigWarning(format!(
"`project.elements` and `project.conventions` are both set in \
{CONFIG_FILE_NAME} — `project.elements` is deprecated (renamed to \
`project.conventions`, issue #2180) and was ignored in favor of \
`project.conventions`"
)));
Some(c)
}
(Some(c), None) => Some(c),
(None, Some(e)) => {
warnings.push(ConfigWarning(format!(
"`project.elements` in {CONFIG_FILE_NAME} is deprecated — rename to \
`project.conventions` (issue #2180: the key now names a module of \
`@[convention]` declarations, not `@[element]` ones)"
)));
Some(e)
}
(None, None) => None,
}
}
fn parse_path_like_string(path: &str, key: &str, value: &Value) -> Result<String, ConfigError> {
value
.as_str()
.map(str::to_owned)
.ok_or_else(|| ConfigError::WrongType {
path: path.to_owned(),
key: format!("project.{key}"),
found: value_type_name(value),
})
}
fn parse_types(path: &str, key: &str, value: &Value) -> Result<TypePolicy, ConfigError> {
let s = value.as_str().ok_or_else(|| ConfigError::WrongType {
path: path.to_owned(),
key: format!("project.{key}"),
found: value_type_name(value),
})?;
match s {
"gradual" => Ok(TypePolicy::Gradual),
"strict" => Ok(TypePolicy::Strict),
other => Err(ConfigError::InvalidValue {
path: path.to_owned(),
key: format!("project.{key}"),
expected: &["gradual", "strict"],
found: other.to_owned(),
}),
}
}
fn parse_deny_warnings(path: &str, key: &str, value: &Value) -> Result<bool, ConfigError> {
value.as_bool().ok_or_else(|| ConfigError::WrongType {
path: path.to_owned(),
key: format!("lints.{key}"),
found: value_type_name(value),
})
}
fn parse_lint_level(path: &str, key: &str, value: &Value) -> Result<LintLevel, ConfigError> {
let s = value.as_str().ok_or_else(|| ConfigError::WrongType {
path: path.to_owned(),
key: format!("lints.{key}"),
found: value_type_name(value),
})?;
match s {
"allow" => Ok(LintLevel::Allow),
"warn" => Ok(LintLevel::Warn),
"deny" => Ok(LintLevel::Deny),
"info" => Ok(LintLevel::Info),
"hint" => Ok(LintLevel::Hint),
other => Err(ConfigError::InvalidValue {
path: path.to_owned(),
key: format!("lints.{key}"),
expected: &["allow", "warn", "deny", "info", "hint"],
found: other.to_owned(),
}),
}
}
fn parse_string_list(path: &str, key: &str, value: &Value) -> Result<Vec<String>, ConfigError> {
let arr = value.as_array().ok_or_else(|| ConfigError::WrongType {
path: path.to_owned(),
key: format!("project.{key}"),
found: value_type_name(value),
})?;
arr.iter()
.map(|item| {
item.as_str()
.map(str::to_owned)
.ok_or_else(|| ConfigError::WrongType {
path: path.to_owned(),
key: format!("project.{key}"),
found: value_type_name(item),
})
})
.collect()
}
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",
}
}
pub const MAX_ANCESTOR_DEPTH: usize = 32;
#[must_use]
pub fn find_config(start_dir: &Path) -> Option<PathBuf> {
find_config_inner(start_dir, false).0
}
#[must_use]
pub fn find_config_with_warnings(start_dir: &Path) -> (Option<PathBuf>, Vec<ConfigWarning>) {
find_config_inner(start_dir, true)
}
fn find_config_inner(
start_dir: &Path,
want_warnings: bool,
) -> (Option<PathBuf>, Vec<ConfigWarning>) {
let mut dir = Some(start_dir);
let mut depth = 0usize;
let mut stopped_at: Option<(PathBuf, &'static str)> = None;
while let Some(d) = dir {
let candidate = d.join(CONFIG_FILE_NAME);
if candidate.is_file() {
return (Some(candidate), Vec::new());
}
if d.join(brink_source_tree::GIT_DIR_NAME).exists() {
stopped_at = Some((d.to_path_buf(), "workspace/git boundary"));
break;
}
if depth >= MAX_ANCESTOR_DEPTH {
stopped_at = Some((d.to_path_buf(), "ancestor depth limit"));
break;
}
depth += 1;
dir = d.parent();
}
let Some((stopped_at, reason)) = stopped_at else {
return (None, Vec::new());
};
if !want_warnings {
return (None, Vec::new());
}
let mut probe = stopped_at.parent();
let mut probe_depth = 0usize;
while let Some(p) = probe {
let candidate = p.join(CONFIG_FILE_NAME);
if candidate.is_file() {
return (
None,
vec![ConfigWarning(format!(
"{} exists above the {reason} at {} and was ignored",
candidate.display(),
stopped_at.display(),
))],
);
}
probe_depth += 1;
if probe_depth >= MAX_ANCESTOR_DEPTH {
break;
}
probe = p.parent();
}
(None, Vec::new())
}
#[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)
}
#[must_use]
pub fn discover_from_entry_with_warnings(
entry_file: &Path,
) -> (Option<PathBuf>, Vec<ConfigWarning>) {
let start = entry_file.parent().unwrap_or_else(|| Path::new("."));
find_config_with_warnings(start)
}
pub fn find_config_in_tree(tree: &dyn SourceTree, start_key: &str) -> io::Result<Option<String>> {
let mut dir = start_key.trim_matches('/');
loop {
let candidate = if dir.is_empty() {
CONFIG_FILE_NAME.to_owned()
} else {
format!("{dir}/{CONFIG_FILE_NAME}")
};
match tree.read(&candidate) {
Err(err) if err.kind() == io::ErrorKind::NotFound => {}
Ok(_) | Err(_) => return Ok(Some(candidate)),
}
if dir.is_empty() {
return Ok(None);
}
dir = match dir.rsplit_once('/') {
Some((parent, _)) => parent,
None => "",
};
}
}
pub fn discover_from_entry_in_tree(
tree: &dyn SourceTree,
entry_key: &str,
) -> io::Result<Option<String>> {
let start = match entry_key.trim_matches('/').rsplit_once('/') {
Some((parent, _)) => parent,
None => "",
};
find_config_in_tree(tree, start)
}
pub fn load_from_entry(
entry_file: &Path,
) -> Result<(Option<LoadedConfig>, Vec<ConfigWarning>), ConfigError> {
let (path, discovery_warnings) = discover_from_entry_with_warnings(entry_file);
let Some(path) = path else {
return Ok((None, discovery_warnings));
};
let text = std::fs::read_to_string(&path).map_err(|source| ConfigError::Io {
path: path.clone(),
source,
})?;
let (config, warnings) = parse_str_at(path.display().to_string(), &text)?;
Ok((
Some(LoadedConfig {
path,
config,
warnings,
}),
Vec::new(),
))
}
#[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 parses_unprune_dirs() {
let (config, warnings) = parse_str(
r#"
[project]
unprune-dirs = ["node_modules", "target"]
"#,
)
.unwrap();
assert_eq!(
config.unprune_dirs,
vec!["node_modules".to_string(), "target".to_string()]
);
assert!(!config.is_empty());
assert!(
warnings.is_empty(),
"both names are real IGNORED_DIR_NAMES entries, no warning expected: {warnings:?}"
);
}
#[test]
fn unprune_dirs_entry_outside_ignored_dir_names_warns() {
let (config, warnings) = parse_str(
r#"
[project]
unprune-dirs = ["node-modules"]
"#,
)
.unwrap();
assert_eq!(config.unprune_dirs, vec!["node-modules".to_string()]);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].0.contains("node-modules"));
assert!(warnings[0].0.contains("unprune-dirs"));
}
#[test]
fn unprune_dirs_wrong_element_type_is_an_error() {
let err = parse_str("[project]\nunprune-dirs = [1, 2]\n").unwrap_err();
assert!(matches!(err, ConfigError::WrongType { .. }));
}
#[test]
fn unprune_dirs_not_an_array_is_an_error() {
let err = parse_str("[project]\nunprune-dirs = \"node_modules\"\n").unwrap_err();
assert!(matches!(err, ConfigError::WrongType { .. }));
}
#[test]
fn empty_unprune_dirs_is_not_a_warning_and_leaves_config_empty_by_itself() {
let (config, warnings) = parse_str("[project]\nunprune-dirs = []\n").unwrap();
assert!(config.unprune_dirs.is_empty());
assert!(warnings.is_empty());
assert!(config.is_empty());
}
#[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 parses_conventions_as_a_path() {
let (config, warnings) = parse_str(
r#"
[project]
conventions = "conventions.brink"
"#,
)
.unwrap();
assert_eq!(config.conventions.as_deref(), Some("conventions.brink"));
assert!(!config.is_empty());
assert!(warnings.is_empty(), "{warnings:?}");
}
#[test]
fn parses_conventions_as_a_preset_name() {
let (config, _warnings) = parse_str("[project]\nconventions = \"screenplay\"\n").unwrap();
assert_eq!(config.conventions.as_deref(), Some("screenplay"));
}
#[test]
fn empty_conventions_string_warns_and_is_not_set() {
let (config, warnings) = parse_str("[project]\nconventions = \"\"\n").unwrap();
assert_eq!(config.conventions, None);
assert!(config.is_empty());
assert_eq!(warnings.len(), 1);
assert!(warnings[0].0.contains("conventions"));
}
#[test]
fn conventions_wrong_type_is_an_error() {
let err = parse_str("[project]\nconventions = 1\n").unwrap_err();
assert!(matches!(err, ConfigError::WrongType { .. }));
}
#[test]
fn unset_conventions_leaves_config_empty_by_itself() {
let (config, _warnings) = parse_str("[project]\ndialect = \"brink\"\n").unwrap();
assert_eq!(config.conventions, None);
}
#[test]
fn parses_entry_as_a_project_relative_path() {
let (config, warnings) = parse_str(
r#"
[project]
entry = "story.ink"
"#,
)
.unwrap();
assert_eq!(config.entry.as_deref(), Some("story.ink"));
assert!(!config.is_empty());
assert!(warnings.is_empty(), "{warnings:?}");
}
#[test]
fn parses_entry_nested_under_a_directory() {
let (config, _warnings) =
parse_str("[project]\nentry = \"chapters/main.brink\"\n").unwrap();
assert_eq!(config.entry.as_deref(), Some("chapters/main.brink"));
}
#[test]
fn empty_entry_string_warns_and_is_not_set() {
let (config, warnings) = parse_str("[project]\nentry = \"\"\n").unwrap();
assert_eq!(config.entry, None);
assert!(config.is_empty());
assert_eq!(warnings.len(), 1);
assert!(warnings[0].0.contains("entry"));
}
#[test]
fn entry_wrong_type_is_an_error() {
let err = parse_str("[project]\nentry = 1\n").unwrap_err();
assert!(matches!(err, ConfigError::WrongType { .. }));
}
#[test]
fn unset_entry_leaves_config_empty_by_itself() {
let (config, _warnings) = parse_str("[project]\ndialect = \"brink\"\n").unwrap();
assert_eq!(config.entry, None);
}
#[test]
fn entry_and_conventions_coexist_independently() {
let (config, warnings) =
parse_str("[project]\nentry = \"story.ink\"\nconventions = \"conventions.brink\"\n")
.unwrap();
assert_eq!(config.entry.as_deref(), Some("story.ink"));
assert_eq!(config.conventions.as_deref(), Some("conventions.brink"));
assert!(warnings.is_empty(), "{warnings:?}");
}
#[test]
fn elements_alias_still_sets_conventions_but_warns() {
let (config, warnings) = parse_str("[project]\nelements = \"conventions.brink\"\n")
.expect("deprecated `elements` key must still parse, not hard-error");
assert_eq!(config.conventions.as_deref(), Some("conventions.brink"));
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(warnings[0].0.contains("project.elements"));
assert!(warnings[0].0.contains("deprecated"));
assert!(warnings[0].0.contains("project.conventions"));
}
#[test]
fn empty_elements_alias_string_warns_and_is_not_set() {
let (config, warnings) = parse_str("[project]\nelements = \"\"\n").unwrap();
assert_eq!(config.conventions, None);
assert!(config.is_empty());
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(warnings[0].0.contains("elements"));
}
#[test]
fn elements_alias_wrong_type_is_an_error() {
let err = parse_str("[project]\nelements = 1\n").unwrap_err();
assert!(matches!(err, ConfigError::WrongType { .. }));
}
#[test]
fn both_conventions_and_elements_set_prefers_conventions_and_warns() {
let (config, warnings) = parse_str(
r#"
[project]
conventions = "new.brink"
elements = "old.brink"
"#,
)
.unwrap();
assert_eq!(config.conventions.as_deref(), Some("new.brink"));
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(warnings[0].0.contains("project.elements"));
assert!(warnings[0].0.contains("project.conventions"));
assert!(warnings[0].0.contains("both set"));
}
#[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 { .. }
));
}
#[test]
fn parse_str_at_names_its_path_on_invalid_value() {
let err =
parse_str_at("chapters/brink.toml", "[project]\ndialect = \"sideways\"\n").unwrap_err();
assert_eq!(err.path(), "chapters/brink.toml");
assert!(
err.to_string().contains("chapters/brink.toml"),
"message must name the file, got: {err}"
);
assert!(
matches!(err, ConfigError::InvalidValue { .. }),
"expected InvalidValue, got: {err:?}"
);
}
#[test]
fn parse_str_at_names_its_path_on_malformed_toml() {
let err = parse_str_at("chapters/brink.toml", "this is not [ toml").unwrap_err();
assert_eq!(err.path(), "chapters/brink.toml");
assert!(
err.to_string().contains("chapters/brink.toml"),
"message must name the file, got: {err}"
);
assert!(
matches!(err, ConfigError::Toml { .. }),
"expected Toml, got: {err:?}"
);
}
#[test]
fn parse_str_at_names_its_path_on_wrong_type() {
let err = parse_str_at("chapters/brink.toml", "[project]\ndialect = 1\n").unwrap_err();
assert_eq!(err.path(), "chapters/brink.toml");
assert!(err.to_string().contains("chapters/brink.toml"));
assert!(
matches!(err, ConfigError::WrongType { .. }),
"expected WrongType, got: {err:?}"
);
}
#[test]
fn parse_str_at_names_its_path_on_not_a_table() {
let err = parse_str_at("chapters/brink.toml", "project = 1\n").unwrap_err();
assert_eq!(err.path(), "chapters/brink.toml");
assert!(err.to_string().contains("chapters/brink.toml"));
assert!(
matches!(err, ConfigError::NotATable { .. }),
"expected NotATable, got: {err:?}"
);
}
#[test]
fn parse_str_falls_back_to_config_file_name_as_path() {
let err = parse_str("[project]\ndialect = \"sideways\"\n").unwrap_err();
assert_eq!(err.path(), CONFIG_FILE_NAME);
}
#[test]
fn toml_syntax_error_carries_a_span_pointing_at_the_bad_text() {
let text = "[project]\ndialect = \"brink\" oops\n";
let err = parse_str_at("brink.toml", text).unwrap_err();
let span = err.span().expect("malformed TOML syntax must carry a span");
assert!(span.start > 0, "span must not point at the file start");
let first_line_end = text.find('\n').unwrap();
assert!(
span.start > first_line_end,
"span {span:?} must point past the first line (ends at {first_line_end})"
);
}
#[test]
fn invalid_value_error_has_no_span() {
let err = parse_str_at("brink.toml", "[project]\ndialect = \"sideways\"\n").unwrap_err();
assert_eq!(err.span(), None);
}
#[test]
fn parses_per_code_lint_levels() {
let (config, warnings) = parse_str(
r#"
[lints]
E063 = "deny"
E014 = "allow"
E022 = "warn"
"#,
)
.unwrap();
assert_eq!(config.lints.get("E063"), Some(&LintLevel::Deny));
assert_eq!(config.lints.get("E014"), Some(&LintLevel::Allow));
assert_eq!(config.lints.get("E022"), Some(&LintLevel::Warn));
assert!(warnings.is_empty());
}
#[test]
fn parses_info_and_hint_lint_levels() {
let (config, warnings) = parse_str(
r#"
[lints]
E014 = "info"
E022 = "hint"
"#,
)
.unwrap();
assert_eq!(config.lints.get("E014"), Some(&LintLevel::Info));
assert_eq!(config.lints.get("E022"), Some(&LintLevel::Hint));
assert!(warnings.is_empty());
}
#[test]
fn parses_deny_warnings_flag() {
let (config, _) = parse_str("[lints]\ndeny-warnings = true\n").unwrap();
assert_eq!(config.deny_warnings, Some(true));
}
#[test]
fn deny_warnings_and_codes_coexist() {
let (config, _) = parse_str(
r#"
[lints]
deny-warnings = true
E063 = "allow"
"#,
)
.unwrap();
assert_eq!(config.deny_warnings, Some(true));
assert_eq!(config.lints.get("E063"), Some(&LintLevel::Allow));
}
#[test]
fn absent_lints_table_is_empty_config() {
let (config, _) = parse_str("[project]\ndialect = \"brink\"\n").unwrap();
assert!(config.lints.is_empty());
assert_eq!(config.deny_warnings, None);
}
#[test]
fn invalid_lint_level_value_is_an_error() {
let err = parse_str("[lints]\nE063 = \"sideways\"\n").unwrap_err();
assert!(matches!(err, ConfigError::InvalidValue { .. }));
}
#[test]
fn wrong_type_deny_warnings_is_an_error() {
let err = parse_str("[lints]\ndeny-warnings = \"yes\"\n").unwrap_err();
assert!(matches!(err, ConfigError::WrongType { .. }));
}
#[test]
fn wrong_type_lint_level_is_an_error() {
let err = parse_str("[lints]\nE063 = 1\n").unwrap_err();
assert!(matches!(err, ConfigError::WrongType { .. }));
}
#[test]
fn non_table_lints_is_an_error() {
let err = parse_str("lints = 1\n").unwrap_err();
assert!(matches!(err, ConfigError::NotATable { .. }));
}
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 find_config_stops_at_git_dir_boundary() {
let root = unique_tmp_dir("git-boundary-dir");
let repo = root.join("repo");
let nested = repo.join("a").join("b");
std::fs::create_dir_all(&nested).unwrap();
std::fs::create_dir_all(repo.join(".git")).unwrap();
std::fs::write(
root.join(CONFIG_FILE_NAME),
"[project]\ndialect = \"brink\"\n",
)
.unwrap();
assert_eq!(
find_config(&nested),
None,
"must not climb past the .git-marked repository root to a stray ancestor config"
);
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn find_config_stops_at_git_file_boundary_worktree_shape() {
let root = unique_tmp_dir("git-boundary-file");
let repo = root.join("repo");
let nested = repo.join("a").join("b");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(repo.join(".git"), "gitdir: /elsewhere/.git/worktrees/x\n").unwrap();
std::fs::write(
root.join(CONFIG_FILE_NAME),
"[project]\ndialect = \"brink\"\n",
)
.unwrap();
assert_eq!(
find_config(&nested),
None,
"a `.git` worktree-pointer *file* must bound the walk exactly like a `.git` dir"
);
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn find_config_still_finds_config_at_the_git_boundary_dir_itself() {
let root = unique_tmp_dir("git-boundary-config-at-root");
let repo = root.join("repo");
let nested = repo.join("a").join("b");
std::fs::create_dir_all(&nested).unwrap();
std::fs::create_dir_all(repo.join(".git")).unwrap();
std::fs::write(
repo.join(CONFIG_FILE_NAME),
"[project]\ndialect = \"brink\"\n",
)
.unwrap();
let found = find_config(&nested).expect("brink.toml at the repo root must still be found");
assert_eq!(found, repo.join(CONFIG_FILE_NAME));
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn find_config_without_any_git_boundary_still_finds_config_within_depth_cap() {
let root = unique_tmp_dir("no-git-anywhere");
let nested = root.join("a").join("b").join("c");
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 still find brink.toml with no .git anywhere");
assert_eq!(found, root.join(CONFIG_FILE_NAME));
std::fs::remove_dir_all(&root).unwrap();
}
fn nested_chain(root: &Path, depth: usize) -> PathBuf {
let mut dir = root.to_path_buf();
for i in 0..depth {
dir = dir.join(format!("d{i}"));
}
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn find_config_bounds_vcs_less_walk_at_max_ancestor_depth() {
let root = unique_tmp_dir("vcs-less-too-deep");
let deepest = nested_chain(&root, MAX_ANCESTOR_DEPTH + 10);
std::fs::write(
root.join(CONFIG_FILE_NAME),
"[project]\ndialect = \"brink\"\n",
)
.unwrap();
assert_eq!(
find_config(&deepest),
None,
"a VCS-less walk must not climb past MAX_ANCESTOR_DEPTH ancestors, even with no \
.git boundary to stop it otherwise"
);
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn find_config_finds_config_exactly_at_max_ancestor_depth() {
let root = unique_tmp_dir("vcs-less-at-cap");
let deepest = nested_chain(&root, MAX_ANCESTOR_DEPTH);
std::fs::write(
root.join(CONFIG_FILE_NAME),
"[project]\ndialect = \"brink\"\n",
)
.unwrap();
let found = find_config(&deepest)
.expect("a brink.toml exactly MAX_ANCESTOR_DEPTH ancestors up must still be found");
assert_eq!(found, root.join(CONFIG_FILE_NAME));
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn find_config_with_warnings_reports_config_skipped_above_git_boundary() {
let root = unique_tmp_dir("warn-git-boundary");
let repo = root.join("repo");
let nested = repo.join("a").join("b");
std::fs::create_dir_all(&nested).unwrap();
std::fs::create_dir_all(repo.join(".git")).unwrap();
let stray = root.join(CONFIG_FILE_NAME);
std::fs::write(&stray, "[project]\ndialect = \"brink\"\n").unwrap();
let (found, warnings) = find_config_with_warnings(&nested);
assert_eq!(found, None, "the stray config must still never be returned");
assert_eq!(warnings.len(), 1, "got: {warnings:?}");
assert!(
warnings[0].0.contains(&stray.display().to_string()),
"warning must name the skipped file, got: {}",
warnings[0]
);
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn find_config_with_warnings_reports_config_skipped_beyond_depth_cap() {
let root = unique_tmp_dir("warn-depth-cap");
let deepest = nested_chain(&root, MAX_ANCESTOR_DEPTH + 10);
let stray = root.join(CONFIG_FILE_NAME);
std::fs::write(&stray, "[project]\ndialect = \"brink\"\n").unwrap();
let (found, warnings) = find_config_with_warnings(&deepest);
assert_eq!(found, None, "the stray config must still never be returned");
assert_eq!(warnings.len(), 1, "got: {warnings:?}");
assert!(
warnings[0].0.contains(&stray.display().to_string()),
"warning must name the skipped file, got: {}",
warnings[0]
);
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn find_config_with_warnings_is_silent_when_nothing_skipped() {
let root = unique_tmp_dir("warn-nothing-to-skip");
let deepest = nested_chain(&root, MAX_ANCESTOR_DEPTH + 10);
let (found, warnings) = find_config_with_warnings(&deepest);
assert_eq!(found, None);
assert!(warnings.is_empty(), "got: {warnings:?}");
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn find_config_skips_the_warning_probe_but_still_returns_none_at_git_boundary() {
let root = unique_tmp_dir("no-warn-probe-git-boundary");
let repo = root.join("repo");
let nested = repo.join("a").join("b");
std::fs::create_dir_all(&nested).unwrap();
std::fs::create_dir_all(repo.join(".git")).unwrap();
std::fs::write(
root.join(CONFIG_FILE_NAME),
"[project]\ndialect = \"brink\"\n",
)
.unwrap();
assert_eq!(find_config(&nested), None);
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn find_config_skips_the_warning_probe_but_still_returns_none_beyond_depth_cap() {
let root = unique_tmp_dir("no-warn-probe-depth-cap");
let deepest = nested_chain(&root, MAX_ANCESTOR_DEPTH + 10);
std::fs::write(
root.join(CONFIG_FILE_NAME),
"[project]\ndialect = \"brink\"\n",
)
.unwrap();
assert_eq!(find_config(&deepest), 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();
let (loaded, warnings) = load_from_entry(&entry).unwrap();
assert!(loaded.is_none());
assert!(warnings.is_empty(), "got: {warnings:?}");
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn load_from_entry_surfaces_discovery_warning_when_config_skipped() {
let root = unique_tmp_dir("load-skipped-warning");
let repo = root.join("repo");
std::fs::create_dir_all(&repo).unwrap();
std::fs::create_dir_all(repo.join(".git")).unwrap();
let stray = root.join(CONFIG_FILE_NAME);
std::fs::write(&stray, "[project]\ndialect = \"brink\"\n").unwrap();
let entry = repo.join("story.ink");
std::fs::write(&entry, "content").unwrap();
let (loaded, warnings) = load_from_entry(&entry).unwrap();
assert!(
loaded.is_none(),
"the out-of-repo config must never be loaded"
);
assert_eq!(warnings.len(), 1, "got: {warnings:?}");
assert!(
warnings[0].0.contains(&stray.display().to_string()),
"warning must name the skipped file, got: {}",
warnings[0]
);
std::fs::remove_dir_all(&root).unwrap();
}
#[test]
fn find_config_in_tree_walks_up_from_start_key() {
use brink_source_tree::InMemory;
use std::collections::BTreeMap;
let mut files = BTreeMap::new();
files.insert(
CONFIG_FILE_NAME.to_owned(),
"[project]\ndialect = \"brink\"\n".to_owned(),
);
files.insert("a/b/story.ink".to_owned(), "content".to_owned());
let tree = InMemory::new(files);
let found = find_config_in_tree(&tree, "a/b")
.expect("list succeeds")
.expect("should find brink.toml in an ancestor key");
assert_eq!(found, CONFIG_FILE_NAME);
}
#[test]
fn find_config_in_tree_returns_none_when_absent() {
use brink_source_tree::InMemory;
use std::collections::BTreeMap;
let mut files = BTreeMap::new();
files.insert("a/b/story.ink".to_owned(), "content".to_owned());
let tree = InMemory::new(files);
let found = find_config_in_tree(&tree, "a/b").expect("list succeeds");
assert_eq!(found, None);
}
struct ErrorsOnList {
files: BTreeMap<String, String>,
}
impl SourceTree for ErrorsOnList {
fn list(&self) -> io::Result<Vec<String>> {
Err(io::Error::other(
"find_config_in_tree must not enumerate the tree via SourceTree::list (issue #1370)",
))
}
fn read(&self, key: &str) -> io::Result<String> {
self.files
.get(key)
.cloned()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, format!("{key}: not found")))
}
}
#[test]
fn find_config_in_tree_probes_directly_without_enumerating_the_tree() {
let mut files = BTreeMap::new();
files.insert(
CONFIG_FILE_NAME.to_owned(),
"[project]\ndialect = \"brink\"\n".to_owned(),
);
for i in 0..10_000 {
files.insert(format!("target/build-artifact-{i}.o"), "ignored".to_owned());
}
let tree = ErrorsOnList { files };
let found = find_config_in_tree(&tree, "a/b/c/d")
.expect("direct probing succeeds without ever calling list")
.expect("should find brink.toml at the tree root");
assert_eq!(found, CONFIG_FILE_NAME);
}
#[test]
fn find_config_in_tree_probes_directly_returns_none_without_enumerating_the_tree() {
let mut files = BTreeMap::new();
for i in 0..10_000 {
files.insert(format!(".git/objects/{i}"), "ignored".to_owned());
}
let tree = ErrorsOnList { files };
let found = find_config_in_tree(&tree, "a/b/c/d")
.expect("direct probing succeeds without ever calling list");
assert_eq!(found, None);
}
struct ErrorsOnRead;
impl SourceTree for ErrorsOnRead {
fn list(&self) -> io::Result<Vec<String>> {
Ok(vec![CONFIG_FILE_NAME.to_owned()])
}
fn read(&self, key: &str) -> io::Result<String> {
if key == CONFIG_FILE_NAME {
Err(io::Error::new(
io::ErrorKind::InvalidData,
"not valid utf-8",
))
} else {
Err(io::Error::new(
io::ErrorKind::NotFound,
format!("{key}: not found"),
))
}
}
}
#[test]
fn find_config_in_tree_reports_found_when_the_candidate_read_errors_non_not_found() {
let found = find_config_in_tree(&ErrorsOnRead, "a/b")
.expect("a non-NotFound read error is not propagated")
.expect("the unreadable brink.toml is still reported as found");
assert_eq!(found, CONFIG_FILE_NAME);
}
#[test]
fn discover_from_entry_in_tree_starts_at_entry_parent_key() {
use brink_source_tree::InMemory;
use std::collections::BTreeMap;
let mut files = BTreeMap::new();
files.insert(
CONFIG_FILE_NAME.to_owned(),
"[project]\ntypes = \"strict\"\n".to_owned(),
);
files.insert("story.ink".to_owned(), "content".to_owned());
let tree = InMemory::new(files);
let found = discover_from_entry_in_tree(&tree, "story.ink")
.expect("list succeeds")
.expect("should find brink.toml beside entry key");
assert_eq!(found, CONFIG_FILE_NAME);
}
#[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, discovery_warnings) = load_from_entry(&entry).unwrap();
let loaded = loaded.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());
assert!(discovery_warnings.is_empty(), "got: {discovery_warnings:?}");
std::fs::remove_dir_all(&root).unwrap();
}
}