use crate::config;
use crate::edge::Edge;
use crate::hooks::Hooks;
use crate::layout::CloneDir;
use crate::verb::Verb;
use std::io;
use std::path::Path;
#[path = "conf_write.rs"]
mod write;
pub(crate) use write::declare_stealth;
#[derive(Debug)]
pub(crate) enum Key {
TaskRemote,
TaskBranch,
LogLevel,
Hook(String),
}
impl Key {
pub(crate) fn parse(token: &str) -> io::Result<Key> {
match token {
"task-remote" => Ok(Key::TaskRemote),
"task-branch" => Ok(Key::TaskBranch),
"log-level" => Ok(Key::LogLevel),
"show" | "list" => Ok(Key::Hook(token.to_string())),
_ if hook_key(token) => Ok(Key::Hook(token.to_string())),
_ => Err(io::Error::other(format!(
"conf: unknown key '{token}' — keys: task-remote, task-branch, log-level, <op>.<pre|post>, show, list"
))),
}
}
}
fn hook_key(token: &str) -> bool {
let Some((op, phase)) = token.split_once('.') else {
return false;
};
matches!(phase, "pre" | "post")
&& matches!(Verb::parse(op), Some(v) if !matches!(v, Verb::Show | Verb::List | Verb::Conf))
}
struct Resolved {
value: String,
layer: String,
}
pub fn run(edge: &Edge, args: &[String]) -> io::Result<()> {
let clone = edge.xdg.clone_dir(&edge.invocation_path);
if !clone.landing().join("config").is_dir() {
return Err(io::Error::other("no balls checkout here — run `bl prime` first"));
}
match args.split_first() {
None => dump(edge, &clone),
Some((op, rest)) if matches!(op.as_str(), "set" | "append" | "prepend" | "remove") => {
write::run(edge, &clone, op, rest)
}
Some((key, [])) => read_one(edge, &clone, key),
Some((key, _)) => Err(io::Error::other(format!(
"conf: '{key}' takes no value on a read — values go with set/append/prepend/remove"
))),
}
}
fn read_one(edge: &Edge, clone: &CloneDir, key: &str) -> io::Result<()> {
let r = resolve(edge, clone, &Key::parse(key)?)?;
println!("{}", r.value);
eprintln!("conf: {key} from {}", r.layer);
Ok(())
}
fn dump(edge: &Edge, clone: &CloneDir) -> io::Result<()> {
let mut rows = Vec::new();
for key in ["task-remote", "task-branch", "log-level"] {
rows.push((key.to_string(), resolve(edge, clone, &Key::parse(key)?)?));
}
let landing = clone.landing();
let hooks = Hooks::effective(&landing, &edge.xdg.user_config())?;
for (key, names) in hooks.entries() {
let value = if names.is_empty() { "(none)".into() } else { names.join(", ") };
rows.push((key.clone(), Resolved { value, layer: hook_layer(edge, clone, key)? }));
}
let key_w = rows.iter().map(|(k, _)| k.len()).fold(0, usize::max) + 2;
let val_w = rows.iter().map(|(_, r)| r.value.len()).fold(0, usize::max) + 2;
for (key, r) in rows {
println!("{key:<key_w$}{:<val_w$}{}", r.value, r.layer);
}
println!();
println!("xdg {}", edge.xdg.user_config().display());
println!("landing {}", landing.display());
println!("store {}", clone.store().display());
Ok(())
}
fn resolve(edge: &Edge, clone: &CloneDir, key: &Key) -> io::Result<Resolved> {
let landing = clone.landing();
match key {
Key::TaskRemote => task_remote(edge, &landing),
Key::TaskBranch => scalar(edge, &landing, "tasks_branch", crate::DEFAULT_TASKS_BRANCH, None),
Key::LogLevel => scalar(edge, &landing, "log_level", "info", edge.log_level.as_deref()),
Key::Hook(k) => {
let hooks = Hooks::effective(&landing, &edge.xdg.user_config())?;
let names = hooks
.entries()
.find(|(key, _)| *key == k)
.map(|(_, names)| names.join(", "))
.filter(|joined| !joined.is_empty());
Ok(Resolved {
value: names.unwrap_or_else(|| "(none)".into()),
layer: hook_layer(edge, clone, k)?,
})
}
}
}
fn task_remote(edge: &Edge, landing: &Path) -> io::Result<Resolved> {
let (remote, declared) = config::remote_ladder(None, landing, &edge.xdg.user_config())?;
if declared {
return Ok(Resolved { value: "(none)".into(), layer: "landing".into() });
}
if let Some(url) = remote {
return Ok(Resolved { value: url, layer: "xdg".into() });
}
Ok(match origin(&edge.invocation_path) {
Some(url) => Resolved { value: url, layer: "origin".into() },
None => Resolved { value: "(none)".into(), layer: "stealth".into() },
})
}
fn origin(project: &Path) -> Option<String> {
let url = crate::git::run(project, &["remote", "get-url", "origin"], None).ok()?;
Some(url.trim().to_string())
}
fn scalar(edge: &Edge, landing: &Path, field: &str, default: &str, cli: Option<&str>) -> io::Result<Resolved> {
if let Some(v) = cli {
return Ok(Resolved { value: v.to_string(), layer: "cli".into() });
}
let layers = [
(config::read_layer(&edge.xdg.user_config())?, "xdg"),
(config::read_layer(&landing.join("config").join("balls.toml"))?, "landing"),
];
for (table, layer) in layers {
if let Some(v) = table.as_ref().and_then(|t| t.get(field)).and_then(|v| v.as_str()) {
return Ok(Resolved { value: v.to_string(), layer: layer.into() });
}
}
Ok(Resolved { value: default.to_string(), layer: "default".into() })
}
fn hook_layer(edge: &Edge, clone: &CloneDir, key: &str) -> io::Result<String> {
let landing = hooks_mentions(&clone.landing().join("config").join("plugins.toml"), key)?;
let xdg = hooks_mentions(&edge.xdg.user_config().with_file_name("plugins.toml"), key)?;
Ok(match (landing, xdg) {
(true, true) => "landing+xdg".into(),
(true, false) => "landing".into(),
(false, true) => "xdg".into(),
(false, false) => "default".into(),
})
}
fn hooks_mentions(path: &Path, key: &str) -> io::Result<bool> {
let Some(root) = config::read_layer(path)? else {
return Ok(false);
};
let Some(toml::Value::Table(hooks)) = root.get("hooks") else {
return Ok(false);
};
Ok(hooks.contains_key(key)
|| ["_prepend", "_append", "_ban"].iter().any(|s| hooks.contains_key(&format!("{key}{s}"))))
}
#[cfg(test)]
#[path = "conf_tests.rs"]
mod tests;