yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
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.
//!
//! The file edited is the **ambient** config — `BRAZEN_CONFIG` is not among
//! the world overrides (§16.2), so [`Env::brazen_config_path`] through the
//! composed world `Env` resolves to the same file the user's own `bz` reads.
//! That is the intent: one host `bz`, one config.
//!
//! 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 / 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, covered by
//! recorder scripts the same way `cli_outbound` and `lock_probe` are.
//!
//! 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::{Commit, FileIo, load_snapshot, stage};
use crate::xdg::{Env, Os};
use std::path::{Path, PathBuf};

mod effects;
pub use effects::RealBzRunner;

#[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`] wraps [`Cli`](crate::cli_outbound).
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;
    /// `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 six provider rows are compiled into `bz` and never
/// appear in the file (§5.1 row 21). Rendered beside the effective pane.
pub const BUILT_IN_ROWS_HINT: &str =
    "six built-in provider rows are compiled into bz and are not shown in this file";

/// brazen's three read locations, folded once from an [`Env`] (§5.1 rows
/// 19/22/23). Credentials and cache are per-OS; the config path is pure XDG.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BrazenPaths {
    pub config: PathBuf,
    pub credentials_dir: PathBuf,
    pub models_cache_dir: PathBuf,
}

impl BrazenPaths {
    pub fn resolve(env: &Env, os: Os) -> Self {
        Self {
            config: env.brazen_config_path(),
            credentials_dir: env.brazen_credentials_dir(os),
            models_cache_dir: env.brazen_models_cache_dir(os),
        }
    }
}

/// 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: String,
    loaded: Option<u64>,
}

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.
    pub fn load(paths: BrazenPaths, io: &dyn FileIo) -> std::io::Result<Self> {
        let (draft, loaded) = load_snapshot(io, &paths.config)?;
        Ok(Self {
            paths,
            draft,
            loaded,
        })
    }

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

    /// The draft as a mutable buffer — the binding an egui `TextEdit` edits.
    pub(crate) fn draft_mut(&mut self) -> &mut String {
        &mut self.draft
    }

    /// Replace the draft text wholesale (e.g. a template paste).
    pub fn set_draft(&mut self, text: String) {
        self.draft = 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<()> {
        let (draft, loaded) = load_snapshot(io, &self.paths.config)?;
        self.draft = draft;
        self.loaded = loaded;
        Ok(())
    }

    /// Run the §9.1 Apply pipeline. Any filesystem error becomes
    /// [`Applied::Io`]; 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: e.to_string(),
            },
        }
    }

    fn apply_inner(&mut self, runner: &dyn BzRunner, io: &dyn FileIo) -> std::io::Result<Applied> {
        let staged = stage(io, &self.paths.config, self.draft.as_bytes())?;
        let gate = runner.dump_config_at(staged.temp());
        if !gate.success {
            staged.discard(io)?;
            return Ok(Applied::Rejected {
                stderr: gate.stderr,
            });
        }
        match staged.commit(io, self.loaded)? {
            Commit::Ok(hash) => {
                self.loaded = Some(hash);
                Ok(Applied::Ok)
            }
            Commit::Conflict => 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()
    }

    /// Credential presence — booleans only (§5.1 row 22). For each provider
    /// named in the draft, does `<creds-dir>/<provider>.json` exist? Contents
    /// are never read.
    pub fn credential_presence(&self, io: &dyn FileIo) -> Vec<(String, bool)> {
        provider_names(&self.draft)
            .into_iter()
            .map(|name| {
                let path = self.paths.credentials_dir.join(format!("{name}.json"));
                let present = io.exists(&path);
                (name, present)
            })
            .collect()
    }

    /// The model cache for a provider (§5.1 row 23): the raw text of
    /// `<cache-dir>/models/<provider>.json`, or `None` if absent. Read-only
    /// and forgiving — no parse, no schema coupling.
    pub fn model_cache(&self, provider: &str, io: &dyn FileIo) -> std::io::Result<Option<String>> {
        let path = self.paths.models_cache_dir.join(format!("{provider}.json"));
        Ok(io
            .read(&path)?
            .map(|b| String::from_utf8_lossy(&b).into_owned()))
    }

    /// 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)
    }
}

/// Provider names in `text` — a documented, deliberately cheap scan for
/// `name = "..."` lines (§9.1: no TOML dep; `bz` is the only parser).
/// Order-preserving and de-duplicated. `pub(crate)` so the login flow derives its
/// provider rows from `bz --dump-config` through the **same** scan (§5.1 #20;
/// single source — the login rows and the editor's credential-presence rows can
/// never drift), see [`crate::login::provider_rows`].
pub(crate) fn provider_names(text: &str) -> Vec<String> {
    let mut out: Vec<String> = Vec::new();
    for line in text.lines() {
        let trimmed = line.trim();
        if let Some(rest) = trimmed.strip_prefix("name")
            && let Some(value) = rest.trim_start().strip_prefix('=')
            && let Some(name) = quoted_value(value.trim())
            && !out.contains(&name)
        {
            out.push(name);
        }
    }
    out
}

/// The inner text of a leading `"..."` string, or `None` when the value is
/// not a simple double-quoted literal.
fn quoted_value(s: &str) -> Option<String> {
    let inner = s.strip_prefix('"')?;
    let end = inner.find('"')?;
    // `end` is the byte offset of a `"` in `inner`, always a valid boundary.
    inner.get(..end).map(str::to_string)
}