Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/dwarves

dwarves-gui/src/room.rs

//! **The code room, as a pane sees it** — the ONE seam every code-graph pane asks
//! through, and the ONE callout it leads with.
//!
//! Since Mímir v2 the code graph is nornir's code room. A pane here does not scan a
//! table, walk an edge or read a source file: it ASKS a reader surface by name
//! (`Viz.CrateGraph`, `Knowledge.TestReach`, `Knowledge.Source` …) through the forge
//! (`CodeRoom.Answer`, proxied to the embedded nornir) and draws the answer. One read
//! path: UI → forge → embedded nornir → room.
//!
//! Every answer is a [`RoomAnswer`] with a state by name. The rule each pane follows:
//!
//! * a MEASURED answer (`complete` · `partial` · `empty_fact`) is drawn, with the chip
//!   of its state when it is not `complete`, its evidence text, and the room it was
//!   read from (workspace · repo@seq (commit) · nodes · edges) — the line a person
//!   checks against the server;
//! * anything else is drawn as the state and its evidence. Never a number, never a
//!   row, never a sample.

use std::sync::Arc;

pub use nornir_warehouse_embed::room::{room_line_of, RoomAnswer, RoomState};

/// Where a pane's room answers come from. Injected, so a test can stand a pane on
/// canned answers; the shipped source is [`ForgeRoom`].
///
/// `Send + Sync`: a pane asks on a WORKER thread (a room is folded from the log on its
/// first ask — seconds to minutes), never on the frame that is trying to draw.
pub trait RoomSource: Send + Sync {
    /// Ask one surface. Total — a failure is an answer with a state.
    fn ask(&self, surface: &str, params: &serde_json::Value) -> RoomAnswer;
    /// Where this source asks (`CodeRoom.Answer @ <forge> / <workspace>`).
    fn describe(&self) -> String;
}

/// The shipped source: the forge's `CodeRoom.Answer`, for the selected workspace.
pub struct ForgeRoom {
    pub endpoint: String,
    pub token: String,
    pub workspace: String,
}

impl ForgeRoom {
    pub fn new(
        endpoint: impl Into<String>,
        token: impl Into<String>,
        workspace: impl Into<String>,
    ) -> Self {
        Self {
            endpoint: endpoint.into(),
            token: token.into(),
            workspace: workspace.into(),
        }
    }

    /// The same source as a shared handle (what a pane's worker clones).
    pub fn shared(endpoint: &str, token: &str, workspace: &str) -> Arc<dyn RoomSource> {
        Arc::new(Self::new(endpoint, token, workspace))
    }
}

impl RoomSource for ForgeRoom {
    fn ask(&self, surface: &str, params: &serde_json::Value) -> RoomAnswer {
        nornir_warehouse_embed::room::ask(
            &self.endpoint,
            &self.token,
            &self.workspace,
            surface,
            params,
        )
    }
    fn describe(&self) -> String {
        format!(
            "CodeRoom.Answer @ {} / {}",
            self.endpoint,
            if self.workspace.is_empty() {
                "(no workspace)"
            } else {
                &self.workspace
            }
        )
    }
}

const RED: egui::Color32 = egui::Color32::from_rgb(0xc6, 0x28, 0x28);
const TEAL: egui::Color32 = egui::Color32::from_rgb(0x00, 0x69, 0x5c);
const AMBER: egui::Color32 = egui::Color32::from_rgb(0x8d, 0x6e, 0x00);

/// The chip of an answer's state: its label and colour. `complete` has one too
/// (teal) — a pane that draws numbers says what they rest on, also when all is well.
pub fn chip_of(a: &RoomAnswer) -> (String, egui::Color32) {
    match a.kind {
        RoomState::Complete => ("COMPLETE".into(), TEAL),
        RoomState::Partial => ("PARTIAL — LOWER BOUND".into(), AMBER),
        RoomState::EmptyFact => ("NOTHING FOUND".into(), TEAL),
        RoomState::EmptyGap => ("EMPTY — INPUT MISSING".into(), RED),
        RoomState::Queued => (
            format!("QUEUED ON JERA — JOB {}", a.job_id().unwrap_or("?")),
            AMBER,
        ),
        RoomState::Refused | RoomState::NotAsked | RoomState::Unstated => {
            (a.state.to_uppercase().replace('_', " "), RED)
        }
    }
}

fn chip(ui: &mut egui::Ui, text: &str, color: egui::Color32) {
    ui.label(
        egui::RichText::new(format!(" {text} "))
            .small()
            .strong()
            .color(egui::Color32::WHITE)
            .background_color(color),
    );
}

/// **The callout every room-fed pane leads with**: what was asked, the state as a
/// chip, the evidence text in the reader's own words, the room it was read from, and
/// where it was asked. `what` names the layer in the pane's words ("crate graph").
pub fn callout(ui: &mut egui::Ui, what: &str, a: &RoomAnswer) {
    let (label, color) = chip_of(a);
    ui.horizontal_wrapped(|ui| {
        chip(ui, &label, color);
        ui.strong(what);
        let ev = a.evidence_text();
        if !ev.is_empty() {
            ui.label(egui::RichText::new(ev).small());
        }
    });
    let room = a.room_line();
    ui.horizontal_wrapped(|ui| {
        if !room.is_empty() {
            ui.label(
                egui::RichText::new(format!("room: {room}"))
                    .small()
                    .monospace(),
            );
        }
        ui.weak(format!("via {}", a.via));
    });
}

/// The line a pane shows while its read is in flight: how long, and — when the
/// server put the answer on its jera queue — which job it waits for.
pub fn asking_line(started: std::time::Instant) -> String {
    let mut line = format!(
        "asking the code room — {:.1}s (off the UI thread)",
        started.elapsed().as_secs_f32()
    );
    for w in nornir_warehouse_embed::room::waiting() {
        line.push_str(" · ");
        line.push_str(&w);
    }
    line
}

/// A room read in flight: the worker's receiver and when it started. The frame polls
/// it; a robot seam blocks on it.
pub struct Pending<T> {
    rx: std::sync::mpsc::Receiver<T>,
    pub started: std::time::Instant,
}

impl<T: Send + 'static> Pending<T> {
    /// Run `read` on a worker thread named `name`.
    pub fn spawn(name: &str, read: impl FnOnce() -> T + Send + 'static) -> Self {
        let (tx, rx) = std::sync::mpsc::channel();
        std::thread::Builder::new()
            .name(name.to_string())
            .spawn(move || {
                let _ = tx.send(read());
            })
            .expect("spawn a code-room read worker");
        Self {
            rx,
            started: std::time::Instant::now(),
        }
    }

    /// The result if the worker finished (`Err(())` = the worker died).
    pub fn try_take(&self) -> Option<Result<T, ()>> {
        match self.rx.try_recv() {
            Ok(v) => Some(Ok(v)),
            Err(std::sync::mpsc::TryRecvError::Empty) => None,
            Err(std::sync::mpsc::TryRecvError::Disconnected) => Some(Err(())),
        }
    }

    /// Block until the worker answers (`Err(())` = it died).
    // `()` says all there is: the worker died. Same shape as `try_take`'s, which every
    // pane's `land` takes.
    #[allow(clippy::result_unit_err)]
    pub fn wait(self) -> Result<T, ()> {
        self.rx.recv().map_err(|_| ())
    }
}

/// The answer a pane holds when its read worker died before answering.
pub fn worker_died(surface: &str, source: &dyn RoomSource) -> RoomAnswer {
    let mut a = RoomAnswer::pending(surface, &source.describe());
    a.state = "read_failed".to_string();
    a.note = "the read worker died before it answered".to_string();
    a
}

#[cfg(test)]
pub mod testing {
    //! Canned room answers for a pane's unit tests (no socket).
    use super::*;
    use std::collections::BTreeMap;
    use std::sync::Mutex;

    /// A source answering canned bodies per surface; an unfed surface refuses
    /// `invalid_argument`, exactly as the reader does for a name it does not know.
    #[derive(Default)]
    pub struct CannedRoom {
        pub answers: Mutex<BTreeMap<String, serde_json::Value>>,
        pub asked: Mutex<Vec<(String, serde_json::Value)>>,
    }

    impl CannedRoom {
        pub fn new(answers: &[(&str, serde_json::Value)]) -> Arc<Self> {
            let me = Self::default();
            for (s, v) in answers {
                me.answers.lock().unwrap().insert(s.to_string(), v.clone());
            }
            Arc::new(me)
        }
        pub fn set(&self, surface: &str, body: serde_json::Value) {
            self.answers
                .lock()
                .unwrap()
                .insert(surface.to_string(), body);
        }
    }

    impl RoomSource for CannedRoom {
        fn ask(&self, surface: &str, params: &serde_json::Value) -> RoomAnswer {
            self.asked
                .lock()
                .unwrap()
                .push((surface.to_string(), params.clone()));
            let body = self.answers.lock().unwrap().get(surface).cloned().unwrap_or_else(|| {
                serde_json::json!({ "state": "invalid_argument", "refused": true, "note": format!("`{surface}` is not fed in this test") })
            });
            RoomAnswer::from_body(surface, &self.describe(), body)
        }
        fn describe(&self) -> String {
            "canned room (test)".to_string()
        }
    }

    /// A refusal body, as the door sends one.
    pub fn refusal(state: &str, note: &str) -> serde_json::Value {
        serde_json::json!({ "state": state, "refused": true, "note": note })
    }

    /// A `room` identity block.
    pub fn room(
        workspace: &str,
        repo: &str,
        seq: i64,
        nodes: u64,
        edges: u64,
    ) -> serde_json::Value {
        serde_json::json!({
            "workspace": workspace, "universe": false,
            "repos": [{ "repo": repo, "seq": seq, "sha": "0123456789abcdef0123" }],
            "nodes": nodes, "edges_total": edges,
        })
    }
}

#[cfg(test)]
mod queued_tests {
    use super::*;

    /// An answer the server put on its jera queue is drawn as the job it waits
    /// for — amber, never a number, never red as if it failed.
    #[test]
    fn a_queued_answer_is_drawn_as_its_jera_job() {
        let body = serde_json::json!({ "state": "running", "job_id": "j-1", "note": "running on jera — job j-1 (running 4s); ask again for the answer" });
        let a = RoomAnswer::from_body("Viz.CrateGraph", "via", body);
        let (label, color) = chip_of(&a);
        assert_eq!((label.as_str(), color), ("QUEUED ON JERA — JOB j-1", AMBER));
        assert!(!a.is_measured() && a.rows("crates").is_empty());
        assert!(asking_line(std::time::Instant::now()).starts_with("asking the code room — "));
    }
}