yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! Config mode (§11 "Config mode … swaps the center to the brazen /
//! lernie-global / config-branch editors", §9): the three write surfaces.
//!
//! Coverage-excluded glue: the editors themselves ([`BrazenEditor`],
//! [`Editor`], the config-branch [`edit`]) are pure tested view-models over the
//! injected [`RealFileIo`] / [`RealBzRunner`] seams; this file wires their
//! drafts to text boxes and their Apply / staged-drive to buttons. `ConfigState`
//! is RAM (§3.5), folded once from the env snapshot and discarded on exit.
//!
//! [`edit`]: crate::config_edit::branch::edit

use crate::AppModel;
use crate::cli_outbound::Cli;
use crate::config_edit::RealFileIo;
use crate::config_edit::branch::config_branches;
use crate::config_edit::branch::edit::{
    DraftFile, EditOrigin, EditPlan, drive, next_nonce, stage_files,
};
use crate::config_edit::brazen::{
    Applied, BUILT_IN_ROWS_HINT, BrazenEditor, BrazenPaths, RealBzRunner,
};
use crate::config_edit::lernie_global::{Editor, LernieGlobal, Saved, WorkflowNameError};
use crate::shell::config_marks::{self, MarksPane};
use crate::theme;
use crate::xdg::{Env, current_os};
use std::path::PathBuf;

/// The config-editors' RAM state (§9): the three editors plus the injected
/// seams and folded paths they need. `active` is the roster Config toggle.
pub struct ConfigState {
    pub active: bool,
    io: RealFileIo,
    bz: RealBzRunner,
    yog_binary: PathBuf,
    stage_root: PathBuf,
    brazen: BrazenEditor,
    brazen_status: String,
    brazen_effective: String,
    lernie: LernieGlobal,
    lernie_editor: Editor,
    lernie_status: String,
    new_workflow: String,
    cb_name: String,
    cb_origin: EditOrigin,
    cb_path: String,
    cb_body: String,
    cb_status: String,
    /// The per-project no-marks knob (§16.3), its own pane in [`config_marks`].
    ///
    /// [`config_marks`]: super::config_marks
    marks: MarksPane,
}

impl ConfigState {
    /// Fold the editors from the env snapshot (§9). A missing brazen/lernie
    /// file loads as an empty draft (§9.1/§9.2), never an error.
    pub fn new(env: &Env) -> std::io::Result<Self> {
        let io = RealFileIo;
        let brazen = BrazenEditor::load(BrazenPaths::resolve(env, current_os()), &io)?;
        let lernie = LernieGlobal::resolve(env);
        let lernie_editor = Editor::load(lernie.models(), &io)?;
        Ok(Self {
            active: false,
            io,
            bz: RealBzRunner::resolve(env),
            yog_binary: std::env::current_exe().unwrap_or_default(),
            stage_root: env.yog_stage_root(),
            brazen,
            brazen_status: String::new(),
            brazen_effective: String::new(),
            lernie,
            lernie_editor,
            lernie_status: String::new(),
            new_workflow: String::new(),
            cb_name: String::new(),
            cb_origin: EditOrigin::Advance,
            cb_path: String::new(),
            cb_body: String::new(),
            cb_status: String::new(),
            marks: MarksPane::resolve(env),
        })
    }
}

/// Render config mode into the center panel: the three editors, scrollable.
pub fn center(ui: &mut egui::Ui, model: &mut AppModel, config: &mut ConfigState, lernie: &Cli) {
    ui.heading("Config editors");
    ui.weak("§9 — stage → validate → hash-guard → atomic rename");
    egui::ScrollArea::vertical().show(ui, |ui| {
        brazen_editor(ui, config);
        ui.separator();
        lernie_global(ui, config);
        ui.separator();
        config_marks::render(ui, model, &mut config.marks);
        ui.separator();
        config_branch(ui, model, config, lernie);
    });
}

/// brazen `config.toml` (§9.1): raw TOML draft, `bz`-gated Apply, effective dump.
fn brazen_editor(ui: &mut egui::Ui, config: &mut ConfigState) {
    ui.heading(egui::RichText::new("brazen config.toml").color(theme::integration_hue("bz")));
    ui.add(
        egui::TextEdit::multiline(config.brazen.draft_mut())
            .desired_rows(6)
            .code_editor(),
    );
    ui.horizontal(|ui| {
        if ui.button("Apply").clicked() {
            config.brazen_status = describe_applied(config.brazen.apply(&config.bz, &config.io));
        }
        if ui.button("Reload").clicked() {
            config.brazen_status = reload_status(config.brazen.reload(&config.io));
        }
        if ui.button("Effective (bz --dump-config)").clicked() {
            config.brazen_effective = config.brazen.effective(&config.bz).stdout;
        }
    });
    status_line(ui, &config.brazen_status);
    if !config.brazen_effective.is_empty() {
        ui.monospace(&config.brazen_effective);
    }
    ui.weak(BUILT_IN_ROWS_HINT);
}

/// lernie global config (§9.2): `models.yaml` + `workflows/*.yaml`, no validator.
fn lernie_global(ui: &mut egui::Ui, config: &mut ConfigState) {
    ui.heading(egui::RichText::new("lernie global config").color(theme::integration_hue("lernie")));
    ui.monospace(config.lernie_editor.path().display().to_string());
    if config.lernie_editor.is_new() {
        ui.weak("(new file)");
    }
    ui.add(
        egui::TextEdit::multiline(config.lernie_editor.draft_mut())
            .desired_rows(6)
            .code_editor(),
    );
    ui.horizontal(|ui| {
        if ui.button("Apply").clicked() {
            config.lernie_status = describe_saved(config.lernie_editor.apply(&config.io));
        }
        if ui.button("Reload").clicked() {
            config.lernie_status = reload_status(config.lernie_editor.reload(&config.io));
        }
        if ui.button("models.yaml").clicked() {
            open_lernie(config, config.lernie.models());
        }
    });
    workflow_list(ui, config);
    ui.horizontal(|ui| {
        ui.label("new workflow:");
        ui.text_edit_singleline(&mut config.new_workflow);
        if ui.button("Create").clicked() {
            new_workflow(config);
        }
    });
    status_line(ui, &config.lernie_status);
}

/// The existing `workflows/*.yaml` as open buttons.
fn workflow_list(ui: &mut egui::Ui, config: &mut ConfigState) {
    let Ok(files) = config.lernie.workflows(&config.io) else {
        return;
    };
    ui.horizontal_wrapped(|ui| {
        for file in files {
            let label = file.file_name().map(|n| n.to_string_lossy().into_owned());
            if ui.button(label.unwrap_or_default()).clicked() {
                open_lernie(config, file);
            }
        }
    });
}

/// Seed a new-workflow editor from the drafted name (§9.2), or surface why the
/// name is unsafe.
fn new_workflow(config: &mut ConfigState) {
    match config.lernie.new_workflow(&config.new_workflow) {
        Ok(path) => {
            config.lernie_editor = Editor::seeded(path, b"");
            config.lernie_status = "new workflow ready".to_string();
        }
        Err(e) => config.lernie_status = describe_name_err(e),
    }
}

/// Open a lernie-global file into the editor (its Conflict recovery is Reload).
fn open_lernie(config: &mut ConfigState, path: PathBuf) {
    match Editor::load(path.clone(), &config.io) {
        Ok(editor) => config.lernie_editor = editor,
        Err(_) => config.lernie_editor = Editor::seeded(path, b""),
    }
}

/// Per-workspace config branches (§9.3): the branch listing + the staged edit
/// that drives `lernie config` (the only lawful `config/*` writer).
fn config_branch(ui: &mut egui::Ui, model: &mut AppModel, config: &mut ConfigState, lernie: &Cli) {
    ui.heading("workspace config branches");
    let Some(ws) = model.focused_workspace().map(PathBuf::from) else {
        ui.weak("focus a workspace to edit its config branches");
        return;
    };
    match config_branches(&ws) {
        Ok(branches) => {
            for b in &branches {
                ui.monospace(format!("config/{} @ {}", b.name, b.tip_short_oid));
            }
        }
        Err(e) => {
            ui.weak(format!("branches: {e}"));
        }
    }
    ui.weak("staged edit → lernie config");
    ui.horizontal(|ui| {
        ui.label("name:");
        ui.text_edit_singleline(&mut config.cb_name);
        ui.selectable_value(&mut config.cb_origin, EditOrigin::Advance, "advance");
        ui.selectable_value(&mut config.cb_origin, EditOrigin::Orphan, "orphan");
    });
    ui.horizontal(|ui| {
        ui.label("file:");
        ui.text_edit_singleline(&mut config.cb_path);
    });
    ui.add(
        egui::TextEdit::multiline(&mut config.cb_body)
            .desired_rows(4)
            .code_editor(),
    );
    if ui.button("Send (stage + lernie config)").clicked() {
        config.cb_status = send_edit(config, &ws, lernie, model.state_root());
    }
    status_line(ui, &config.cb_status);
}

/// Stage the drafted file and drive `lernie config <ws> <name> [flags]` (§9.3).
fn send_edit(
    config: &ConfigState,
    ws: &std::path::Path,
    lernie: &Cli,
    state_root: &std::path::Path,
) -> String {
    let files = vec![DraftFile {
        rel_path: config.cb_path.clone(),
        bytes: config.cb_body.clone().into_bytes(),
    }];
    match stage_files(&config.stage_root, &next_nonce(), &files) {
        Ok(dir) => {
            let plan = EditPlan::compose(
                &config.yog_binary,
                ws,
                &config.cb_name,
                &config.cb_origin,
                &dir,
            );
            let entry = drive(lernie, ws, &plan, &super::now_ts(), state_root);
            format!("lernie config exit {}", entry.exit)
        }
        Err(e) => e.to_string(),
    }
}

fn status_line(ui: &mut egui::Ui, status: &str) {
    if !status.is_empty() {
        ui.weak(status);
    }
}

fn reload_status(result: std::io::Result<()>) -> String {
    match result {
        Ok(()) => "reloaded".to_string(),
        Err(e) => e.to_string(),
    }
}

fn describe_applied(applied: Applied) -> String {
    match applied {
        Applied::Ok => "applied".to_string(),
        Applied::Rejected { stderr } => format!("rejected: {stderr}"),
        Applied::Conflict => "conflict — reload to re-diff".to_string(),
        Applied::Io { error } => format!("io error: {error}"),
    }
}

fn describe_saved(saved: Saved) -> String {
    match saved {
        Saved::Ok => "saved".to_string(),
        Saved::Conflict => "conflict — reload to re-diff".to_string(),
        Saved::Io { error } => format!("io error: {error}"),
    }
}

fn describe_name_err(err: WorkflowNameError) -> String {
    match err {
        WorkflowNameError::Empty => "name required".to_string(),
        WorkflowNameError::Separator => "name must be a single file, no path".to_string(),
        WorkflowNameError::DotLeading => "name must not start with a dot".to_string(),
    }
}