balls 0.5.13

Git-native task tracker for parallel agent workflows
Documentation
//! §4 `bl conf` writes — scope-keyed CRUD on each key's canonical home.
//!
//! `set` replaces (a scalar, or a hooks key's whole list); `append`/`prepend`/
//! `remove` compose a list — the §4 directive vocabulary APPLIED AT WRITE TIME
//! to the canonical bare list, never stored as `_append`/`_prepend`/`_ban` keys
//! beside it (one fact, one home; the directive keys remain the cross-LAYER
//! compose for a hand-written XDG overlay). Compose converges (§13 idempotence):
//! appending a present name or removing an absent one is a no-op, and a list
//! emptied by `remove` drops its key (absent/empty = run nothing, §4/§6).
//!
//! Homes: `task-remote` routes by VALUE — a URL ⇒ this clone's `binding.toml` (a
//! plain per-checkout local-state file edit, clearing any landing stealth
//! sentinel so the set changes what the ladder resolves — never the machine-wide
//! XDG file that silently shadowed every other repo's store, bl-d081), the
//! sentinel `none` ⇒ the landing
//! `task_remote` policy rung (§12, bl-9df0); `clock-provider` ⇒ this clone's
//! `binding.toml` too (§8, bl-cfe3 — a box-local op-clock value, never a landing
//! field, never travels on `install`); `task-branch`/`log-level` ⇒ the
//! landing `balls.toml` and the hooks
//! keys ⇒ the landing `plugins.toml`, each an ordinary commit on `balls/config`
//! (`balls: conf <op> <key> …`, checkout-scoped — §5). A write that changes
//! nothing seals nothing: git's own empty-diff check is the change detector,
//! the same trick as the §8 no-op seal. Foreign tables in either TOML file
//! round-trip untouched — this edits ONE key, never re-shapes the document.

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};

/// Dispatch one write: `bl conf <op> <key> <value...>`. The key implies its
/// home (§4); a list op on a scalar key is refused, naming the split.
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(), &edge.machine_dirs())?;
    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)?; // refuse a level the ladder won't speak
            landing_set(&landing, actor, token, "log_level", value)
        }
        (Key::TaskBranch, "set") => {
            let value = one(op, token, values)?;
            crate::config::forbid_landing(value)?; // the coincident name is refused at the front door (bl-ac89)
            landing_set(&landing, actor, token, "tasks_branch", value)
        }
        (Key::ClockProvider, "set") => {
            // A DIRECTLY-SET LOCAL value (bl-cfe3): an absolute path or a
            // PATH-resolved name, written to THIS clone's `binding.toml` — the
            // per-machine LOCAL-TRUST layer that never travels on `install` (§4).
            // No install, no `bin/<name>` symlink: the clock is box-local,
            // cosmetic, fail-open (§1/§8). The value is resolved + fail-open at
            // op-start ([`crate::clock`]), not validated here.
            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(())
}

/// The single value a scalar `set` takes.
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"))),
    }
}

/// Bind THIS checkout to a store-remote `url` (the §12 durable per-clone tier):
/// clear any declared stealth first — the landing rung outranks the binding one,
/// so leaving the sentinel would make this set change nothing the ladder resolves
/// (the bl-d234 trap, inverted) — then write `url` into the clone's `binding.toml`.
/// `bl conf set task-remote <url>` IS this write, and `bl prime --center <url>`
/// reuses it as enrollment's durable half (bl-35e5): one composition, one home,
/// so a durable bind can never drift between the two spellings.
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)
}

/// Declare stealth: write the §12 sentinel into the landing's per-checkout
/// `task_remote` policy rung, sealed like any landing config edit. `bl prime
/// --stealth` is sugar for exactly this write (bl-9df0) — the opt-out is a
/// durable config fact every later op's bind derives, never a per-invocation
/// flag.
pub(crate) fn declare_stealth(landing: &Path, actor: &str) -> io::Result<()> {
    landing_set(landing, actor, "task-remote", "task_remote", crate::config::STEALTH_REMOTE)
}

/// Drop the landing stealth sentinel (a durable URL set supersedes the declared
/// opt-out). Removing an absent key is the convergent no-op — nothing commits.
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(())
    })
}

/// Set one string `field` in THIS checkout's `binding.toml` — a plain local-state
/// file edit, every other key in it untouched. The binding is the per-machine
/// LOCAL-TRUST layer (§4/§12): the store `remote` (which center this clone tracks)
/// and the §8 `clock_provider` (this box's op-clock) both live here, never in the
/// landing config, so neither travels on `install` and neither can shadow another
/// repo the way the machine-wide XDG file once did (bl-d081/bl-cfe3). Local state:
/// never committed — it lives beside the landing/store checkouts in the clone
/// bundle.
///
/// The binding is the ONE balls-owned mutable fact outside git, so it has no CAS
/// commit point to seal against; the replace is therefore made ATOMIC by hand
/// (bl-ffbf): the new document goes to a private temp file beside the target and
/// is `rename`d over it — git's own `index.lock` discipline. A reader (every
/// op's §12 ladder resolution) sees the whole old file or the whole new one,
/// never a truncated prefix, and a crash mid-write leaves the established
/// binding standing. What remains — and is ACCEPTED — is the lost update: two
/// concurrent `bl conf set` in one clone read the same table and the later
/// rename wins whole, dropping the other's field. A lock would trade that for a
/// stale lockfile bricking every later write, the same true-forever debris the
/// founding predicate had to shed; the fix, if this ever bites, is to move the
/// binding under a ref, not to add a lock.
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"))?;
    // The pid keeps two concurrent writers in their own temp, so neither can
    // interleave bytes into the other's — the failure they share is losing a
    // field, never a corrupt document.
    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); // a half-written temp is never left behind
    }
    replaced
}

/// Set a landing `balls.toml` scalar and seal it on `balls/config` (§4).
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(())
    })
}

/// Apply one §4 list op to a `[hooks]` key on the landing `plugins.toml`:
/// `set` bare-replaces with `values`; `append`/`prepend` insert ONE name iff
/// absent (convergent); `remove` prunes it, dropping the key when emptied.
fn hooks_edit(landing: &Path, actor: &str, op: &str, key: &str, values: &[String]) -> io::Result<()> {
    if values.iter().any(String::is_empty) {
        // bl-bee0: "" is not a plugin name — it would resolve bin/ itself at
        // dispatch. Clearing already has a spelling: `set <key>` with no values.
        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)?; // compose moves exactly one name
    }
    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]),
            _ => {} // append/prepend of a present name — the convergent no-op
        }
        if names.is_empty() {
            hooks.remove(key); // absent/empty = run nothing (§4) — drop, don't store []
        } else {
            let list = names.into_iter().map(Value::String).collect();
            hooks.insert(key.to_string(), Value::Array(list));
        }
        Ok(())
    })
}

/// Read-edit-write one landing `config/<file>` TOML document, then seal it as
/// an ordinary commit on `balls/config` carrying the §5 checkout-scoped
/// trailer block (bl-1d9b). The edit touches one key; everything else in the
/// document round-trips. A no-change edit commits nothing — git's empty-diff
/// check is the §13 convergence test. `pub(crate)` so [`crate::converge`]
/// rewrites retired plugin names through the SAME raw-`toml::Table` seal (a raw
/// closure, not [`Hooks::to_toml`], which drops a team's foreign tables —
/// bl-18bf §12.1); every other key round-trips exactly as here.
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(()); // the value already held — converge, no empty commit
    }
    let message = Message::checkout(Verb::Conf, actor, subject.to_string()).render()?;
    git::run(landing, &["commit", "-q", "-F", "-"], Some(&message))?;
    Ok(())
}

/// One TOML document as a table: absent ⇒ empty (the un-configured case),
/// malformed ⇒ an error naming the file ([`crate::config::read_layer`]).
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;