pub mod edit;
pub mod globs;
pub use edit::{ConfigDocument, EditError};
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,
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
PartialOrd,
Ord,
Default,
serde::Serialize,
serde::Deserialize,
)]
pub enum FixPolicy {
Off,
#[default]
Ask,
Auto,
}
use thiserror::Error;
use toml::Value;
pub const CONFIG_FILE_NAME: &str = "brink.toml";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ProseDialect {
#[default]
American,
British,
Canadian,
Australian,
}
impl ProseDialect {
pub fn as_str(self) -> &'static str {
match self {
Self::American => "american",
Self::British => "british",
Self::Canadian => "canadian",
Self::Australian => "australian",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DialogueConfig {
pub preset: Option<String>,
pub file: Option<String>,
pub elements: Vec<DialogueElementConfig>,
pub run_ends_at: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DialogueElementConfig {
pub kind: String,
pub nature: Option<String>,
pub prefix: Option<String>,
pub suffix: Option<String>,
pub glued: Option<bool>,
pub content_role: Option<String>,
pub pattern: Option<String>,
pub template: Option<String>,
}
#[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 fix: BTreeMap<String, FixPolicy>,
pub unprune_dirs: Vec<String>,
pub indent: Option<u8>,
pub drafts: Vec<String>,
pub prose_dialect: Option<ProseDialect>,
pub prose_enable: Option<bool>,
pub prose_dictionary: Vec<String>,
pub conventions: Option<String>,
pub dialogue: Option<DialogueConfig>,
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.fix.is_empty()
&& self.unprune_dirs.is_empty()
&& self.indent.is_none()
&& self.conventions.is_none()
&& self.entry.is_none()
&& self.prose_dialect.is_none()
&& self.prose_enable.is_none()
&& self.prose_dictionary.is_empty()
&& self.dialogue.is_none()
}
#[must_use]
pub fn effective_fix_policy(&self, code: &str, app_ceiling: Option<FixPolicy>) -> FixPolicy {
let project = self.fix.get(code).copied().unwrap_or_default();
match app_ceiling {
Some(ceiling) => project.min(ceiling),
None => project,
}
}
}
#[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 == "prose" {
let prose = match value {
Value::Table(t) => t,
other => {
return Err(ConfigError::NotATable {
path,
key: "prose".to_owned(),
found: value_type_name(other),
});
}
};
parse_prose_table(&path, prose, &mut config, &mut warnings)?;
} else if key == "dialogue" {
match value {
Value::Table(t) => {
let mut cfg = DialogueConfig::default();
parse_dialogue_table(&path, t, &mut cfg, &mut warnings)?;
config.dialogue = Some(cfg);
}
Value::String(file) => {
config.dialogue = Some(DialogueConfig {
file: Some(file.clone()),
..DialogueConfig::default()
});
}
other => {
return Err(ConfigError::WrongType {
path,
key: "dialogue".to_owned(),
found: value_type_name(other),
});
}
}
} 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 if key == "fix" {
parse_fix_table(&path, value, &mut config)?;
} else {
warnings.push(ConfigWarning(format!(
"unknown top-level key `{key}` in {CONFIG_FILE_NAME} (ignored)"
)));
}
}
Ok((config, warnings))
}
fn parse_fix_table(
path: &str,
value: &Value,
config: &mut ProjectConfig,
) -> Result<(), ConfigError> {
let fix = match value {
Value::Table(t) => t,
other => {
return Err(ConfigError::NotATable {
path: path.to_owned(),
key: "fix".to_owned(),
found: value_type_name(other),
});
}
};
for (fkey, fvalue) in fix {
config
.fix
.insert(fkey.clone(), parse_fix_policy(path, fkey, fvalue)?);
}
Ok(())
}
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)?),
"indent" => {
config.indent = Some(parse_indent(path, pkey, pvalue, warnings)?);
}
"drafts" => {
let globs = parse_string_list(path, &format!("project.{pkey}"), pvalue)?;
for glob in &globs {
if glob.is_empty() {
warnings.push(ConfigWarning(format!(
"`project.drafts` in {CONFIG_FILE_NAME} contains an empty string \
(ignored) — expected a project-relative path or glob (e.g. \
\"scratch/**\")"
)));
} else if glob.starts_with('/') || glob.contains("..") {
warnings.push(ConfigWarning(format!(
"`project.drafts` entry `{glob}` in {CONFIG_FILE_NAME} is not \
project-relative (ignored) — drafts globs match paths inside the \
project, so leading `/` and `..` never match anything"
)));
}
}
config.drafts = globs;
}
"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_dialogue_table(
path: &str,
table: &toml::map::Map<String, Value>,
cfg: &mut DialogueConfig,
warnings: &mut Vec<ConfigWarning>,
) -> Result<(), ConfigError> {
for (dkey, dvalue) in table {
match dkey.as_str() {
"preset" => {
cfg.preset = Some(
dvalue
.as_str()
.ok_or_else(|| ConfigError::WrongType {
path: path.to_owned(),
key: format!("dialogue.{dkey}"),
found: value_type_name(dvalue),
})?
.to_owned(),
);
}
"file" => {
cfg.file = Some(
dvalue
.as_str()
.ok_or_else(|| ConfigError::WrongType {
path: path.to_owned(),
key: format!("dialogue.{dkey}"),
found: value_type_name(dvalue),
})?
.to_owned(),
);
}
"run-ends-at" => {
cfg.run_ends_at = parse_string_list(path, &format!("dialogue.{dkey}"), dvalue)?;
}
"elements" => {
let rows = dvalue.as_array().ok_or_else(|| ConfigError::WrongType {
path: path.to_owned(),
key: format!("dialogue.{dkey}"),
found: value_type_name(dvalue),
})?;
for (i, row) in rows.iter().enumerate() {
let t = row.as_table().ok_or_else(|| ConfigError::NotATable {
path: path.to_owned(),
key: format!("dialogue.elements[{i}]"),
found: value_type_name(row),
})?;
cfg.elements
.push(parse_dialogue_element(path, i, t, warnings)?);
}
}
_ => warnings.push(ConfigWarning(format!(
"unknown key `dialogue.{dkey}` in {CONFIG_FILE_NAME} (ignored)"
))),
}
}
Ok(())
}
fn parse_dialogue_element(
path: &str,
index: usize,
t: &toml::map::Map<String, Value>,
warnings: &mut Vec<ConfigWarning>,
) -> Result<DialogueElementConfig, ConfigError> {
let mut el = DialogueElementConfig::default();
let key_of = |k: &str| format!("dialogue.elements[{index}].{k}");
let str_at = |k: &str, v: &Value| -> Result<String, ConfigError> {
v.as_str()
.map(str::to_owned)
.ok_or_else(|| ConfigError::WrongType {
path: path.to_owned(),
key: key_of(k),
found: value_type_name(v),
})
};
for (k, v) in t {
match k.as_str() {
"kind" => el.kind = str_at(k, v)?,
"nature" => el.nature = Some(str_at(k, v)?),
"prefix" => el.prefix = Some(str_at(k, v)?),
"suffix" => el.suffix = Some(str_at(k, v)?),
"content-role" => el.content_role = Some(str_at(k, v)?),
"pattern" => el.pattern = Some(str_at(k, v)?),
"template" => el.template = Some(str_at(k, v)?),
"glued" => {
el.glued = Some(v.as_bool().ok_or_else(|| ConfigError::WrongType {
path: path.to_owned(),
key: key_of(k),
found: value_type_name(v),
})?);
}
_ => warnings.push(ConfigWarning(format!(
"unknown key `{}` in {CONFIG_FILE_NAME} (ignored)",
key_of(k)
))),
}
}
if el.kind.is_empty() {
return Err(ConfigError::WrongType {
path: path.to_owned(),
key: key_of("kind"),
found: "missing (every element needs a `kind`)",
});
}
Ok(el)
}
fn parse_prose_table(
path: &str,
prose: &toml::map::Map<String, Value>,
config: &mut ProjectConfig,
warnings: &mut Vec<ConfigWarning>,
) -> Result<(), ConfigError> {
for (pkey, pvalue) in prose {
match pkey.as_str() {
"dialect" => {
let raw = pvalue.as_str().ok_or_else(|| ConfigError::WrongType {
path: path.to_owned(),
key: format!("prose.{pkey}"),
found: value_type_name(pvalue),
})?;
match raw {
"american" => config.prose_dialect = Some(ProseDialect::American),
"british" => config.prose_dialect = Some(ProseDialect::British),
"canadian" => config.prose_dialect = Some(ProseDialect::Canadian),
"australian" => config.prose_dialect = Some(ProseDialect::Australian),
other => warnings.push(ConfigWarning(format!(
"`prose.dialect` in {CONFIG_FILE_NAME} is `{other}` — expected one of \
`american`, `british`, `canadian`, `australian`; using \
`{}`",
ProseDialect::default().as_str()
))),
}
}
"enable" => {
config.prose_enable =
Some(pvalue.as_bool().ok_or_else(|| ConfigError::WrongType {
path: path.to_owned(),
key: format!("prose.{pkey}"),
found: value_type_name(pvalue),
})?);
}
"dictionary" => {
config.prose_dictionary =
parse_string_list(path, &format!("prose.{pkey}"), pvalue)?;
}
_ => warnings.push(ConfigWarning(format!(
"unknown key `prose.{pkey}` in {CONFIG_FILE_NAME} (ignored)"
))),
}
}
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_fix_policy(path: &str, key: &str, value: &Value) -> Result<FixPolicy, ConfigError> {
let s = value.as_str().ok_or_else(|| ConfigError::WrongType {
path: path.to_owned(),
key: format!("fix.{key}"),
found: value_type_name(value),
})?;
match s {
"off" => Ok(FixPolicy::Off),
"ask" => Ok(FixPolicy::Ask),
"auto" => Ok(FixPolicy::Auto),
other => Err(ConfigError::InvalidValue {
path: path.to_owned(),
key: format!("fix.{key}"),
expected: &["off", "ask", "auto"],
found: other.to_owned(),
}),
}
}
pub const DEFAULT_INDENT: u8 = 4;
const INDENT_RANGE: std::ops::RangeInclusive<i64> = 1..=16;
fn parse_indent(
path: &str,
key: &str,
value: &Value,
warnings: &mut Vec<ConfigWarning>,
) -> Result<u8, ConfigError> {
let raw = value.as_integer().ok_or_else(|| ConfigError::WrongType {
path: path.to_owned(),
key: format!("project.{key}"),
found: value_type_name(value),
})?;
if !INDENT_RANGE.contains(&raw) {
let (lo, hi) = (INDENT_RANGE.start(), INDENT_RANGE.end());
warnings.push(ConfigWarning(format!(
"`project.indent` in {CONFIG_FILE_NAME} is {raw}, outside {lo}..={hi} — using the \
default of {DEFAULT_INDENT} spaces instead"
)));
return Ok(DEFAULT_INDENT);
}
Ok(u8::try_from(raw).unwrap_or(DEFAULT_INDENT))
}
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: key.to_owned(),
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: key.to_owned(),
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 {
mod drafts {
use super::super::{globs, parse_str};
#[test]
fn drafts_parse_as_a_string_list() {
let (config, warnings) =
parse_str("[project]\ndrafts = [\"scratch/**\", \"*.draft.ink\"]\n")
.expect("valid config");
assert_eq!(config.drafts, vec!["scratch/**", "*.draft.ink"]);
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
}
#[test]
fn drafts_default_to_empty() {
let (config, _) = parse_str("[project]\nentry = \"main.ink\"\n").expect("valid");
assert!(config.drafts.is_empty());
}
#[test]
fn a_non_project_relative_glob_warns_but_still_parses() {
let (config, warnings) =
parse_str("[project]\ndrafts = [\"/tmp/**\", \"../out/**\", \"\"]\n")
.expect("valid config");
assert_eq!(config.drafts.len(), 3);
assert_eq!(warnings.len(), 3, "got {warnings:?}");
assert!(warnings.iter().any(|w| w.0.contains("/tmp/**")));
assert!(warnings.iter().any(|w| w.0.contains("../out/**")));
assert!(warnings.iter().any(|w| w.0.contains("empty string")));
assert!(!globs::matches_any("tmp/notes.ink", &config.drafts));
}
#[test]
fn a_non_list_value_is_an_error() {
assert!(parse_str("[project]\ndrafts = \"scratch/**\"\n").is_err());
}
}
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_the_prose_table() {
let (config, warnings) =
parse_str("[prose]\ndialect = \"british\"\nenable = true\n").expect("valid");
assert_eq!(config.prose_dialect, Some(ProseDialect::British));
assert_eq!(config.prose_enable, Some(true));
assert!(warnings.is_empty(), "{warnings:?}");
}
#[test]
fn parses_the_prose_dictionary() {
let (config, warnings) =
parse_str("[prose]\ndictionary = [\n \"Griswold\",\n \"Kaelen\",\n]\n")
.expect("valid");
assert_eq!(config.prose_dictionary, vec!["Griswold", "Kaelen"]);
assert!(warnings.is_empty(), "{warnings:?}");
}
#[test]
fn an_absent_prose_dictionary_is_empty_rather_than_an_error() {
let (config, _) = parse_str("[prose]\ndialect = \"british\"\n").expect("valid");
assert!(config.prose_dictionary.is_empty());
}
#[test]
fn a_prose_dictionary_that_is_not_a_list_of_strings_reports_its_own_key() {
let err = parse_str("[prose]\ndictionary = [1, 2]\n").expect_err("not strings");
let text = err.to_string();
assert!(text.contains("prose.dictionary"), "{text}");
}
#[test]
fn a_dictionary_makes_the_config_non_empty() {
let (config, _) = parse_str("[prose]\ndictionary = [\"Ada\"]\n").expect("valid");
assert!(!config.is_empty());
}
#[test]
fn prose_keys_are_none_when_unset_so_callers_apply_their_own_defaults() {
let (config, _) = parse_str("[project]\nentry = \"story.ink\"\n").expect("valid");
assert_eq!(config.prose_dialect, None);
assert_eq!(config.prose_enable, None);
}
#[test]
fn every_dialect_spelling_round_trips() {
for (raw, expected) in [
("american", ProseDialect::American),
("british", ProseDialect::British),
("canadian", ProseDialect::Canadian),
("australian", ProseDialect::Australian),
] {
let (config, warnings) =
parse_str(&format!("[prose]\ndialect = \"{raw}\"\n")).expect("valid");
assert_eq!(config.prose_dialect, Some(expected), "parsing {raw}");
assert_eq!(expected.as_str(), raw, "as_str for {raw}");
assert!(warnings.is_empty(), "{warnings:?}");
}
}
#[test]
fn an_unknown_dialect_warns_and_falls_back_rather_than_failing_the_config() {
let (config, warnings) =
parse_str("[project]\nentry = \"story.ink\"\n\n[prose]\ndialect = \"martian\"\n")
.expect("still valid");
assert_eq!(
config.prose_dialect, None,
"falls back to the caller default"
);
assert_eq!(
config.entry.as_deref(),
Some("story.ink"),
"the rest still parsed"
);
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(warnings[0].0.contains("martian"), "{warnings:?}");
}
#[test]
fn an_unknown_prose_key_warns_and_is_ignored() {
let (config, warnings) = parse_str("[prose]\nvoice = \"formal\"\n").expect("valid");
assert!(config.is_empty());
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(warnings[0].0.contains("prose.voice"), "{warnings:?}");
}
#[test]
fn a_prose_table_that_is_not_a_table_is_an_error() {
assert!(parse_str("prose = 3\n").is_err());
}
#[test]
fn parses_indent() {
let (config, warnings) = parse_str("[project]\nindent = 2\n").expect("valid");
assert_eq!(config.indent, Some(2));
assert!(warnings.is_empty(), "{warnings:?}");
}
#[test]
fn indent_is_none_when_unset_so_callers_apply_the_default() {
let (config, _) = parse_str("[project]\n").expect("valid");
assert_eq!(config.indent, None);
}
#[test]
fn a_non_integer_indent_is_an_error() {
let err = parse_str("[project]\nindent = \"four\"\n").unwrap_err();
assert!(matches!(err, ConfigError::WrongType { .. }), "got {err:?}");
}
#[test]
fn an_out_of_range_indent_warns_and_falls_back() {
for raw in ["0", "17", "400", "-2"] {
let (config, warnings) =
parse_str(&format!("[project]\nindent = {raw}\n")).expect("loads anyway");
assert_eq!(config.indent, Some(DEFAULT_INDENT), "for {raw}");
assert_eq!(warnings.len(), 1, "for {raw}: {warnings:?}");
assert!(warnings[0].0.contains("indent"), "for {raw}: {warnings:?}");
}
}
#[test]
fn the_range_bounds_are_themselves_accepted() {
for raw in ["1", "16"] {
let (config, warnings) =
parse_str(&format!("[project]\nindent = {raw}\n")).expect("valid");
assert!(warnings.is_empty(), "for {raw}: {warnings:?}");
assert_eq!(
config.indent.map(u32::from),
Some(raw.parse::<u32>().expect("num"))
);
}
}
#[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 { .. }));
}
#[test]
fn parses_per_code_fix_policies() {
let (config, warnings) = parse_str(
r#"
[fix]
E033 = "auto"
E014 = "off"
E022 = "ask"
"#,
)
.unwrap();
assert_eq!(config.fix.get("E033"), Some(&FixPolicy::Auto));
assert_eq!(config.fix.get("E014"), Some(&FixPolicy::Off));
assert_eq!(config.fix.get("E022"), Some(&FixPolicy::Ask));
assert!(warnings.is_empty());
}
#[test]
fn absent_fix_table_is_empty_config() {
let (config, _) = parse_str("[project]\ndialect = \"brink\"\n").unwrap();
assert!(config.fix.is_empty());
}
#[test]
fn invalid_fix_policy_value_is_an_error_not_a_panic() {
let err = parse_str("[fix]\nE033 = \"sideways\"\n").unwrap_err();
assert!(matches!(err, ConfigError::InvalidValue { .. }));
}
#[test]
fn wrong_type_fix_policy_is_an_error_not_a_panic() {
let err = parse_str("[fix]\nE033 = 1\n").unwrap_err();
assert!(matches!(err, ConfigError::WrongType { .. }));
}
#[test]
fn non_table_fix_is_an_error_not_a_panic() {
let err = parse_str("fix = 1\n").unwrap_err();
assert!(matches!(err, ConfigError::NotATable { .. }));
}
#[test]
fn unrecognized_fix_code_parses_fine_here() {
let (config, warnings) = parse_str("[fix]\nE9999 = \"auto\"\n").unwrap();
assert_eq!(config.fix.get("E9999"), Some(&FixPolicy::Auto));
assert!(warnings.is_empty());
}
#[test]
fn effective_fix_policy_defaults_to_ask_when_unset() {
let config = ProjectConfig::default();
assert_eq!(config.effective_fix_policy("E033", None), FixPolicy::Ask);
}
#[test]
fn effective_fix_policy_with_no_ceiling_is_the_project_entry() {
let (config, _) = parse_str("[fix]\nE033 = \"auto\"\n").unwrap();
assert_eq!(config.effective_fix_policy("E033", None), FixPolicy::Auto);
}
#[test]
fn effective_fix_policy_ceiling_truth_table() {
let cases: &[(FixPolicy, Option<FixPolicy>, FixPolicy)] = &[
(FixPolicy::Auto, None, FixPolicy::Auto),
(FixPolicy::Auto, Some(FixPolicy::Auto), FixPolicy::Auto),
(FixPolicy::Auto, Some(FixPolicy::Ask), FixPolicy::Ask),
(FixPolicy::Auto, Some(FixPolicy::Off), FixPolicy::Off),
(FixPolicy::Ask, None, FixPolicy::Ask),
(FixPolicy::Ask, Some(FixPolicy::Auto), FixPolicy::Ask),
(FixPolicy::Ask, Some(FixPolicy::Ask), FixPolicy::Ask),
(FixPolicy::Ask, Some(FixPolicy::Off), FixPolicy::Off),
(FixPolicy::Off, None, FixPolicy::Off),
(FixPolicy::Off, Some(FixPolicy::Auto), FixPolicy::Off),
(FixPolicy::Off, Some(FixPolicy::Ask), FixPolicy::Off),
(FixPolicy::Off, Some(FixPolicy::Off), FixPolicy::Off),
];
for (project_entry, ceiling, expected) in cases.iter().copied() {
let mut config = ProjectConfig::default();
config.fix.insert("E033".to_owned(), project_entry);
let effective = config.effective_fix_policy("E033", ceiling);
assert_eq!(
effective, expected,
"project={project_entry:?} ceiling={ceiling:?}: expected {expected:?}, got \
{effective:?}"
);
}
}
#[test]
fn effective_fix_policy_ceiling_never_raises_past_off() {
let (config, _) = parse_str("[fix]\nE014 = \"off\"\n").unwrap();
assert_eq!(
config.effective_fix_policy("E014", Some(FixPolicy::Auto)),
FixPolicy::Off
);
}
#[test]
fn fix_policy_round_trips_through_the_edit_write_path() {
let mut doc = crate::edit::ConfigDocument::parse("[project]\nentry = \"main.ink\"\n")
.expect("valid toml");
doc.set_string("fix", "E033", "auto").expect("edit");
let text = doc.to_toml_string();
let (config, warnings) = parse_str(&text).expect("round-tripped text still parses");
assert!(warnings.is_empty());
assert_eq!(config.fix.get("E033"), Some(&FixPolicy::Auto));
assert_eq!(config.effective_fix_policy("E033", None), FixPolicy::Auto);
assert!(text.contains("entry = \"main.ink\""));
}
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();
}
#[test]
fn dialogue_is_none_when_the_file_declares_nothing() {
let (config, _) = parse_str("[project]\nentry = \"story.ink\"\n").expect("valid");
assert_eq!(
config.dialogue, None,
"no [dialogue] = no dialect, never a preset"
);
assert!(!config.is_empty() || config.dialogue.is_none());
}
#[test]
fn dialogue_table_parses_preset_overlay_elements_and_run_rule() {
let toml = r#"
[dialogue]
preset = "at-cue"
run-ends-at = ["character", "action"]
[[dialogue.elements]]
kind = "action"
nature = "narrative"
prefix = ">"
[[dialogue.elements]]
kind = "aside"
pattern = "^\\[(?<content>[^\\]]*)\\]$"
template = "[${content}]"
content-role = "content"
glued = false
"#;
let (config, warnings) = parse_str(toml).expect("valid");
assert!(warnings.is_empty(), "{warnings:?}");
let d = config.dialogue.expect("declared");
assert_eq!(d.preset.as_deref(), Some("at-cue"));
assert_eq!(d.file, None);
assert_eq!(d.run_ends_at, vec!["character", "action"]);
assert_eq!(d.elements.len(), 2);
assert_eq!(d.elements[0].kind, "action");
assert_eq!(d.elements[0].prefix.as_deref(), Some(">"));
assert_eq!(d.elements[0].nature.as_deref(), Some("narrative"));
assert_eq!(
d.elements[1].pattern.as_deref(),
Some(r"^\[(?<content>[^\]]*)\]$")
);
assert_eq!(d.elements[1].glued, Some(false));
}
#[test]
fn dialogue_string_form_is_the_file_escape_hatch() {
let (config, _) = parse_str("dialogue = \"dialect.json\"\n").expect("valid");
let d = config.dialogue.expect("declared");
assert_eq!(d.file.as_deref(), Some("dialect.json"));
assert_eq!(d.preset, None);
assert!(d.elements.is_empty());
}
#[test]
fn dialogue_unknown_keys_warn_and_wrong_types_error() {
let (_, warnings) =
parse_str("[dialogue]\npreset = \"at-cue\"\ncolour = \"x\"\n").expect("valid");
assert!(
warnings.iter().any(|w| w.0.contains("dialogue.colour")),
"{warnings:?}"
);
let err = parse_str("[dialogue]\npreset = 3\n").expect_err("wrong type");
assert!(matches!(err, ConfigError::WrongType { .. }), "{err:?}");
let err = parse_str("dialogue = 3\n").expect_err("wrong type at the top level");
assert!(matches!(err, ConfigError::WrongType { .. }), "{err:?}");
let err =
parse_str("[[dialogue.elements]]\nprefix = \">\"\n").expect_err("kind is required");
assert!(matches!(err, ConfigError::WrongType { .. }), "{err:?}");
}
}