nornir 0.4.13

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
//! Knowledge-map tab: **background** scan + bubble chart of repos.
//!
//! On first entry (and on "↻ rescan" / a workspace switch) the tab kicks off one
//! [`crate::knowledge::scan_all`] per configured repo, in parallel via rayon, on
//! a **dedicated background thread** — the UI thread NEVER blocks on the scan.
//! While the thread runs the tab paints "⏳ scanning …" and requests a repaint;
//! when the slot fills, the results swap in. Results are cached until the next
//! rescan / workspace change.
//!
//! Root cause of the historical ~5s click stall: the rayon scan (syn parse of
//! every `.rs` + a `gix` HEAD-history walk per repo) ran **synchronously on the
//! UI thread** inside `draw()`, so the first frame after the tab was selected
//! froze for the whole wall-time of the slowest repo scan. Moving the scan to a
//! `std::thread::spawn` (the LiveRun / Security tab pattern) keeps the UI live.
//!
//! Visual model:
//!   - one bubble per crate, x = symbol-count, y = call-count (both log)
//!   - bubble radius = file count of the crate
//!   - bubble color = git heat (max commits-30d across the crate's files)
//!   - hovering shows breakdown (kinds, top-N functions by call-out)

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Instant;

use eframe::egui::{self, Color32, Pos2, Sense, Stroke, Vec2};
use serde::Serialize;

use crate::knowledge::{self, ScanResult};
use crate::viz::trace;

/// The handoff slot a background scan thread fills exactly once. `None` until the
/// thread finishes; then the finished per-repo scans (input order preserved).
type ScanSlot = Arc<Mutex<Option<Vec<(String, Result<ScanResult, String>)>>>>;

/// INPUT of a Knowledge rescan — the workspace_root and the repo list the scan
/// was handed (the case-sensitive `workspace_root.join(repo)` happens per repo).
#[derive(Serialize)]
struct ScanIn {
    workspace_root: String,
    repos: Vec<String>,
}

/// OUTPUT data for one scanned repo — resolved path, whether it exists on disk
/// (a case mismatch like `njord` vs `Njord` shows up as `exists:false`), the
/// per-kind counts, timing, and the error if the scan failed.
#[derive(Serialize)]
struct RepoScan {
    repo: String,
    path: String,
    exists: bool,
    symbols: usize,
    calls: usize,
    features: usize,
    git_files: usize,
    ms: u64,
    error: Option<String>,
}

/// OUTPUT summary of a rescan across all repos.
#[derive(Serialize)]
struct ScanOut {
    repos_ok: usize,
    repos_total: usize,
    ms: u64,
}

/// The rendered table payload — the exact per-crate rows the user sees.
#[derive(Serialize)]
struct RenderOut<'a> {
    crates: usize,
    bubbles: &'a [Bubble],
}

pub struct KnowledgeState {
    workspace_root: PathBuf,
    repos: Vec<String>,
    scans: Vec<(String, Result<ScanResult, String>)>,
    last_scan: Option<Instant>,
    /// Set when a rescan should be kicked off on the next `draw()` — flipped on
    /// construction, by the "↻ rescan" button, and by a workspace switch.
    rescan_requested: bool,
    /// The in-flight background scan's handoff slot, plus when it started (for
    /// the "scanning Ns" label). `Some` while a scan thread is running; the slot
    /// is drained into `scans` the first frame the thread has finished.
    pending: Option<(ScanSlot, Instant)>,
    /// Set when a rescan produces fresh `scans`; gates the `knowledge.render`
    /// trace event so it fires once per data change, not once per frame (the
    /// tab redraws at ~60fps — emitting render every frame would flood the
    /// trace). Cleared after the render event is emitted.
    render_dirty: bool,
}

impl KnowledgeState {
    pub fn new(workspace_root: PathBuf, repos: Vec<String>) -> Self {
        Self {
            workspace_root,
            repos,
            scans: Vec::new(),
            last_scan: None,
            rescan_requested: true,
            pending: None,
            render_dirty: false,
        }
    }

    /// Re-scope to a different workspace (the picker switched): swap in the new
    /// root + repo list, drop the stale scans, and arm a fresh background rescan
    /// so the next `draw()` rebuilds the map for the new workspace.
    pub fn set_workspace(&mut self, workspace_root: PathBuf, repos: Vec<String>) {
        self.workspace_root = workspace_root;
        self.repos = repos;
        self.scans.clear();
        self.last_scan = None;
        self.pending = None;
        self.rescan_requested = true;
        self.render_dirty = false;
    }

    /// Kick off a background scan of all configured repos. Returns immediately;
    /// the UI thread never blocks. The spawned thread fans out across repos with
    /// rayon (slowest-scan, not sum-of-scans) and parks the result in the slot.
    fn start_scan(&mut self) {
        let slot: ScanSlot = Arc::new(Mutex::new(None));
        let sink = slot.clone();
        let workspace_root = self.workspace_root.clone();
        let repos = self.repos.clone();
        // Structured observability (see viz::trace): emit the INPUT (the
        // workspace_root + the repo list this scan was handed). The per-repo
        // OUTPUT + the scan OUT summary are emitted from the worker thread.
        trace::emit_in(
            "knowledge.scan",
            &ScanIn {
                workspace_root: workspace_root.display().to_string(),
                repos: repos.clone(),
            },
        );
        std::thread::spawn(move || {
            use rayon::prelude::*;
            let t0 = Instant::now();
            let scans: Vec<(String, Result<ScanResult, String>)> = repos
                .par_iter()
                .map(|repo| {
                    let repo_root = workspace_root.join(repo);
                    let exists = repo_root.exists();
                    let r0 = Instant::now();
                    let scan = knowledge::scan_all(&repo_root, repo).map_err(|e| format!("{e:#}"));
                    let result = match &scan {
                        Ok(s) => RepoScan {
                            repo: repo.clone(),
                            path: repo_root.display().to_string(),
                            exists,
                            symbols: s.symbols.symbols.len(),
                            calls: s.symbols.calls.len(),
                            features: s.symbols.features.len(),
                            git_files: s.git.files.len(),
                            ms: r0.elapsed().as_millis() as u64,
                            error: None,
                        },
                        Err(e) => RepoScan {
                            repo: repo.clone(),
                            path: repo_root.display().to_string(),
                            exists,
                            symbols: 0,
                            calls: 0,
                            features: 0,
                            git_files: 0,
                            ms: r0.elapsed().as_millis() as u64,
                            error: Some(e.clone()),
                        },
                    };
                    // The OUTPUT data for this repo, structured (a missing path or
                    // a case-mismatch shows as exists=false + an error string).
                    trace::emit_end("knowledge.scan.repo", &result);
                    (repo.clone(), scan)
                })
                .collect();
            let ok = scans.iter().filter(|(_, s)| s.is_ok()).count();
            trace::emit_out(
                "knowledge.scan",
                &ScanOut {
                    repos_ok: ok,
                    repos_total: scans.len(),
                    ms: t0.elapsed().as_millis() as u64,
                },
            );
            *sink.lock().unwrap_or_else(|p| p.into_inner()) = Some(scans);
        });
        self.pending = Some((slot, Instant::now()));
    }

    /// Run the scan synchronously and store the result — **test-only** injector
    /// for the inject-and-assert harness (no UI loop to drain the background
    /// slot). After this `scans` / `state_json` carry the real counts.
    #[doc(hidden)]
    pub fn scan_blocking_for_test(&mut self) {
        use rayon::prelude::*;
        let workspace_root = &self.workspace_root;
        self.scans = self
            .repos
            .par_iter()
            .map(|repo| {
                let repo_root = workspace_root.join(repo);
                let scan = knowledge::scan_all(&repo_root, repo).map_err(|e| format!("{e:#}"));
                (repo.clone(), scan)
            })
            .collect();
        self.last_scan = Some(Instant::now());
        self.rescan_requested = false;
        self.pending = None;
        self.render_dirty = true;
    }

    pub fn draw(&mut self, ui: &mut egui::Ui) {
        // Drain a finished background scan into `scans` (recover a poisoned lock
        // rather than panic the UI thread if the worker unwound mid-flight).
        if let Some((slot, _started)) = &self.pending {
            let done = slot.lock().unwrap_or_else(|p| p.into_inner()).take();
            if let Some(scans) = done {
                self.scans = scans;
                self.last_scan = Some(Instant::now());
                self.pending = None;
                self.render_dirty = true;
            }
        }
        // First entry / rescan / workspace switch: arm the background scan. We
        // don't kick a new one while one is already in flight.
        if self.rescan_requested && self.pending.is_none() {
            self.start_scan();
            self.rescan_requested = false;
        }

        let scanning = self.pending.is_some();
        ui.horizontal(|ui| {
            ui.heading("🗺 Knowledge map");
            ui.separator();
            if ui.add_enabled(!scanning, egui::Button::new("↻ rescan")).clicked() {
                self.rescan_requested = true;
            }
            if let Some((_, started)) = &self.pending {
                ui.label(format!("⏳ scanning {} repo(s) … {}s", self.repos.len(), started.elapsed().as_secs()));
                // Keep the frame loop alive so we notice the worker finishing.
                ui.ctx().request_repaint();
            } else if let Some(when) = self.last_scan {
                ui.label(format!("last scanned {}s ago", when.elapsed().as_secs()));
            }
        });
        ui.separator();

        let bubbles = collect_bubbles(&self.scans);
        // The rendered table/chart payload — the EXACT rows the user sees on
        // screen, as structured data. This is the "see the data we see" event:
        // an agent reads this back to know precisely what the Knowledge tab
        // displayed (which repos, which crates, the counts), no screenshot.
        // Edge-triggered (only after a rescan) so it never floods the per-frame
        // redraw loop.
        if self.render_dirty {
            trace::emit_end(
                "knowledge.render",
                &RenderOut { crates: bubbles.len(), bubbles: &bubbles },
            );
            self.render_dirty = false;
        }
        if bubbles.is_empty() {
            if scanning {
                ui.label("⏳ scanning repos (symbol parse + git heat) — the UI stays responsive …");
            } else {
                ui.label("No data yet — click ↻ rescan or check workspace path.");
            }
            return;
        }

        // Header table of per-crate totals.
        egui::CollapsingHeader::new(format!("📊 {} crates scanned", bubbles.len()))
            .default_open(true)
            .show(ui, |ui| {
                egui::Grid::new("kn_summary").striped(true).num_columns(7).show(ui, |ui| {
                    ui.label("repo"); ui.label("crate");
                    ui.label("symbols"); ui.label("calls"); ui.label("files");
                    ui.label("gates"); ui.label("git heat (30d)");
                    ui.end_row();
                    for b in &bubbles {
                        ui.label(&b.repo); ui.label(&b.krate);
                        ui.label(format!("{}", b.symbols));
                        ui.label(format!("{}", b.calls));
                        ui.label(format!("{}", b.files));
                        ui.label(format!("{}", b.gates));
                        ui.label(format!("{}", b.heat_30d));
                        ui.end_row();
                    }
                });
            });

        ui.separator();
        ui.label("Bubble chart — x: symbol count, y: call count, size: files, color: 30d commits");
        draw_bubble_chart(ui, &bubbles);
    }

    /// The Knowledge tab's slice of `state_json` — the exact rendered rows (one
    /// per crate), the scan status, and per-repo totals. Mirrors what the UI
    /// paints so the headless test matrix / an agent reads back the real data
    /// (LAW #6: the rendered rows are in `state_json`).
    pub fn state_json(&self) -> serde_json::Value {
        let bubbles = collect_bubbles(&self.scans);
        let repos: Vec<serde_json::Value> = self
            .scans
            .iter()
            .map(|(repo, scan)| match scan {
                Ok(s) => serde_json::json!({
                    "repo": repo,
                    "ok": true,
                    "symbols": s.symbols.symbols.len(),
                    "calls": s.symbols.calls.len(),
                    "features": s.symbols.features.len(),
                    "git_files": s.git.files.len(),
                }),
                Err(e) => serde_json::json!({ "repo": repo, "ok": false, "error": e }),
            })
            .collect();
        serde_json::json!({
            "workspace_root": self.workspace_root.display().to_string(),
            "configured_repos": self.repos,
            "scanning": self.pending.is_some(),
            "scanned_repos": self.scans.len(),
            "crates": bubbles.len(),
            "rows": bubbles,
            "repo_totals": repos,
        })
    }
}

#[derive(Serialize)]
struct Bubble {
    repo: String,
    krate: String,
    symbols: usize,
    calls: usize,
    files: usize,
    gates: usize,
    heat_30d: i64,
}

fn collect_bubbles(scans: &[(String, Result<ScanResult, String>)]) -> Vec<Bubble> {
    let mut out = Vec::new();
    for (repo, scan) in scans {
        let Ok(scan) = scan else { continue };

        let mut per_crate_syms: BTreeMap<&str, usize> = BTreeMap::new();
        let mut per_crate_calls: BTreeMap<&str, usize> = BTreeMap::new();
        let mut per_crate_files: BTreeMap<&str, std::collections::BTreeSet<&str>> = BTreeMap::new();
        let mut per_crate_gates: BTreeMap<&str, usize> = BTreeMap::new();
        for s in &scan.symbols.symbols {
            *per_crate_syms.entry(&s.crate_name).or_default() += 1;
            per_crate_files.entry(&s.crate_name).or_default().insert(&s.file);
        }
        for c in &scan.symbols.calls {
            *per_crate_calls.entry(&c.crate_name).or_default() += 1;
        }
        for f in &scan.symbols.features {
            *per_crate_gates.entry(&f.crate_name).or_default() += 1;
        }

        // Heat: max 30d commits across all files in the repo, applied per-crate
        // (we lack module→crate mapping in git_heat, so use repo-wide max as a
        // simple proxy; refine later by joining file prefixes with crate dirs).
        let max_30d = scan.git.files.iter().map(|f| f.commits_30d).max().unwrap_or(0);

        for (krate, sym_n) in &per_crate_syms {
            out.push(Bubble {
                repo: repo.clone(),
                krate: krate.to_string(),
                symbols: *sym_n,
                calls: per_crate_calls.get(krate).copied().unwrap_or(0),
                files: per_crate_files.get(krate).map(|s| s.len()).unwrap_or(0),
                gates: per_crate_gates.get(krate).copied().unwrap_or(0),
                heat_30d: max_30d,
            });
        }
    }
    out.sort_by_key(|b| std::cmp::Reverse(b.symbols));
    out
}

fn draw_bubble_chart(ui: &mut egui::Ui, bubbles: &[Bubble]) {
    let avail = ui.available_size_before_wrap();
    let plot_w = avail.x.max(400.0);
    let plot_h = (avail.y - 40.0).max(300.0);
    let (rect, _resp) = ui.allocate_exact_size(Vec2::new(plot_w, plot_h), Sense::hover());
    let painter = ui.painter_at(rect);

    painter.rect_filled(rect, 4.0, Color32::from_rgb(20, 20, 28));

    let max_sym = bubbles.iter().map(|b| b.symbols).max().unwrap_or(1) as f32;
    let max_calls = bubbles.iter().map(|b| b.calls).max().unwrap_or(1) as f32;
    let max_files = bubbles.iter().map(|b| b.files).max().unwrap_or(1) as f32;
    let max_heat = bubbles.iter().map(|b| b.heat_30d).max().unwrap_or(1) as f32;

    let pad = 30.0;
    for b in bubbles {
        let x_frac = ((b.symbols as f32).max(1.0).ln()) / max_sym.max(1.0).ln().max(0.1);
        let y_frac = ((b.calls as f32).max(1.0).ln()) / max_calls.max(1.0).ln().max(0.1);
        let cx = rect.min.x + pad + (rect.width() - 2.0 * pad) * x_frac;
        let cy = rect.max.y - pad - (rect.height() - 2.0 * pad) * y_frac;
        let r = 6.0 + 28.0 * ((b.files as f32) / max_files.max(1.0)).sqrt();
        let heat_norm = (b.heat_30d as f32) / max_heat.max(1.0);
        let color = heat_color(heat_norm);

        painter.circle_filled(Pos2::new(cx, cy), r, color);
        painter.circle_stroke(Pos2::new(cx, cy), r, Stroke::new(1.0, Color32::WHITE));
        painter.text(
            Pos2::new(cx, cy - r - 6.0),
            egui::Align2::CENTER_BOTTOM,
            &b.krate,
            egui::FontId::proportional(10.0),
            Color32::LIGHT_GRAY,
        );
    }

    // Axis labels
    painter.text(
        Pos2::new(rect.center().x, rect.max.y - 4.0),
        egui::Align2::CENTER_BOTTOM,
        "symbols (log) →",
        egui::FontId::proportional(11.0),
        Color32::GRAY,
    );
    painter.text(
        Pos2::new(rect.min.x + 4.0, rect.center().y),
        egui::Align2::LEFT_CENTER,
        "↑ calls (log)",
        egui::FontId::proportional(11.0),
        Color32::GRAY,
    );
}

fn heat_color(t: f32) -> Color32 {
    // cold → warm: deep teal → orange
    let t = t.clamp(0.0, 1.0);
    let r = (60.0 + 195.0 * t) as u8;
    let g = (160.0 - 80.0 * t) as u8;
    let b = (180.0 - 160.0 * t) as u8;
    Color32::from_rgb(r, g, b)
}

#[allow(dead_code)]
fn _path_only(_: &Path) {}