yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! Live `bl` projection wiring: ball fetch, §3.5 claimant join badges,
//! Close/Unclaim enablement target, and the ops tail cache (§15 Y16). A hermetic
//! world with a cloned project, two named workspaces, and a fake `bl` whose live
//! and closed replies the test mutates to observe the fetch cadence and the
//! on-demand delivered refresh.

use super::*;
use crate::actions::{close_enabled, unclaim_enabled};
use crate::app::Roots;
use crate::projects::join::JoinState;
use crate::projects::runner::BlRunner;
use crate::test_support::{FakeClock, OkProbe};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::sync::{Arc, Mutex};
use tempfile::{TempDir, tempdir};

// The internal-toggle (§5.1 #1), start-flow input, and fetch-cadence tests share
// this world but live in their own files to stay under the 300-line source cap.
mod internal;
mod refresh;
// The ops-tail / surface-failure view-model tests (§4.2, §7.3) likewise share
// this world from their own file.
mod ops;
// The derived conversation↔ball join (§3.2/§3.3/§3.5, bl-de16): goal-stamp
// balls resolved through the claimant join. Its own hermetic fixture world.
mod convball;

/// A fake `bl` whose per-project `list` and `list_closed` replies the test can
/// rewrite between fetches (shared via the retained `Arc`s). A project in `fail`
/// makes `list` error — a process failure, distinct from an empty listing.
#[derive(Clone)]
struct FakeBl {
    live: Arc<Mutex<HashMap<PathBuf, String>>>,
    closed: Arc<Mutex<HashMap<PathBuf, String>>>,
    fail: Arc<Mutex<HashSet<PathBuf>>>,
}

impl BlRunner for FakeBl {
    fn list(&self, project: &Path) -> std::io::Result<String> {
        if self.fail.lock().unwrap().contains(project) {
            return Err(std::io::Error::other("bl exited nonzero"));
        }
        Ok(self
            .live
            .lock()
            .unwrap()
            .get(project)
            .cloned()
            .unwrap_or_else(|| "[]".to_string()))
    }
    fn list_closed(&self, project: &Path) -> std::io::Result<String> {
        Ok(self
            .closed
            .lock()
            .unwrap()
            .get(project)
            .cloned()
            .unwrap_or_else(|| "[]".to_string()))
    }
    fn show(&self, _p: &Path, _id: &str) -> std::io::Result<String> {
        Ok("{}".to_string())
    }
}

struct World {
    _root: TempDir,
    roots: Roots,
    project: PathBuf,
    ws_cobalt: PathBuf,
    ws_spare: PathBuf,
    live: Arc<Mutex<HashMap<PathBuf, String>>>,
    closed: Arc<Mutex<HashMap<PathBuf, String>>>,
    fail: Arc<Mutex<HashSet<PathBuf>>>,
}

/// Two live balls in one cloned project: `bl-work` (claimant = the local
/// workspace name "cobalt", so bound) and `bl-boss` (claimant "boss", a name no
/// local workspace carries → claimed-elsewhere).
const LIST: &str = r#"[{"id":"bl-work","title":"Work","claimant":"cobalt"},
{"id":"bl-boss","title":"Boss","claimant":"boss"}]"#;

fn world() -> World {
    let root = tempdir().unwrap();
    let roots = Roots {
        yog_data: root.path().join("yog"),
        lernie_data: root.path().join("lernie"),
        yog_state: root.path().join("state"),
        balls_clones: root.path().join("clones"),
        home: root.path().join("home"),
    };
    // A clone whose percent-encoded basename decodes to /proj/a.
    fs::create_dir_all(roots.balls_clones.join("%2Fproj%2Fa")).unwrap();
    fs::create_dir_all(&roots.yog_state).unwrap();
    let project = PathBuf::from("/proj/a");
    // Named workspaces under yog's flat names root: the leaf is the name.
    let ws = |name: &str| {
        let p = roots.yog_data.join("workspaces").join(name);
        fs::create_dir_all(p.join("repo.git")).unwrap();
        p
    };
    let ws_cobalt = ws("cobalt");
    let ws_spare = ws("spare");
    let live = Arc::new(Mutex::new(HashMap::from([(
        project.clone(),
        LIST.to_string(),
    )])));
    let closed = Arc::new(Mutex::new(HashMap::new()));
    let fail = Arc::new(Mutex::new(HashSet::new()));
    World {
        _root: root,
        roots,
        project,
        ws_cobalt,
        ws_spare,
        live,
        closed,
        fail,
    }
}

fn model(w: &World) -> (FakeClock, AppModel) {
    build_model(w, None)
}

/// A model with an explicit startup focus — the start-target derivation tests.
fn model_focused(w: &World, ws: &std::path::Path) -> (FakeClock, AppModel) {
    build_model(w, Some(ws.to_path_buf()))
}

fn build_model(w: &World, focus: Option<PathBuf>) -> (FakeClock, AppModel) {
    let clock = FakeClock::new();
    let m = AppModel::new(
        w.roots.clone(),
        focus,
        clock.arc(),
        Box::new(FakeBl {
            live: w.live.clone(),
            closed: w.closed.clone(),
            fail: w.fail.clone(),
        }),
        Box::new(OkProbe),
        Some("me".to_string()),
    );
    (clock, m)
}

fn set_list(w: &World, json: &str) {
    w.live
        .lock()
        .unwrap()
        .insert(w.project.clone(), json.to_string());
}

fn set_closed(w: &World, json: &str) {
    w.closed
        .lock()
        .unwrap()
        .insert(w.project.clone(), json.to_string());
}

fn append_op(m: &AppModel) {
    opslog::append(
        m.state_root(),
        &opslog::OpEntry {
            ts: "TS".into(),
            argv: vec!["bl".into(), "close".into(), "bl-work".into()],
            cwd: "/proj/a".into(),
            exit: 0,
            stdout: String::new(),
            stderr: String::new(),
        },
    )
    .unwrap();
}

#[test]
fn construction_fetches_balls_and_builds_the_claimant_join() {
    let w = world();
    let (_c, m) = model(&w);
    // bl-work claims the local name "cobalt" → the workspace is Bound.
    let bound = m.row_for(&w.ws_cobalt).unwrap();
    assert_eq!(bound.state, JoinState::Bound, "claimant = workspace name");
    assert_eq!(bound.ball_id, "bl-work");
    // "spare" is claimed by nothing → an unassigned workspace.
    let spare = m.row_for(&w.ws_spare).unwrap();
    assert_eq!(spare.state, JoinState::UnassignedWorkspace);
    // Bound cobalt renders its one ball, unbadged (Bound needs none); spare and an
    // unknown workspace render no ball at all.
    let cobalt_balls = m.ws_balls(&w.ws_cobalt);
    assert_eq!(cobalt_balls.len(), 1);
    assert_eq!(cobalt_balls[0].id, "bl-work");
    assert_eq!(cobalt_balls[0].badge, None);
    assert!(m.ws_balls(&w.ws_spare).is_empty());
    assert!(m.ws_balls(Path::new("/nope")).is_empty());
}

#[test]
fn focused_join_targets_the_focused_workspace_ball() {
    let w = world();
    let (_c, mut m) = model(&w);
    m.focus_workspace(&w.ws_cobalt);
    let row = m.focused_join().unwrap();
    assert_eq!(row.ball_id, "bl-work");
    assert!(close_enabled(row.state), "Bound ⇒ Close offered");
    assert!(unclaim_enabled(row.state));

    m.focus_workspace(&w.ws_spare);
    let spare = m.focused_join().unwrap().state;
    assert!(!close_enabled(spare), "unassigned workspace ⇒ no Close");
    assert!(!unclaim_enabled(spare));
}

#[test]
fn identity_and_state_root_are_exposed() {
    let w = world();
    let (_c, m) = model(&w);
    assert_eq!(
        m.identity(),
        "me",
        "no recorded identity ⇒ the $USER fallback"
    );
    assert_eq!(m.state_root(), w.roots.yog_state.as_path());
}

#[test]
fn an_unlistable_clone_renders_orphaned() {
    let w = world();
    // A second clone whose `bl list` fails (a process failure, not an empty
    // listing): it is enumerated but absent from the ball map, so the join marks
    // it orphaned (§3.5), distinct from a listable-but-empty project.
    fs::create_dir_all(w.roots.balls_clones.join("%2Fproj%2Fgone")).unwrap();
    let bad = PathBuf::from("/proj/gone");
    w.fail.lock().unwrap().insert(bad.clone());
    let (_c, m) = model(&w);
    let orphan = m
        .join_rows
        .iter()
        .find(|r| r.project == bad)
        .expect("the unlistable clone has a row");
    assert_eq!(orphan.state, JoinState::OrphanedProject);
}

/// An empty-world model — no workspaces, no balls — so startup derives **no**
/// focus (§4.1). The one state where a bare start mints (§3.4) and `focused_join`
/// is `None`. Returns the tempdir so its dirs outlive construction.
pub(super) fn empty_model() -> (TempDir, AppModel) {
    let root = tempdir().unwrap();
    let roots = Roots {
        yog_data: root.path().join("yog"),
        lernie_data: root.path().join("lernie"),
        yog_state: root.path().join("state"),
        balls_clones: root.path().join("clones"),
        home: root.path().join("home"),
    };
    let empty = FakeBl {
        live: Arc::new(Mutex::new(HashMap::new())),
        closed: Arc::new(Mutex::new(HashMap::new())),
        fail: Arc::new(Mutex::new(HashSet::new())),
    };
    let m = AppModel::new(
        roots,
        None,
        FakeClock::new().arc(),
        Box::new(empty),
        Box::new(OkProbe),
        None,
    );
    (root, m)
}

#[test]
fn focused_join_is_none_without_a_focused_workspace() {
    // An empty world: no workspaces ⇒ no startup focus ⇒ focused_join None.
    let (_root, m) = empty_model();
    assert!(m.focused_join().is_none());
    assert!(m.ops_rows().is_empty());
    assert_eq!(m.identity(), "", "no recorded identity, no user ⇒ empty");
}