yog 0.0.51

yog: the standalone server for litany loops — the world, the balls and the conversations, behind one wire
Documentation
//! brazen `config.toml` editor — a pure view-model (DESIGN §9.1, §5.1
//! rows 19–23).
//!
//! The editor is **raw TOML text**, never form fields: brazen's schema is
//! versionless and full of open valves, so `bz` is the only lawful parser
//! (§9.1). yog therefore adds no TOML dependency — it edits bytes and lets
//! `bz` validate them. Since §16.7 W10 that `bz` is *linked*, so "the only
//! lawful parser" is enforced by the linker rather than by a version gate: the
//! validator and the loops' adapter are one exact-pinned implementation.
//!
//! The file edited is the **focused workspace's own** — since the
//! blast-radius ruling (§16.2) brazen's config lives inside that workspace's
//! wall ([`crate::world::wall`]), so switching workspace switches providers,
//! sign-ins and model cache together. An unfocused surface has no wall and so
//! no paths ([`BrazenPaths::of`] answers `None`); it renders a guard rather
//! than falling back to the machine's own brazen state.
//!
//! The view-model is pure over two injected effects, exactly the
//! [`LockProbe`](crate::git_tree) shape: a [`BzRunner`] (the sole `bz`
//! command surface — validate / effective-dump / provider table / list-models)
//! and the shared [`FileIo`](super::FileIo) seam. A fake pair drives every
//! state transition under Linux tarpaulin; the real [`RealBzRunner`] is a thin
//! shell over [`crate::bz_host`] plus one recorder-covered spawn.
//!
//! The three on-disk locations themselves — and the read that needs nothing but
//! them ([`model_cache_at`]) — are [`paths`], split off at §12's pre-split band:
//! a layout is not an editor, and that read was already documented as free of
//! one.
//!
//! Apply is the shared [`pipeline`](super::pipeline) (stage → hash-guard →
//! atomic rename) with brazen's one addition, the `bz` validator gate:
//! ```text
//!   draft ──stage──▶ .config.toml.yog-tmp-<pid>  (temp in the dest dir)
//!         ──gate──▶  bz --config <temp> --dump-config
//!            non-zero exit ─▶ Rejected{stderr}   (draft kept, temp discarded)
//!         ──commit──▶ hash-guard + atomic rename
//!            snapshot moved ─▶ Conflict          (offer reload, temp removed)
//!            else ─▶ Ok                          (loaded snapshot updated)
//!       any fs error at any step ─▶ Io{error}
//! ```
//! `BRAZEN_CONFIG` is never leaked into the child env: the gate passes
//! `--config <temp>` explicitly, which overrides `bz`'s default search path
//! (`bz --help`: "--config <file> … else the default search path").

use super::{Draft, FileIo};
use std::path::Path;

mod effects;
pub use effects::RealBzRunner;

/// The wall's brazen layout and the read that needs only it (§5.1 #19/#23) —
/// free of the editor, because every caller asks without one.
mod paths;
pub use paths::{BrazenPaths, model_cache_at};

mod providers;
pub use providers::{
    MISSING, NOT_REQUIRED, ProviderRow, ProviderRowView, dialect_decline, provider_rows, row_names,
    row_views,
};

#[cfg(test)]
mod tests;

/// The captured result of one `bz` invocation. `success` is exit code 0.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BzOutcome {
    pub success: bool,
    pub stdout: String,
    pub stderr: String,
}

/// yog's entire `bz` command surface. Injected so the view-model is driven
/// by a fake in tests; [`RealBzRunner`] answers the three read verbs through
/// the **linked** brazen (§16.7 W10, [`crate::bz_host`]) and keeps a spawn only
/// for the one verb that goes to the network.
pub trait BzRunner {
    /// `bz --config <config> --dump-config` — the Apply validation gate.
    fn dump_config_at(&self, config: &Path) -> BzOutcome;
    /// `bz --dump-config` against the real file/env — the effective view.
    fn dump_config_effective(&self) -> BzOutcome;
    /// The effective provider table, in routing order (`bz --list-providers`,
    /// §5.1 #20/#21): the login rows and the credential-presence rows both read
    /// this one answer, and its `auth` column is the login-capability fact
    /// (§8.3) — see [`ProviderRow`].
    fn providers(&self) -> Vec<ProviderRow>;
    /// `bz --list-models --provider <provider> --json` — cache refresh.
    fn list_models(&self, provider: &str) -> BzOutcome;
}

/// The terminal state of an Apply (§9.1).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Applied {
    /// Validated, hash-guard passed, renamed into place; loaded hash updated.
    Ok,
    /// `bz` rejected the draft; its stderr is surfaced verbatim, the draft
    /// is kept in RAM, the temp deleted. A malformed config never lands.
    Rejected { stderr: String },
    /// The on-disk file changed since load (hash mismatch): refuse rather
    /// than blind-LWW a concurrent edit. The temp is deleted; reload to
    /// re-diff ([`BrazenEditor::reload`]).
    Conflict,
    /// A filesystem error at any pipeline step.
    Io { error: String },
}

/// The static hint that provider rows are compiled into `bz` and never appear
/// in the file or the dump (§5.1 row 21). Rendered beside the §9.1 editor.
/// Deliberately count-free: the number is brazen's, and pinning it here would
/// be a second representation of a fact the crate already owns — the login
/// surface's [`BzRunner::providers`] listing is where they are actually named.
///
/// It used to end *"(the Login provider list shows them)"*. Since bl-20cb the
/// §9.1 pane states outright that the Login tab is where the rows are named, so
/// the parenthetical was that same routing said twice on one surface
/// (QUALITY H1) — this line is now only the fact the *file* is short of.
pub const BUILT_IN_ROWS_HINT: &str =
    "built-in provider rows are compiled into bz and are not shown in this file";

/// The brazen config editor view-model. Holds only the RAM carve-out (the
/// unsent draft, §5.3) and the loaded-content hash for the concurrent-edit
/// guard; every other datum is derived through an injected effect on demand.
#[derive(Debug, Clone)]
pub struct BrazenEditor {
    paths: BrazenPaths,
    draft: Draft,
}

impl BrazenEditor {
    /// Load the file at the folded path into the draft buffer. A missing
    /// file is empty text (fold identity, not an error, §9.1); the load-time
    /// snapshot is `None`, so the Apply guard still refuses if a config
    /// appears underneath the editor. See [`Draft::load`].
    pub fn load(paths: BrazenPaths, io: &dyn FileIo) -> std::io::Result<Self> {
        let draft = Draft::load(paths.config.clone(), io)?;
        Ok(Self { paths, draft })
    }

    /// The RAM draft (§5.3 carve-out). Test-only reader; the shell binds the
    /// mutable buffer through `draft_mut`.
    #[cfg(test)]
    pub(crate) fn draft(&self) -> &str {
        self.draft.text()
    }

    /// Replace the draft text wholesale (e.g. a template paste).
    pub fn set_draft(&mut self, text: String) {
        self.draft.set(text);
    }

    /// Re-read the file into the draft and re-snapshot — the Conflict recovery
    /// ("offer reload") and a plain refresh.
    pub fn reload(&mut self, io: &dyn FileIo) -> std::io::Result<()> {
        self.draft.reload(io)
    }

    /// Follow the file when nothing has been typed into the draft (§9
    /// read-on-demand freshness). The config roots carry no watcher (§7.1,
    /// bl-9130), so the operator's own attention gesture — opening the Config
    /// pane — is the re-read trigger: without it a `config.toml` edited in `vi`
    /// stays invisible to this pane for the whole process lifetime. Reports
    /// whether it re-read.
    pub fn refresh(&mut self, io: &dyn FileIo) -> std::io::Result<bool> {
        self.draft.refresh(io)
    }

    /// Run the §9.1 Apply pipeline. Any filesystem error becomes
    /// [`Applied::Io`], naming the file it could not write (bl-8c06); the
    /// logical outcomes are `Ok`, `Rejected`, `Conflict`.
    pub fn apply(&mut self, runner: &dyn BzRunner, io: &dyn FileIo) -> Applied {
        match self.apply_inner(runner, io) {
            Ok(applied) => applied,
            Err(e) => Applied::Io {
                error: self.draft.io_fault(&e),
            },
        }
    }

    fn apply_inner(&mut self, runner: &dyn BzRunner, io: &dyn FileIo) -> std::io::Result<Applied> {
        let staged = self.draft.stage(io)?;
        let gate = runner.dump_config_at(staged.temp());
        if !gate.success {
            staged.discard(io)?;
            return Ok(Applied::Rejected {
                stderr: gate.stderr,
            });
        }
        if self.draft.commit(staged, io)? {
            Ok(Applied::Ok)
        } else {
            Ok(Applied::Conflict)
        }
    }

    /// The read-only effective config: `bz --dump-config` against the real
    /// file/env, rendered verbatim (§5.1 row 20). Pair with
    /// [`BUILT_IN_ROWS_HINT`].
    pub fn effective(&self, runner: &dyn BzRunner) -> BzOutcome {
        runner.dump_config_effective()
    }

    /// The model cache for a provider (§5.1 row 23) — [`model_cache_at`] at
    /// this editor's own wall.
    pub fn model_cache(&self, provider: &str, io: &dyn FileIo) -> std::io::Result<Option<String>> {
        model_cache_at(&self.paths.models_cache_dir, provider, io)
    }

    /// Refresh a provider's model cache: `bz --list-models` writes the cache
    /// on disk itself; the caller re-reads via [`model_cache`](Self::model_cache).
    /// The outcome is returned so a failure surfaces verbatim.
    pub fn refresh_models(&self, provider: &str, runner: &dyn BzRunner) -> BzOutcome {
        runner.list_models(provider)
    }
}