nornir 0.5.1

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
//! 🧬 **Time-Helix tab** — the dedicated viz pane that renders the workspace's
//! release history through the reusable facett component
//! [`facett_helix::HelixView`] (a [`facett_core::Facet`], imported here as
//! [`facett_jobview::Facet`] — the SAME `facett-core` 0.1 trait both crates
//! re-export).
//!
//! Each monitored repo's release timeline becomes a [`facett_helix::Coil`]: the
//! releases march along the helix time axis `t` (index in the lane), the repo is
//! the `system` lane, and each release depends on its predecessor (the intra-coil
//! DAG that winds the coil forward through time). The workspace's latest
//! dependency-graph snapshot supplies the **cross-repo** edges, which bridge the
//! coils as inter-helix links ([`facett_helix::Scene::add_link`]).
//!
//! When the selected workspace has no releases yet, the pane falls back to
//! [`HelixView::demo`] so the tab is never blank.
//!
//! [`state_json`](Facet::state_json) delegates to the facett `HelixView`, so the
//! headless matrix asserts the drawn helix as DATA (LAW 6).

use facett_helix::{Coil, HelixRef, HelixView, Kind, NodeId, PlanDag, Scene, Status};
use facett_jobview::Facet; // == facett_core::Facet (both crates re-export the 0.1 trait)

use super::facett_theme::Theme;
use super::model::Timeline;

/// 🧬 The Time-Helix tab pane state — a thin host around the facett [`HelixView`].
pub struct HelixTabState {
    view: HelixView,
    /// The workspace + release-count signature the current scene was built from, so
    /// we rebuild the helix only when the underlying timeline actually changes.
    built_sig: Option<(String, usize, usize)>,
    /// `true` once a REAL (non-demo) scene has been built from workspace data.
    from_data: bool,
    /// Nodes in the current scene (0 for the demo fallback path only if it were
    /// empty — the demo always has nodes). Surfaced for the empty-state note.
    coil_count: usize,
}

impl Default for HelixTabState {
    fn default() -> Self {
        Self {
            view: HelixView::demo(),
            built_sig: None,
            from_data: false,
            coil_count: 0,
        }
    }
}

impl HelixTabState {
    /// Discovery-contract ctor (the no-arg surface the test matrix enumerates).
    pub fn local() -> Self {
        Self::default()
    }

    /// The active facett palette (C8). The Facet reads the ambient egui theme, so
    /// this is a no-op seam kept for parity with the other tabs' `set_palette`.
    pub fn set_palette(&mut self, _t: Theme) {}

    /// Signature of a timeline for cheap change-detection: (workspace, total
    /// releases across all lanes, cross-repo edge count in the latest snapshot).
    fn signature(tl: &Timeline) -> (String, usize, usize) {
        let releases: usize = tl.lanes.iter().map(|l| l.nodes.len()).sum();
        let edges = tl.latest_snapshot.as_ref().map(|s| s.edges.len()).unwrap_or(0);
        (tl.workspace_name.clone(), releases, edges)
    }

    /// Rebuild the helix scene from the current timeline when it changed. Falls back
    /// to [`HelixView::demo`] when the workspace has no release data yet.
    fn rebuild_if_needed(&mut self, tl: Option<&Timeline>) {
        let sig = tl.map(Self::signature);
        if sig == self.built_sig {
            return;
        }
        self.built_sig = sig.clone();
        match tl.and_then(scene_from_timeline) {
            Some((scene, coils)) => {
                let title = format!("🧬 Time Helix — {}", tl.map(|t| t.workspace_name.as_str()).unwrap_or(""));
                self.view = HelixView::new(title, scene);
                self.from_data = true;
                self.coil_count = coils;
            }
            None => {
                // No releases (or no timeline) — never blank, show the demo helix.
                self.view = HelixView::demo();
                self.from_data = false;
                self.coil_count = 0;
            }
        }
    }

    /// Draw the pane: header + the facett [`HelixView`], scoped to the stable
    /// `Helix` pane id so the AccessKit `id_salt` matches the surfaces registry.
    pub fn draw(&mut self, ui: &mut egui::Ui, tl: Option<&Timeline>) {
        self.rebuild_if_needed(tl);
        ui.push_id("Helix", |ui| {
            ui.horizontal(|ui| {
                ui.heading("🧬 Time Helix");
                ui.label(
                    egui::RichText::new(
                        "each repo's releases wind forward along the time axis; cross-repo deps bridge the coils",
                    )
                    .weak(),
                );
            });
            if !self.from_data {
                ui.label(
                    egui::RichText::new(
                        "no releases recorded in this workspace yet — showing the demo helix. Populate the workspace to render its real release timelines.",
                    )
                    .weak(),
                );
            } else {
                ui.label(
                    egui::RichText::new(format!("{} repo coil(s) advancing through release time", self.coil_count)).weak(),
                );
            }
            ui.separator();
            Facet::ui(&mut self.view, ui);
        });
    }

    /// The drawn helix as DATA (LAW 6) — delegates to the facett `HelixView`, plus a
    /// `from_data` flag so a headless drive can tell the real scene from the demo.
    pub fn state_json(&self) -> serde_json::Value {
        let mut v = Facet::state_json(&self.view);
        if let Some(obj) = v.as_object_mut() {
            obj.insert("from_data".into(), serde_json::Value::Bool(self.from_data));
            obj.insert("coils".into(), serde_json::Value::from(self.coil_count));
        }
        v
    }
}

/// Build a [`Scene`] from a workspace [`Timeline`]: one coil per repo lane (its
/// releases along the time axis, each depending on its predecessor), and the
/// latest dep-graph snapshot's cross-repo edges as inter-helix links. Returns the
/// scene + coil count, or `None` when there is no release data to render.
fn scene_from_timeline(tl: &Timeline) -> Option<(Scene, usize)> {
    // No releases anywhere ⇒ nothing to build (caller falls back to the demo).
    if tl.lanes.iter().all(|l| l.nodes.is_empty()) {
        return None;
    }
    let mut scene = Scene::new();
    // repo name → (helix id, node id of each release in lane order).
    let mut placed: std::collections::HashMap<String, (u32, Vec<NodeId>)> =
        std::collections::HashMap::new();
    let n = tl.lanes.iter().filter(|l| !l.nodes.is_empty()).count().max(1) as f32;
    let mut coil_idx = 0usize;
    for lane in &tl.lanes {
        if lane.nodes.is_empty() {
            continue;
        }
        let mut dag = PlanDag::new();
        let mut ids: Vec<NodeId> = Vec::with_capacity(lane.nodes.len());
        for (ri, node) in lane.nodes.iter().enumerate() {
            // Label: prefer a published crate version, else the short git sha.
            let label = if let Some((c, v)) = node.published_versions.first() {
                format!("{c} {v}")
            } else if !node.sha.is_empty() {
                node.sha.chars().take(7).collect::<String>()
            } else {
                format!("r{ri}")
            };
            // A published release is a `Crate` node; an un-published gate run a `Task`.
            let kind = if node.published_versions.is_empty() { Kind::Task } else { Kind::Crate };
            let id = dag.add_idea(label, lane.repo.clone(), ri as f64, kind);
            // Status from the gate verdict / test tallies.
            let status = match node.gate_status.to_ascii_lowercase().as_str() {
                "pass" | "passed" | "ok" | "green" | "clean" => Status::Done,
                "fail" | "failed" | "red" | "blocked" => Status::Blocked,
                _ if node.tests_failed > 0 => Status::Blocked,
                _ => Status::Active,
            };
            dag.set_status(id, status);
            // Intra-coil dep: each release winds forward from its predecessor.
            if let Some(&prev) = ids.last() {
                dag.add_dep(id, prev);
            }
            ids.push(id);
        }
        // Stack the coils in parallel along Z, centred on the origin.
        let z = (coil_idx as f32 - (n - 1.0) / 2.0) * 6.0;
        let coil = Coil::new(dag).at([0.0, 0.0, z]);
        let hid = scene.add_coil(coil);
        placed.insert(lane.repo.clone(), (hid, ids));
        coil_idx += 1;
    }
    // Cross-repo (inter-helix) links from the latest dep-graph snapshot: link the
    // most-recent release of the consumer repo to that of the producer repo.
    if let Some(snap) = tl.latest_snapshot.as_ref() {
        for e in &snap.edges {
            if let (Some((fh, fids)), Some((th, tids))) = (placed.get(&e.from), placed.get(&e.to)) {
                if let (Some(&fnode), Some(&tnode)) = (fids.last(), tids.last()) {
                    scene.add_link(
                        HelixRef { helix: *fh, node: fnode },
                        HelixRef { helix: *th, node: tnode },
                        1,
                    );
                }
            }
        }
    }
    Some((scene, coil_idx))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::viz::model::{Lane, LaneNode};
    use crate::warehouse::dep_graph::{CrossRepoEdge, DepGraphSnapshot};
    use std::collections::BTreeMap;

    fn lane(repo: &str, releases: usize) -> Lane {
        let nodes = (0..releases)
            .map(|i| LaneNode {
                release_id: uuid::Uuid::new_v4(),
                timestamp: chrono::Utc::now(),
                sha: format!("{repo}{i:07}"),
                branch: "main".into(),
                dirty: false,
                gate_status: "pass".into(),
                tests_passed: 1,
                tests_failed: 0,
                published_versions: vec![(repo.to_string(), format!("0.1.{i}"))],
            })
            .collect();
        Lane { repo: repo.to_string(), nodes }
    }

    fn tl_with(lanes: Vec<Lane>, edges: Vec<CrossRepoEdge>) -> Timeline {
        let snap = (!edges.is_empty()).then(|| DepGraphSnapshot {
            snapshot_id: uuid::Uuid::new_v4(),
            workspace_name: "ws".into(),
            timestamp: chrono::Utc::now(),
            edges,
        });
        Timeline {
            workspace_name: "ws".into(),
            lanes,
            release_order: Vec::new(),
            release_snapshot: BTreeMap::new(),
            snapshots: BTreeMap::new(),
            latest_snapshot: snap,
            bench_history: BTreeMap::new(),
        }
    }

    #[test]
    fn empty_timeline_builds_no_scene() {
        let tl = tl_with(vec![Lane { repo: "a".into(), nodes: Vec::new() }], vec![]);
        assert!(scene_from_timeline(&tl).is_none());
    }

    #[test]
    fn two_repos_build_two_coils_and_a_link() {
        let edges = vec![CrossRepoEdge::normal("a", "b", Default::default())];
        let tl = tl_with(vec![lane("a", 3), lane("b", 2)], edges);
        let (scene, coils) = scene_from_timeline(&tl).expect("scene");
        assert_eq!(coils, 2, "one coil per non-empty lane");
        assert_eq!(scene.coils.len(), 2);
        assert_eq!(scene.links.len(), 1, "the cross-repo edge became an inter-helix link");
    }

    #[test]
    fn tab_falls_back_to_demo_without_data() {
        let mut tab = HelixTabState::local();
        tab.rebuild_if_needed(None);
        assert!(!tab.from_data);
        assert_eq!(tab.state_json()["from_data"], serde_json::json!(false));
    }
}