nornir 0.4.22

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
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
//! 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::{Deserialize, Serialize};

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

use super::facett_theme::Theme;

/// 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>)>>>>;

/// Remote source for thin mode: the server runs the scan (it owns the cloned
/// repos) and ships back a [`KnowledgeSummary`] over `Viz.Knowledge`.
#[derive(Clone)]
struct RemoteSrc {
    endpoint: String,
    token: String,
    workspace: String,
}

/// The handoff slot a remote background fetch fills once — `Ok(summary)` or a
/// human error string. Mirrors [`ScanSlot`] for the RPC path.
type RemoteSlot = Arc<Mutex<Option<Result<KnowledgeSummary, 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>,
    /// `Some` ⇒ thin mode: scan runs server-side via `Viz.Knowledge`; the local
    /// `workspace_root.join(repo)` scan is skipped (there's no checkout here).
    remote: Option<RemoteSrc>,
    scans: Vec<(String, Result<ScanResult, String>)>,
    /// Remote-mode rendered summary (decoded from the RPC) — `None` until the
    /// first fetch completes. The local path uses `scans`; the remote path uses
    /// this (it has no `ScanResult`s, only the server's rollup).
    remote_summary: Option<KnowledgeSummary>,
    /// In-flight remote fetch slot + its start time (the "fetching Ns" label).
    remote_pending: Option<(RemoteSlot, Instant)>,
    /// Last remote error (surfaced in the panel + `state_json`).
    remote_error: Option<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,
    theme: Theme,
}

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

    /// Thin-mode constructor: the scan runs on the server (it holds the cloned
    /// repos at `<root>/<ws>/git/<member>`), reached over the `Viz.Knowledge`
    /// RPC. `workspace_root`/`repos` are only used for the displayed labels here.
    pub fn new_remote(
        workspace_root: PathBuf,
        repos: Vec<String>,
        endpoint: String,
        token: String,
        workspace: String,
    ) -> Self {
        let mut s = Self::new(workspace_root, repos);
        s.remote = Some(RemoteSrc { endpoint, token, workspace });
        s
    }

    /// Set the facett palette the pane paints with.
    pub fn set_palette(&mut self, t: Theme) {
        self.theme = t;
    }

    /// 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;
        // In thin mode the RPC is workspace-scoped: re-point its header so the
        // server scans the newly selected workspace's checkouts.
        if let Some(r) = self.remote.as_mut() {
            // The viz drives the picker; the workspace name comes from app.rs via
            // `set_workspace_name` below (so the root/repos labels and the RPC
            // header stay in sync). Default to keeping the prior name here.
            let _ = r;
        }
        self.scans.clear();
        self.remote_summary = None;
        self.remote_pending = None;
        self.remote_error = None;
        self.last_scan = None;
        self.pending = None;
        self.rescan_requested = true;
        self.render_dirty = false;
    }

    /// Thin mode only: re-point the `Viz.Knowledge` RPC at a new workspace (the
    /// picker switched). No-op when local. Call this alongside `set_workspace`.
    pub fn set_workspace_name(&mut self, workspace: String) {
        if let Some(r) = self.remote.as_mut() {
            r.workspace = workspace;
        }
    }

    /// Kick off a background fetch of the server-side knowledge scan over the
    /// `Viz.Knowledge` RPC (thin mode). Returns immediately; the UI never blocks.
    fn start_remote_scan(&mut self, r: RemoteSrc) {
        let slot: RemoteSlot = Arc::new(Mutex::new(None));
        let sink = slot.clone();
        trace::emit_in(
            "knowledge.scan",
            &ScanIn {
                workspace_root: format!("remote {} (ws={})", r.endpoint, r.workspace),
                repos: self.repos.clone(),
            },
        );
        std::thread::spawn(move || {
            let res = super::remote::knowledge_summary(&r.endpoint, &r.token, &r.workspace)
                .map_err(|e| format!("{e:#}"));
            *sink.lock().unwrap_or_else(|p| p.into_inner()) = Some(res);
        });
        self.remote_pending = Some((slot, Instant::now()));
    }

    /// 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) {
        // Thin mode: the scan runs on the server (it owns the checkouts).
        if let Some(r) = self.remote.clone() {
            self.start_remote_scan(r);
            return;
        }
        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;
    }

    /// Drain a finished remote `Viz.Knowledge` fetch into `remote_summary` /
    /// `remote_error`. Lock-poison tolerant (the worker thread may have unwound).
    fn drain_remote(&mut self) {
        if let Some((slot, _started)) = &self.remote_pending {
            let done = slot.lock().unwrap_or_else(|p| p.into_inner()).take();
            if let Some(res) = done {
                match res {
                    Ok(summary) => {
                        self.remote_summary = Some(summary);
                        self.remote_error = None;
                    }
                    Err(e) => self.remote_error = Some(e),
                }
                self.last_scan = Some(Instant::now());
                self.remote_pending = None;
                self.render_dirty = true;
            }
        }
    }

    /// The bubbles to render this frame: the server's summary in thin mode, else
    /// the locally-collected ones from `scans`.
    fn current_bubbles(&self) -> Vec<Bubble> {
        match &self.remote_summary {
            Some(s) => s.crates.clone(),
            None => collect_bubbles(&self.scans),
        }
    }

    /// True while either a local scan or a remote fetch is in flight.
    fn is_scanning(&self) -> bool {
        self.pending.is_some() || self.remote_pending.is_some()
    }

    pub fn draw(&mut self, ui: &mut egui::Ui) {
        let theme = self.theme;
        // 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;
            }
        }
        self.drain_remote();
        // 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.is_scanning() {
            self.start_scan();
            self.rescan_requested = false;
        }

        let scanning = self.is_scanning();
        ui.horizontal(|ui| {
            ui.heading("🗺 Knowledge map");
            ui.separator();
            if ui.add_enabled(!scanning, egui::Button::new("↻ rescan")).clicked() {
                self.rescan_requested = true;
            }
            let started = self
                .pending
                .as_ref()
                .map(|(_, t)| *t)
                .or(self.remote_pending.as_ref().map(|(_, t)| *t));
            if let Some(started) = started {
                let where_ = if self.remote.is_some() { "server" } else { "local" };
                ui.label(format!(
                    "⏳ scanning {} repo(s) [{where_}] … {}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()));
            }
        });
        if let Some(err) = &self.remote_error {
            ui.colored_label(Color32::RED, format!("Viz.Knowledge failed: {err}"));
        }
        ui.separator();

        let bubbles = self.current_bubbles();
        // 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, &theme);
    }

    /// 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 = self.current_bubbles();
        // Remote mode renders from the server's summary; local from `scans`.
        let (repos, scanned_repos): (Vec<serde_json::Value>, usize) =
            if let Some(s) = &self.remote_summary {
                let v = s
                    .repos
                    .iter()
                    .map(|r| {
                        if r.ok {
                            serde_json::json!({
                                "repo": r.repo, "ok": true,
                                "symbols": r.symbols, "calls": r.calls,
                                "features": r.features, "git_files": r.git_files,
                            })
                        } else {
                            serde_json::json!({ "repo": r.repo, "ok": false, "error": r.error })
                        }
                    })
                    .collect();
                (v, s.repos.len())
            } else {
                let v = 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();
                (v, self.scans.len())
            };
        serde_json::json!({
            "workspace_root": self.workspace_root.display().to_string(),
            "configured_repos": self.repos,
            "source": if self.remote.is_some() { "remote" } else { "local" },
            "scanning": self.is_scanning(),
            "error": self.remote_error,
            "scanned_repos": scanned_repos,
            "crates": bubbles.len(),
            "rows": bubbles,
            "repo_totals": repos,
            "palette": self.theme.name,
        })
    }

    /// Test hook (thin mode): inject a decoded `Viz.Knowledge` summary directly,
    /// exactly as if the RPC fetch had returned it — so a headless test can
    /// assert `state_json()` renders the server's bubbles without a live server.
    #[doc(hidden)]
    pub fn inject_remote_summary_for_test(&mut self, summary: KnowledgeSummary) {
        self.remote_summary = Some(summary);
        self.remote_error = None;
        self.remote_pending = None;
        self.rescan_requested = false;
        self.last_scan = Some(Instant::now());
    }
}

/// One per-crate bubble — the exact row the Knowledge map renders. Shared
/// between the local scan path and the remote `Viz.Knowledge` decode, so a thin
/// client renders the identical bubbles the embedded scan produces.
#[derive(Serialize, Deserialize, Clone)]
pub struct Bubble {
    pub repo: String,
    pub krate: String,
    pub symbols: usize,
    pub calls: usize,
    pub files: usize,
    pub gates: usize,
    pub heat_30d: i64,
}

/// One per-repo scan summary (mirrors the embedded `state_json` per-repo block) —
/// the server emits these so the remote tab shows the same `repo_totals` rows.
#[derive(Serialize, Deserialize, Clone)]
pub struct RepoScanSummary {
    pub repo: String,
    pub ok: bool,
    #[serde(default)]
    pub symbols: usize,
    #[serde(default)]
    pub calls: usize,
    #[serde(default)]
    pub features: usize,
    #[serde(default)]
    pub git_files: usize,
    #[serde(default)]
    pub error: Option<String>,
}

/// The whole knowledge-map summary a `Viz.Knowledge` response carries: the
/// per-repo scan rollups + the per-crate bubbles. Built server-side by
/// [`scan_summary_json`] from raw [`ScanResult`]s and decoded client-side into a
/// [`RemoteKnowledge`] the tab renders.
#[derive(Serialize, Deserialize, Clone, Default)]
pub struct KnowledgeSummary {
    pub repos: Vec<RepoScanSummary>,
    pub crates: Vec<Bubble>,
}

/// Build the [`KnowledgeSummary`] JSON the `Viz.Knowledge` RPC returns from raw
/// per-repo scans. Same `collect_bubbles` + per-repo rollup the embedded tab
/// computes, so server and embedded render byte-identical maps. Public so
/// `nornir-server` (same crate, `viz` feature) can call it.
pub fn scan_summary_json(scans: &[(String, Result<ScanResult, String>)]) -> serde_json::Value {
    let crates = collect_bubbles(scans);
    let repos: Vec<RepoScanSummary> = scans
        .iter()
        .map(|(repo, scan)| match scan {
            Ok(s) => RepoScanSummary {
                repo: repo.clone(),
                ok: true,
                symbols: s.symbols.symbols.len(),
                calls: s.symbols.calls.len(),
                features: s.symbols.features.len(),
                git_files: s.git.files.len(),
                error: None,
            },
            Err(e) => RepoScanSummary {
                repo: repo.clone(),
                ok: false,
                error: Some(e.clone()),
                ..Default::default()
            },
        })
        .collect();
    serde_json::to_value(KnowledgeSummary { repos, crates }).unwrap_or_default()
}

impl Default for RepoScanSummary {
    fn default() -> Self {
        Self {
            repo: String::new(),
            ok: false,
            symbols: 0,
            calls: 0,
            features: 0,
            git_files: 0,
            error: None,
        }
    }
}

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], theme: &Theme) {
    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, theme.bg);

    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 = theme.heat(heat_norm);

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

    // 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),
        theme.text_dim,
    );
    painter.text(
        Pos2::new(rect.min.x + 4.0, rect.center().y),
        egui::Align2::LEFT_CENTER,
        "↑ calls (log)",
        egui::FontId::proportional(11.0),
        theme.text_dim,
    );
}

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

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

    /// LAW #1: thin-mode Knowledge renders the server's scan summary as REAL rows.
    /// Inject a decoded `Viz.Knowledge` summary (as the RPC fetch would deliver)
    /// and assert `state_json` carries crates>0 + the exact per-crate counts — the
    /// precise inverse of the `crates:0, workspace_root:""` empty-remote bug.
    #[test]
    fn remote_summary_renders_nonempty_rows() {
        let mut st = KnowledgeState::new_remote(
            PathBuf::from("/remote/holger/git"),
            vec!["znippy".into()],
            "http://server:7878".into(),
            "tok".into(),
            "holger".into(),
        );
        // Before any fetch the remote map is empty (no local checkout to scan).
        let before = st.state_json();
        assert_eq!(before["crates"], 0, "no data before the RPC returns");
        assert_eq!(before["source"], "remote");

        // The server replied with one crate (znippy: 120 symbols, 88 calls).
        st.inject_remote_summary_for_test(KnowledgeSummary {
            repos: vec![RepoScanSummary {
                repo: "znippy".into(),
                ok: true,
                symbols: 120,
                calls: 88,
                features: 3,
                git_files: 40,
                error: None,
            }],
            crates: vec![Bubble {
                repo: "znippy".into(),
                krate: "znippy".into(),
                symbols: 120,
                calls: 88,
                files: 40,
                gates: 3,
                heat_30d: 7,
            }],
        });

        let js = st.state_json();
        assert_eq!(js["crates"], 1, "remote map must render the server's crate");
        assert_eq!(js["rows"][0]["krate"], "znippy");
        assert_eq!(js["rows"][0]["symbols"], 120);
        assert_eq!(js["rows"][0]["calls"], 88);
        assert_eq!(js["repo_totals"][0]["repo"], "znippy");
        assert_eq!(js["repo_totals"][0]["git_files"], 40);
        assert_eq!(js["scanned_repos"], 1);
        assert_eq!(js["error"], serde_json::Value::Null);
    }

    /// `scan_summary_json` (the server's builder) → `KnowledgeSummary` (the client
    /// decode) preserves a failed-repo rollup with its error, so the remote tab
    /// can show a per-repo failure (e.g. a missing checkout) the same way local
    /// does — not silently drop it.
    #[test]
    fn scan_summary_carries_repo_errors() {
        let scans: Vec<(String, Result<ScanResult, String>)> =
            vec![("ghost".into(), Err("no such repo dir".into()))];
        let v = scan_summary_json(&scans);
        let summary: KnowledgeSummary = serde_json::from_value(v).unwrap();
        assert_eq!(summary.repos.len(), 1);
        assert!(!summary.repos[0].ok);
        assert_eq!(summary.repos[0].error.as_deref(), Some("no such repo dir"));
        assert!(summary.crates.is_empty(), "a failed repo contributes no bubbles");
    }
}