use super::Key;
use crate::edge::Edge;
use crate::git;
use crate::hooks::Hooks;
use crate::layout::CloneDir;
use crate::log::Level;
use crate::message::Message;
use crate::verb::Verb;
use std::fs;
use std::io;
use std::path::Path;
use toml::value::{Table, Value};
pub(super) fn run(edge: &Edge, clone: &CloneDir, op: &str, rest: &[String]) -> io::Result<()> {
let Some((token, values)) = rest.split_first() else {
return Err(crate::usage(format!("conf {op}: needs <key> <value...>")));
};
let landing = clone.landing();
let actor = &edge.default_actor;
let present = Hooks::effective(&landing, &edge.xdg.user_config())?;
match (Key::parse(token, &present)?, op) {
(Key::Hook(k), _) => hooks_edit(&landing, actor, op, &k, values),
(Key::TaskRemote, "set") => match one(op, token, values)? {
crate::config::STEALTH_REMOTE => declare_stealth(&landing, actor),
url => bind_task_remote(clone, &landing, actor, url),
},
(Key::LogLevel, "set") => {
let value = one(op, token, values)?;
Level::parse(value)?; landing_set(&landing, actor, token, "log_level", value)
}
(Key::TaskBranch, "set") => {
let value = one(op, token, values)?;
crate::config::forbid_landing(value)?; landing_set(&landing, actor, token, "tasks_branch", value)
}
(Key::ClockProvider, "set") => {
binding_set(clone, "clock_provider", one(op, token, values)?)
}
_ => Err(crate::usage(format!(
"conf {op}: '{token}' is a scalar — append/prepend/remove compose the [hooks] list keys"
))),
}?;
eprintln!("conf {op} {token}");
Ok(())
}
fn one<'a>(op: &str, key: &str, values: &'a [String]) -> io::Result<&'a str> {
match values {
[only] => Ok(only),
_ => Err(crate::usage(format!("conf {op}: '{key}' takes exactly one value"))),
}
}
pub(crate) fn bind_task_remote(clone: &CloneDir, landing: &Path, actor: &str, url: &str) -> io::Result<()> {
clear_stealth(landing, actor, url)?;
binding_set(clone, "remote", url)
}
pub(crate) fn declare_stealth(landing: &Path, actor: &str) -> io::Result<()> {
landing_set(landing, actor, "task-remote", "task_remote", crate::config::STEALTH_REMOTE)
}
fn clear_stealth(landing: &Path, actor: &str, url: &str) -> io::Result<()> {
edit_landing_toml(landing, actor, "balls.toml", &format!("balls: conf set task-remote {url}"), |table| {
table.remove("task_remote");
Ok(())
})
}
fn binding_set(clone: &CloneDir, field: &str, value: &str) -> io::Result<()> {
let path = clone.binding();
let mut table = read_table(&path)?;
table.insert(field.into(), Value::String(value.to_string()));
let body = toml::to_string(&Value::Table(table)).expect("a string field always serializes");
fs::create_dir_all(path.parent().expect("the clone binding always has a parent"))?;
let temp = path.with_file_name(format!("binding.toml.{}.tmp", std::process::id()));
let replaced = fs::write(&temp, body).and_then(|()| fs::rename(&temp, &path));
if replaced.is_err() {
let _ = fs::remove_file(&temp); }
replaced
}
fn landing_set(landing: &Path, actor: &str, token: &str, field: &str, value: &str) -> io::Result<()> {
edit_landing_toml(landing, actor, "balls.toml", &format!("balls: conf set {token} {value}"), |table| {
table.insert(field.into(), Value::String(value.to_string()));
Ok(())
})
}
fn hooks_edit(landing: &Path, actor: &str, op: &str, key: &str, values: &[String]) -> io::Result<()> {
if values.iter().any(String::is_empty) {
return Err(crate::usage(format!(
"conf {op}: a plugin name must be non-empty — `conf set {key}` clears the list"
)));
}
if op != "set" {
one(op, key, values)?; }
let subject = format!("balls: conf {op} {key} {}", values.join(" "));
edit_landing_toml(landing, actor, "plugins.toml", &subject, |root| {
let Value::Table(hooks) = root.entry("hooks").or_insert_with(|| Value::Table(Table::new())) else {
return Err(io::Error::other("plugins.toml: [hooks] is not a table"));
};
let mut names: Vec<String> = hooks
.get(key)
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|v| v.as_str().map(str::to_string))
.collect();
match op {
"set" => names = values.to_vec(),
"append" if !names.contains(&values[0]) => names.push(values[0].clone()),
"prepend" if !names.contains(&values[0]) => names.insert(0, values[0].clone()),
"remove" => names.retain(|n| n != &values[0]),
_ => {} }
if names.is_empty() {
hooks.remove(key); } else {
let list = names.into_iter().map(Value::String).collect();
hooks.insert(key.to_string(), Value::Array(list));
}
Ok(())
})
}
pub(crate) fn edit_landing_toml(
landing: &Path,
actor: &str,
file: &str,
subject: &str,
edit: impl FnOnce(&mut Table) -> io::Result<()>,
) -> io::Result<()> {
let path = landing.join("config").join(file);
let mut table = read_table(&path)?;
edit(&mut table)?;
fs::write(&path, toml::to_string(&Value::Table(table)).expect("a hooks/scalar table always serializes"))?;
git::run(landing, &["add", "-A", "config"], None)?;
if git::run(landing, &["diff", "--cached", "--quiet"], None).is_ok() {
return Ok(()); }
let message = Message::checkout(Verb::Conf, actor, subject.to_string()).render()?;
git::run(landing, &["commit", "-q", "-F", "-"], Some(&message))?;
Ok(())
}
fn read_table(path: &Path) -> io::Result<Table> {
Ok(crate::config::read_layer(path)?.unwrap_or_default())
}
#[cfg(test)]
#[path = "conf_write_tests.rs"]
mod tests;