yog 0.0.2

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! lernie global config editor — `models.yaml` and `workflows/*.yaml` (§9.2).
//!
//! > Text editor per file; Apply = **provider gate** → hash-guard +
//! > temp-in-dir + rename (lernie declares these hand-edited; yog is the hand,
//! > minus torn writes). yog still adds no YAML dep […] New workflow = same
//! > path, new name; templates copyable. (DESIGN §9.2)
//!
//! So this surface reuses the shared [`pipeline`](super::pipeline) verbatim —
//! the same stage → hash-guard → atomic rename brazen uses — with **one**
//! validator, the narrowest one the file's own contract implies: `provider:`
//! on a model is a brazen provider-row NAME, and brazen publishes the row set
//! ([`BzRunner::providers`](crate::config_edit::brazen::BzRunner::providers)),
//! so an entry naming a row brazen does not have is refused
//! ([`Saved::Rejected`]) exactly as §9.1 refuses a `bz`-rejected TOML draft.
//! Nothing else about the YAML is judged — the operator's remaining risk is
//! still `vi`'s.
//!
//! Two things are deliberately *not* special cases. The gate runs over every
//! §9.2 file, because a `workflows/*.yaml` declares no `models:` block and so
//! is always clean ([`unknown_rows`]) — no branch on which file is open. And a
//! new workflow is the general path with an absent load-time snapshot: the
//! guard that refuses a changed file *is* the must-not-exist guard when the
//! file was never there.

use super::{Draft, FileIo};
use crate::model_pick::grammar::{DeclaredModel, unknown_rows};
use crate::xdg::Env;
use std::path::{Path, PathBuf};

/// The lernie-global editable surface, rooted at one config root (§9.2). The
/// root is the Y2 fold ([`lernie_config_root`](Env::lernie_config_root),
/// honoring `LERNIE_HOME`); a missing root or empty `workflows/` simply yields
/// no workflows, never an error — absence is a value.
#[derive(Debug, Clone)]
pub struct LernieGlobal {
    root: PathBuf,
}

impl LernieGlobal {
    /// Fold the config root from an [`Env`] snapshot (§15 Y2).
    pub fn resolve(env: &Env) -> Self {
        Self {
            root: env.lernie_config_root(),
        }
    }

    /// The single `models.yaml` path. Always offered: a missing file is a
    /// new-file edit (its editor reports [`is_new`](Editor::is_new)), not an
    /// error.
    pub fn models(&self) -> PathBuf {
        self.root.join("models.yaml")
    }

    /// The `workflows/` directory that holds the `*.yaml` workflow files.
    pub fn workflows_dir(&self) -> PathBuf {
        self.root.join("workflows")
    }

    /// Every existing `workflows/*.yaml`, sorted by path. Empty when the
    /// directory is absent or holds no `.yaml` file — the missing-root and
    /// empty-dir cases, folded to the same value, never an error.
    pub fn workflows(&self, io: &dyn FileIo) -> std::io::Result<Vec<PathBuf>> {
        let mut files: Vec<PathBuf> = io
            .list_dir(&self.workflows_dir())?
            .into_iter()
            .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("yaml"))
            .collect();
        files.sort();
        Ok(files)
    }

    /// The path a new workflow named `name` would occupy, once `name` is a
    /// safe single-file basename (§9.2 "new workflow = new file name"). The
    /// must-not-exist guard is the Apply pipeline's own snapshot guard applied
    /// to the [`Editor::seeded`] absent snapshot, not a check here.
    pub fn new_workflow(&self, name: &str) -> Result<PathBuf, WorkflowNameError> {
        validate_workflow_name(name)?;
        Ok(self.workflows_dir().join(format!("{name}.yaml")))
    }
}

/// Why a proposed workflow name is not a safe single-file basename (§9.2). The
/// sentence rides on the error, so the §11 pane and the §8.5 headless spelling
/// refuse in the same words rather than each phrasing the same three facts.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum WorkflowNameError {
    /// Empty — a workflow must have a name.
    #[error("name required")]
    Empty,
    /// Contains a path separator — a name must name one file, not a path.
    #[error("name must be a single file, no path")]
    Separator,
    /// Leads with a dot — reserved for hidden and relative names.
    #[error("name must not start with a dot")]
    DotLeading,
}

fn validate_workflow_name(name: &str) -> Result<(), WorkflowNameError> {
    if name.is_empty() {
        return Err(WorkflowNameError::Empty);
    }
    if name.starts_with('.') {
        return Err(WorkflowNameError::DotLeading);
    }
    if name.contains('/') || name.contains('\\') {
        return Err(WorkflowNameError::Separator);
    }
    Ok(())
}

/// A raw-text editor over one lernie-global file (§9.2): load into a RAM draft,
/// edit, Apply through the provider gate and then the shared hash-guard +
/// temp-in-dir + atomic rename pipeline. Apply refuses on exactly two grounds
/// — a model declared on a provider row brazen does not have
/// ([`Rejected`](Saved::Rejected)) and a concurrent edit
/// ([`Conflict`](Saved::Conflict)).
#[derive(Debug, Clone)]
pub struct Editor {
    draft: Draft,
}

impl Editor {
    /// Load an existing file (or an editable-but-absent one like `models.yaml`)
    /// into a draft. See [`Draft::load`].
    pub fn load(path: PathBuf, io: &dyn FileIo) -> std::io::Result<Self> {
        Ok(Self {
            draft: Draft::load(path, io)?,
        })
    }

    /// Author a brand-new file at `path`, its draft seeded from `seed` bytes —
    /// the new-workflow and copy-from-existing affordances (§9.2 "templates
    /// copyable"). See [`Draft::seeded`]: the guard becomes must-not-exist.
    pub fn seeded(path: PathBuf, seed: &[u8]) -> Self {
        Self {
            draft: Draft::seeded(path, seed),
        }
    }

    /// The file this editor targets.
    pub(crate) fn path(&self) -> &Path {
        self.draft.path()
    }

    /// The RAM draft (§5.3 carve-out) — the text the §9.5 pane derives its
    /// typed rows from and writes back through, and the raw escape's read.
    pub(crate) fn draft(&self) -> &str {
        self.draft.text()
    }

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

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

    /// Whether the file was absent at load — a "new file" being authored,
    /// distinct from an existing one (§9.2).
    pub fn is_new(&self) -> bool {
        self.draft.is_new()
    }

    /// 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 same rule as §9.1's editor, from the
    /// same predicate. Reports whether it re-read.
    pub fn refresh(&mut self, io: &dyn FileIo) -> std::io::Result<bool> {
        self.draft.refresh(io)
    }

    /// Apply the draft: provider gate first, then the shared pipeline. A model
    /// on a row missing from `providers` ⇒ [`Saved::Rejected`]; a concurrent
    /// change (or an already-present file when creating) ⇒
    /// [`Saved::Conflict`]; any fs error ⇒ [`Saved::Io`].
    ///
    /// `providers` is brazen's effective row set
    /// ([`BzRunner::providers`](crate::config_edit::brazen::BzRunner::providers));
    /// an empty slice is *no answer* and gates nothing ([`unknown_rows`]).
    pub fn apply(&mut self, providers: &[String], io: &dyn FileIo) -> Saved {
        match self.apply_inner(providers, io) {
            Ok(saved) => saved,
            Err(e) => Saved::Io {
                error: e.to_string(),
            },
        }
    }

    fn apply_inner(&mut self, providers: &[String], io: &dyn FileIo) -> std::io::Result<Saved> {
        let unknown = unknown_rows(self.draft.text(), providers);
        if !unknown.is_empty() {
            return Ok(Saved::Rejected { unknown });
        }
        let staged = self.draft.stage(io)?;
        if self.draft.commit(staged, io)? {
            Ok(Saved::Ok)
        } else {
            Ok(Saved::Conflict)
        }
    }
}

/// The terminal state of an [`Editor::apply`] (§9.2): the provider rejection,
/// the concurrent-edit conflict (which is also the new-file must-not-exist
/// refusal), and a filesystem error.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Saved {
    /// Guard passed, renamed into place; the loaded snapshot is updated.
    Ok,
    /// The draft declares models on brazen provider rows that do not exist:
    /// refuse, name every offending entry, keep the draft in RAM and write
    /// nothing — the §9.1 posture, minus a temp file, since the judgement is
    /// pure over the draft text and needs no file to hand `bz`.
    Rejected { unknown: Vec<DeclaredModel> },
    /// The on-disk file changed since load (or already exists when creating):
    /// refuse rather than blind-LWW. Reload to re-diff ([`Editor::reload`]).
    Conflict,
    /// A filesystem error at any pipeline step.
    Io { error: String },
}

#[cfg(test)]
mod tests;