yog 0.0.1

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 = hash-guard + temp-in-dir + rename (lernie
//! > declares these hand-edited; yog is the hand, minus torn writes). No
//! > validator exists and yog adds no YAML dep — the operator's risk is
//! > identical to `vi`. […] 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 **no
//! validator gate**: the only non-success Apply outcomes are a concurrent-edit
//! [`Conflict`](Saved::Conflict) and a filesystem error. A new workflow is not
//! a special case but 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::{Commit, FileIo, load_snapshot, stage};
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).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WorkflowNameError {
    /// Empty — a workflow must have a name.
    Empty,
    /// Contains a path separator — a name must name one file, not a path.
    Separator,
    /// Leads with a dot — reserved for hidden and relative names.
    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 shared hash-guard + temp-in-dir + atomic rename
/// pipeline. There is no validator — lernie declares these files hand-edited
/// and yog is the hand — so Apply's only refusal is a concurrent-edit
/// [`Conflict`](Saved::Conflict).
#[derive(Debug, Clone)]
pub struct Editor {
    path: PathBuf,
    draft: String,
    loaded: Option<u64>,
}

impl Editor {
    /// Load an existing file (or an editable-but-absent one like `models.yaml`)
    /// into a draft. A missing file is empty text with an absent snapshot, so
    /// [`is_new`](Self::is_new) reports it and Apply overwrites an existing file
    /// but refuses one that changed underneath.
    pub fn load(path: PathBuf, io: &dyn FileIo) -> std::io::Result<Self> {
        let (draft, loaded) = load_snapshot(io, &path)?;
        Ok(Self {
            path,
            draft,
            loaded,
        })
    }

    /// 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"). A pure constructor: the load-time snapshot is forced absent,
    /// so Apply's guard becomes a must-not-exist guard — it refuses if any file
    /// is already at `path`. Seed with `b""` for an empty new file.
    pub fn seeded(path: PathBuf, seed: &[u8]) -> Self {
        Self {
            path,
            draft: String::from_utf8_lossy(seed).into_owned(),
            loaded: None,
        }
    }

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

    /// 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.
    pub fn set_draft(&mut self, text: String) {
        self.draft = text;
    }

    /// Whether the file was absent at load — a "new file" being authored,
    /// distinct from an existing one (§9.2). Flips to `false` once Apply
    /// creates it.
    pub fn is_new(&self) -> bool {
        self.loaded.is_none()
    }

    /// 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.path)?;
        self.draft = draft;
        self.loaded = loaded;
        Ok(())
    }

    /// Apply the draft through the shared pipeline. A concurrent change (or an
    /// already-present file when creating) ⇒ [`Saved::Conflict`]; any fs error
    /// ⇒ [`Saved::Io`].
    pub fn apply(&mut self, io: &dyn FileIo) -> Saved {
        match self.apply_inner(io) {
            Ok(saved) => saved,
            Err(e) => Saved::Io {
                error: e.to_string(),
            },
        }
    }

    fn apply_inner(&mut self, io: &dyn FileIo) -> std::io::Result<Saved> {
        let staged = stage(io, &self.path, self.draft.as_bytes())?;
        match staged.commit(io, self.loaded)? {
            Commit::Ok(hash) => {
                self.loaded = Some(hash);
                Ok(Saved::Ok)
            }
            Commit::Conflict => Ok(Saved::Conflict),
        }
    }
}

/// The terminal state of an [`Editor::apply`] (§9.2). No validator ⇒ no
/// rejection; the only non-success outcomes are a 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 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;