yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! The login flow (DESIGN §8.3 as amended, §15 M6 Z8): bz's one interactive
//! surface, run as the **streamed-piped** spawn class (§8's third class).
//! `bz --login --provider <row>` streams its device-code / URL lines live to the
//! invoking surface — the toolchain pane, and beside an auth-failed step —
//! verbatim (§5.3 instance-local RAM); on exit ONE outcome row lands in
//! `ops.jsonl` (§4.2, the stream never logged line-by-line), and a non-zero exit
//! carries the exact command as a run-by-hand fallback (§8.3). Credentials stay
//! bz's: yog renders the flow, never reads or writes a credential (§5.1 #22).
//!
//! [`provider_rows`] derives the selectable providers from `bz --dump-config`
//! (§5.1 #20; bz is the authority on the value fold, with #21's six built-ins
//! compiled in). [`LoginRun`] wraps the streamed child + the pure [`LoginView`]
//! the shell paints; [`auth`] classifies an auth-shaped step failure so the Login
//! affordance surfaces one click away.

use std::path::{Path, PathBuf};

use crate::cli_outbound::{Cli, Streamed, StreamedOutcome, StreamedPoll};
use crate::opslog::{self, OpEntry};

pub mod auth;

#[cfg(test)]
mod tests;

/// bz's login subcommand flag and its provider selector (§8.2).
const LOGIN_FLAG: &str = "--login";
const PROVIDER_FLAG: &str = "--provider";

/// Derive the selectable provider rows from `bz --dump-config` stdout (§5.1 #20):
/// the same order-preserving, de-duplicated `name = "..."` scan the brazen editor
/// uses (no TOML dep — bz is the only lawful parser, §9.1). bz's six compiled-in
/// rows (#21) surface through the same effective dump, so one derivation covers
/// both — and the login rows can never drift from the editor's provider view.
pub fn provider_rows(dump_config_stdout: &str) -> Vec<String> {
    crate::config_edit::brazen::provider_names(dump_config_stdout)
}

/// The pure view-model the shell paints for a login run (§8.3). All three fields
/// are derived facts of the streamed child; the shell holds a [`LoginRun`] as its
/// §5.3 instance-local RAM and reads this each frame.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LoginView {
    /// The device-code / URL lines bz printed, **verbatim** and in order (§8.3),
    /// rendered live as they arrive.
    pub lines: Vec<String>,
    /// The terminal exit code once the run settles (`None` while streaming) — the
    /// outcome the shell paints and the S0-T5 story asserts.
    pub outcome: Option<i32>,
    /// The exact command to run by hand, set **only** on a non-zero exit (§8.3:
    /// "Showing the exact command stays as the fallback when the piped flow exits
    /// non-zero").
    pub fallback: Option<String>,
}

/// A live `bz --login` run: the streamed child (§8) plus the [`LoginView`] the
/// shell paints. Held at the invoking surface as instance-local RAM (§5.3); the
/// child is SIGTERM'd on drop (closing the surface aborts the device flow —
/// consistent with a device code being for the human at *this* keyboard).
pub struct LoginRun {
    streamed: Streamed,
    view: LoginView,
    /// The resolved argv — the `ops.jsonl` outcome row and the run-by-hand
    /// fallback both read it (single source: the logged and the shown command
    /// never diverge).
    argv: Vec<String>,
    state_root: PathBuf,
    ts: String,
}

/// Spawn `bz --login --provider <provider>` as the streamed-piped class (§8):
/// stdin null (this verb never reads TTY input — bz's device flow polls on its
/// own), stdout/stderr piped for live line-buffering. A spawn failure (bz absent)
/// appends a synthetic `ops.jsonl` line (§4.2) and returns the error, so no
/// attempt is ever un-logged (§7.3). `ts` is the wall-clock stamp minted at the
/// shell boundary, kept clock-free here.
pub fn start(bz: &Cli, provider: &str, state_root: &Path, ts: &str) -> std::io::Result<LoginRun> {
    let args = [LOGIN_FLAG, PROVIDER_FLAG, provider];
    let mut argv = vec![bz.binary().display().to_string()];
    argv.extend(args.iter().map(|s| (*s).to_string()));
    match bz.run(&args) {
        Ok(stream) => Ok(LoginRun {
            streamed: Streamed::new(stream),
            view: LoginView::default(),
            argv,
            state_root: state_root.to_path_buf(),
            ts: ts.to_owned(),
        }),
        Err(spawn) => {
            let entry =
                OpEntry::synthetic_failure(ts.to_owned(), argv, String::new(), spawn.to_string());
            opslog::append(state_root, &entry)?;
            Err(std::io::Error::other(spawn))
        }
    }
}

impl LoginRun {
    /// The view-model the shell paints this frame (§8.3). Owned per rule 2; the
    /// device flow is a handful of lines, so the clone is negligible.
    pub fn view(&self) -> LoginView {
        self.view.clone()
    }

    /// Non-blocking: drain what the child has produced since the last frame into
    /// the view, and on exit finalize (outcome + fallback + the one ops row).
    /// Returns whether the run is **still live** — `false` once settled, so the
    /// shell can drop it. Idempotent after settle (the guard short-circuits, and
    /// [`Streamed::poll`] itself stays `Pending`): never a second outcome row.
    pub fn poll(&mut self) -> bool {
        if self.view.outcome.is_some() {
            return false;
        }
        match self.streamed.poll() {
            StreamedPoll::Lines(lines) => {
                self.view.lines.extend(lines);
                true
            }
            StreamedPoll::Pending => true,
            StreamedPoll::Done(outcome) => {
                self.finalize(outcome);
                false
            }
        }
    }

    /// Fold the terminal outcome into the view and append the single `ops.jsonl`
    /// row (§4.2): exit + captured stderr, `stdout` blank (the stream converged
    /// live, never re-logged line-by-line). A non-zero exit sets the §8.3
    /// fallback. An append io error has nowhere else to be recorded, so it is
    /// dropped (mirrors `Stream`'s best-effort cleanup) — the live view already
    /// showed the run.
    fn finalize(&mut self, outcome: StreamedOutcome) {
        self.view.lines.extend(outcome.lines);
        self.view.outcome = Some(outcome.exit);
        if outcome.exit != 0 {
            self.view.fallback = Some(self.argv.join(" "));
        }
        let entry = OpEntry {
            ts: self.ts.clone(),
            argv: self.argv.clone(),
            cwd: String::new(),
            exit: outcome.exit,
            stdout: String::new(),
            stderr: outcome.stderr,
        };
        let _ = opslog::append(&self.state_root, &entry);
    }

    /// Build a run over an already-wired [`Streamed`] — the seam the unit tests
    /// drive [`poll`](Self::poll)/[`finalize`](Self::finalize) through
    /// deterministically (the real spawn path is [`start`], covered by S0-T5).
    #[cfg(test)]
    pub(crate) fn from_streamed(streamed: Streamed, argv: Vec<String>, state_root: &Path) -> Self {
        Self {
            streamed,
            view: LoginView::default(),
            argv,
            state_root: state_root.to_path_buf(),
            ts: "TS".to_owned(),
        }
    }
}