use crate::DEFAULT_TASKS_BRANCH;
use serde::Deserialize;
use std::fs;
use std::io;
use std::path::Path;
use toml::value::{Table, Value};
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct EffectiveConfig {
#[serde(default = "default_tasks_branch")]
pub tasks_branch: String,
#[serde(default = "default_log_level")]
pub log_level: String,
}
fn default_tasks_branch() -> String {
DEFAULT_TASKS_BRANCH.to_string()
}
fn default_log_level() -> String {
"info".to_string()
}
impl Default for EffectiveConfig {
fn default() -> EffectiveConfig {
EffectiveConfig { tasks_branch: default_tasks_branch(), log_level: default_log_level() }
}
}
impl EffectiveConfig {
pub fn resolve(landing: &Path, user_config: &Path) -> io::Result<EffectiveConfig> {
let mut merged = Table::new();
if let Some(layer) = read_layer(&landing.join("config").join("balls.toml"))? {
layer_over(&mut merged, layer);
}
if let Some(layer) = read_layer(user_config)? {
layer_over(&mut merged, layer);
}
let cfg: EffectiveConfig = Value::Table(merged)
.try_into()
.map_err(|e| io::Error::other(format!("config does not resolve: {e}")))?;
forbid_landing(&cfg.tasks_branch)?;
Ok(cfg)
}
}
pub(crate) fn forbid_landing(tasks_branch: &str) -> io::Result<()> {
if tasks_branch == crate::LANDING_BRANCH {
return Err(io::Error::other(format!(
"tasks_branch '{tasks_branch}' names the landing — one branch cannot back two checkouts, \
and the landing is single-owner, never a store (§2/§4); pick another: bl conf set task-branch <branch>"
)));
}
Ok(())
}
pub const STEALTH_REMOTE: &str = "none";
pub fn xdg_remote(user_config: &Path) -> Option<String> {
let table = read_layer(user_config).ok().flatten()?;
table.get("remote")?.as_str().map(str::to_string)
}
pub fn landing_remote(landing: &Path) -> io::Result<Option<String>> {
let layer = read_layer(&landing.join("config").join("balls.toml"))?;
Ok(layer.and_then(|t| t.get("task_remote").and_then(Value::as_str).map(str::to_string)))
}
pub fn remote_ladder(cli: Option<String>, landing: &Path, user_config: &Path) -> io::Result<(Option<String>, bool)> {
if cli.is_some() {
return Ok((cli, false));
}
match landing_remote(landing)? {
Some(v) if v == STEALTH_REMOTE => Ok((None, true)),
Some(v) => Err(io::Error::other(format!(
"task_remote '{v}' in the landing config — the landing holds only the stealth sentinel \
'{STEALTH_REMOTE}' (a remote URL is per-machine: `bl conf set task-remote <url>`, §4/§12)"
))),
None => Ok((xdg_remote(user_config), false)),
}
}
pub(crate) fn read_layer(path: &Path) -> io::Result<Option<Table>> {
match fs::read_to_string(path) {
Ok(text) => toml::from_str::<Table>(&text)
.map(Some)
.map_err(|e| io::Error::other(format!("{}: {e}", path.display()))),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}
pub(crate) fn layer_over(base: &mut Table, inner: Table) {
for (key, value) in inner {
match list_directive(&key) {
Some((field, op)) => compose_list(base, field, op, value),
None => {
base.insert(key, value);
}
}
}
}
#[derive(Clone, Copy)]
enum ListOp {
Prepend,
Append,
Ban,
}
fn list_directive(key: &str) -> Option<(&str, ListOp)> {
const SUFFIXES: [(&str, ListOp); 3] = [
("_prepend", ListOp::Prepend),
("_append", ListOp::Append),
("_ban", ListOp::Ban),
];
SUFFIXES.into_iter().find_map(|(suffix, op)| {
key.strip_suffix(suffix)
.filter(|field| !field.is_empty())
.map(|field| (field, op))
})
}
fn compose_list(base: &mut Table, field: &str, op: ListOp, value: Value) {
let incoming = as_array(&value);
let current = base.get(field).map(as_array).unwrap_or_default();
let merged: Vec<Value> = match op {
ListOp::Prepend => incoming.into_iter().chain(current).collect(),
ListOp::Append => current.into_iter().chain(incoming).collect(),
ListOp::Ban => current.into_iter().filter(|v| !incoming.contains(v)).collect(),
};
base.insert(field.to_string(), Value::Array(merged));
}
fn as_array(value: &Value) -> Vec<Value> {
value.as_array().cloned().unwrap_or_default()
}
#[cfg(test)]
#[path = "config_tests.rs"]
mod tests;