Skip to main content

fallow_engine/
viz.rs

1//! Typed data contract and builder for `fallow viz`.
2//!
3//! The CLI runs one project analysis (dead code + duplication + complexity)
4//! through [`crate::session::AnalysisSession`] and hands the retained
5//! artifacts to [`build_viz_data`]. The resulting [`VizData`] is embedded as
6//! JSON in the self-contained interactive HTML the `viz` command writes.
7//!
8//! The contract is engine-owned so the graph internals never leak past the
9//! engine boundary: everything the frontend needs is resolved to file
10//! indices, relative paths, and plain counts here.
11
12use std::path::Path;
13
14use rustc_hash::FxHashMap;
15use serde::Serialize;
16
17use fallow_config::{ResolvedConfig, WorkspaceInfo};
18use fallow_types::discover::DiscoveredFile;
19use fallow_types::duplicates::{CloneInstance, DuplicationReport};
20use fallow_types::extract::{FunctionComplexity, ModuleInfo};
21use fallow_types::results::AnalysisResults;
22
23use crate::module_graph::RetainedModuleGraph;
24
25/// A file counts as a complexity hotspot at or above this cyclomatic score.
26const HOTSPOT_CYCLOMATIC_FLOOR: u16 = 10;
27/// Maximum bytes of clone-fragment preview shipped per clone group. The
28/// budget is measured in bytes, not characters; truncation only ever cuts
29/// at a line boundary, so multi-byte source cannot be sliced mid-character.
30const CLONE_PREVIEW_MAX_BYTES: usize = 2000;
31/// Maximum lines of clone-fragment preview shipped per clone group. The
32/// preview grows to its content in the panel (no inner scroll), so this can
33/// be generous; big blocks still truncate, keeping the leading context.
34const CLONE_PREVIEW_MAX_LINES: usize = 32;
35/// Source lines of context included on each side of the duplicated block
36/// in a clone preview. A fixed window is universal: clones are frequently
37/// not functions (interface fields, object literals, type aliases), so no
38/// enclosing-scope detection is attempted.
39const CLONE_PREVIEW_CONTEXT: usize = 4;
40/// Maximum clone groups serialized into the payload. Far above any
41/// legitimate report; a guardrail against multi-MB HTML on monorepos.
42/// Groups keep the detector's report order, so the cap keeps the first N.
43const MAX_CLONE_GROUPS: usize = 500;
44/// Edge flag bit: every import of this edge is type-only.
45const EDGE_FLAG_TYPE_ONLY: u32 = 1;
46
47/// Everything [`build_viz_data`] needs from one project analysis run.
48pub struct VizBuildInput<'a> {
49    /// Dead-code analysis results (unused files/exports, cycles, boundaries).
50    pub results: &'a AnalysisResults,
51    /// Retained module graph for edges, entry points, and export counts.
52    pub graph: &'a RetainedModuleGraph,
53    /// Parsed modules with complexity data, when retained.
54    pub modules: Option<&'a [ModuleInfo]>,
55    /// Discovered source files, in `FileId` order.
56    pub files: &'a [DiscoveredFile],
57    /// Duplication report from the same session.
58    pub duplication: &'a DuplicationReport,
59    /// Discovered monorepo workspaces.
60    pub workspaces: &'a [WorkspaceInfo],
61    /// Resolved config (project root + boundary zones).
62    pub config: &'a ResolvedConfig,
63}
64
65/// Serialized payload embedded in the viz HTML.
66#[derive(Serialize)]
67pub struct VizData {
68    /// Project display name (root directory basename).
69    pub root: String,
70    /// One entry per analyzed source file, indexed by position.
71    pub files: Vec<VizFile>,
72    /// Import edges as `[from, to, flags]` file-index pairs.
73    /// `flags` bit 0 marks an edge whose imports are all type-only.
74    pub edges: Vec<[u32; 3]>,
75    /// Project-wide totals for the header stat boxes.
76    pub summary: VizSummary,
77    /// Discovered workspaces; `VizFile.workspace` indexes into this.
78    pub workspaces: Vec<VizWorkspace>,
79    /// Boundary zones; `VizFile.zone` and violations index into this.
80    pub zones: Vec<VizZone>,
81    /// Circular-dependency cycles as file-index lists.
82    pub cycles: Vec<Vec<u32>>,
83    /// Clone groups; `VizFile.clone_groups` indexes into this.
84    pub clones: Vec<VizCloneGroup>,
85    /// Boundary violations resolved to file indices.
86    pub violations: Vec<VizViolation>,
87}
88
89/// One analyzed source file.
90#[derive(Serialize)]
91pub struct VizFile {
92    /// Root-relative path with forward slashes.
93    pub path: String,
94    /// File size in bytes (treemap area).
95    pub size: u64,
96    /// Dead-code status classification.
97    pub status: VizFileStatus,
98    /// Number of exports declared by the file.
99    pub export_count: u16,
100    /// Number of exports (values + types) reported unused.
101    pub unused_export_count: u16,
102    /// Whether the file is an entry point.
103    pub is_entry: bool,
104    /// Number of files importing this file.
105    pub importer_count: u16,
106    /// Number of files this file imports.
107    pub import_count: u16,
108    /// Index into `VizData.workspaces`, if the file belongs to one.
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub workspace: Option<u16>,
111    /// Index into `VizData.zones`, if the file matches a boundary zone.
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub zone: Option<u16>,
114    /// Names of unused exports (for actionable tooltips).
115    #[serde(skip_serializing_if = "Vec::is_empty")]
116    pub unused_exports: Vec<String>,
117    /// Number of functions parsed in the file.
118    pub fn_count: u16,
119    /// Highest cyclomatic complexity of any function in the file.
120    pub max_cyclomatic: u16,
121    /// Highest cognitive complexity of any function in the file.
122    pub max_cognitive: u16,
123    /// Total React hook calls across the file's functions.
124    pub react_hooks: u16,
125    /// Deepest JSX nesting across the file's functions.
126    pub jsx_depth: u16,
127    /// Every function in the file, sorted hardest-first.
128    #[serde(skip_serializing_if = "Vec::is_empty")]
129    pub functions: Vec<VizFunction>,
130    /// Duplicated lines in this file across all clone groups.
131    pub dup_lines: u32,
132    /// Indices into `VizData.clones` this file participates in.
133    #[serde(skip_serializing_if = "Vec::is_empty")]
134    pub clone_groups: Vec<u32>,
135    /// Whether the file participates in any circular dependency.
136    pub in_cycle: bool,
137}
138
139/// Dead-code status of a file, ordered by severity in the frontend.
140#[derive(Serialize, Clone, Copy, PartialEq, Eq)]
141#[serde(rename_all = "camelCase")]
142pub enum VizFileStatus {
143    /// No findings.
144    Clean,
145    /// Live file with one or more unused exports.
146    HasUnusedExports,
147    /// Entire file is unreachable.
148    Unused,
149    /// Configured or detected entry point.
150    EntryPoint,
151}
152
153/// One function inside a file, with its complexity metrics.
154#[derive(Serialize)]
155pub struct VizFunction {
156    /// Function name, or `<anonymous>`.
157    name: String,
158    /// 1-based start line.
159    line: u32,
160    /// McCabe cyclomatic complexity.
161    cyclomatic: u16,
162    /// SonarSource cognitive complexity.
163    cognitive: u16,
164    /// Body line count.
165    lines: u32,
166    /// React hook calls made directly in the body.
167    hooks: u16,
168    /// Deepest JSX nesting in the body.
169    jsx_depth: u16,
170    /// Props destructured from the first parameter.
171    props: u16,
172}
173
174/// Project-wide totals for the header stat boxes.
175#[derive(Serialize)]
176pub struct VizSummary {
177    /// Total analyzed files.
178    pub total_files: usize,
179    /// Total bytes across analyzed files.
180    pub total_size: u64,
181    /// Total import edges.
182    pub total_edges: usize,
183    /// Fully unused files.
184    pub unused_files: usize,
185    /// Unused exports (values + types).
186    pub unused_exports: usize,
187    /// Unused exported types.
188    pub unused_types: usize,
189    /// Unused dependencies (prod + dev + optional).
190    pub unused_deps: usize,
191    /// Imports that resolve to nothing.
192    pub unresolved_imports: usize,
193    /// Circular dependency cycles.
194    pub circular_deps: usize,
195    /// Clone groups detected.
196    pub clone_groups: usize,
197    /// Total duplicated lines across clone groups.
198    pub duplicated_lines: usize,
199    /// Boundary violations.
200    pub boundary_violations: usize,
201    /// Files at or above the complexity hotspot floor.
202    pub hotspot_files: usize,
203    /// Kept clone groups dropped by the `MAX_CLONE_GROUPS` payload cap.
204    /// Present only when the clone payload was truncated.
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub clone_groups_truncated: Option<u32>,
207}
208
209/// One discovered workspace.
210#[derive(Serialize)]
211pub struct VizWorkspace {
212    /// Package name.
213    name: String,
214    /// Root-relative workspace root.
215    root: String,
216}
217
218/// One configured boundary zone.
219#[derive(Serialize)]
220pub struct VizZone {
221    /// Zone name from the boundaries config.
222    name: String,
223    /// Number of files classified into this zone.
224    files: u32,
225}
226
227/// One clone group resolved to file indices.
228#[derive(Serialize)]
229pub struct VizCloneGroup {
230    /// Lines per duplicated block.
231    lines: usize,
232    /// Tokens per duplicated block.
233    tokens: usize,
234    /// Where the duplicated block appears.
235    instances: Vec<VizCloneInstance>,
236    /// Source preview: a context window around the duplicated block, the
237    /// copied lines flanked by up to `CLONE_PREVIEW_CONTEXT` surrounding
238    /// source lines on each side.
239    preview: String,
240    /// 0-based index, among the lines of `preview`, of the first copied
241    /// line. Lines before it are dimmed context.
242    highlight_start: u32,
243    /// Number of copied lines present in `preview`. The frontend highlights
244    /// `preview` lines `[highlight_start, highlight_start + highlight_lines)`
245    /// and dims the rest.
246    highlight_lines: u32,
247}
248
249/// One location of a duplicated block.
250#[derive(Serialize)]
251pub struct VizCloneInstance {
252    /// File index into `VizData.files`.
253    file: u32,
254    /// 1-based start line.
255    start_line: u32,
256    /// 1-based end line.
257    end_line: u32,
258}
259
260/// One boundary violation resolved to file indices.
261#[derive(Serialize)]
262pub struct VizViolation {
263    /// Importing file index.
264    from: u32,
265    /// Imported file index.
266    to: u32,
267    /// Index into `VizData.zones` for the importing file's zone.
268    from_zone: u16,
269    /// Index into `VizData.zones` for the imported file's zone.
270    to_zone: u16,
271    /// 1-based line of the offending import.
272    line: u32,
273    /// Raw import specifier.
274    specifier: String,
275}
276
277/// Build the viz payload from one project analysis run.
278#[must_use]
279pub fn build_viz_data(input: &VizBuildInput<'_>) -> VizData {
280    let root = &input.config.root;
281    let index = FileIndex::new(input.files);
282    let workspaces = build_workspaces(input.workspaces, root);
283    let (zones, zone_by_file) = classify_zones(input, &index);
284    let (clones, clone_groups_by_file, dup_lines_by_file, clone_groups_truncated) =
285        build_clones(input.duplication, &index, MAX_CLONE_GROUPS);
286    let cycles = build_cycles(input.results, &index);
287    let violations = build_violations(input.results, &zones, &index);
288
289    let files = build_files(
290        input,
291        &index,
292        &FilePropertyMaps {
293            zone_by_file: &zone_by_file,
294            clone_groups_by_file: &clone_groups_by_file,
295            dup_lines_by_file: &dup_lines_by_file,
296            cycles: &cycles,
297        },
298    );
299
300    let summary = build_summary(
301        input,
302        &files,
303        &clones,
304        &cycles,
305        &violations,
306        clone_groups_truncated,
307    );
308
309    VizData {
310        root: display_root(root),
311        files,
312        edges: build_edges(input.graph, &index),
313        summary,
314        workspaces,
315        zones,
316        cycles,
317        clones,
318        violations,
319    }
320}
321
322/// Maps absolute paths to dense viz file indices in `FileId` order.
323struct FileIndex<'a> {
324    ordered: Vec<&'a DiscoveredFile>,
325    by_path: FxHashMap<&'a Path, u32>,
326    by_file_id: FxHashMap<u32, u32>,
327}
328
329impl<'a> FileIndex<'a> {
330    fn new(files: &'a [DiscoveredFile]) -> Self {
331        let mut ordered: Vec<&DiscoveredFile> = files.iter().collect();
332        ordered.sort_by_key(|f| f.id.0);
333        let mut by_path = FxHashMap::default();
334        let mut by_file_id = FxHashMap::default();
335        for (i, f) in ordered.iter().enumerate() {
336            let idx = clamp_u32(i);
337            by_path.insert(f.path.as_path(), idx);
338            by_file_id.insert(f.id.0, idx);
339        }
340        Self {
341            ordered,
342            by_path,
343            by_file_id,
344        }
345    }
346
347    fn index_of_path(&self, path: &Path) -> Option<u32> {
348        self.by_path.get(path).copied()
349    }
350
351    fn index_of_file_id(&self, file_id: u32) -> Option<u32> {
352        self.by_file_id.get(&file_id).copied()
353    }
354}
355
356/// Per-file lookup maps threaded into [`build_files`].
357struct FilePropertyMaps<'a> {
358    zone_by_file: &'a FxHashMap<u32, u16>,
359    clone_groups_by_file: &'a FxHashMap<u32, Vec<u32>>,
360    dup_lines_by_file: &'a FxHashMap<u32, u32>,
361    cycles: &'a [Vec<u32>],
362}
363
364fn display_root(root: &Path) -> String {
365    root.file_name().map_or_else(
366        || root.to_string_lossy().into_owned(),
367        |n| n.to_string_lossy().into_owned(),
368    )
369}
370
371fn relative_path(path: &Path, root: &Path) -> String {
372    path.strip_prefix(root)
373        .unwrap_or(path)
374        .to_string_lossy()
375        .replace('\\', "/")
376}
377
378fn build_workspaces(workspaces: &[WorkspaceInfo], root: &Path) -> Vec<VizWorkspace> {
379    workspaces
380        .iter()
381        .map(|ws| VizWorkspace {
382            name: ws.name.clone(),
383            root: relative_path(&ws.root, root),
384        })
385        .collect()
386}
387
388fn workspace_index_for(path: &Path, workspaces: &[WorkspaceInfo]) -> Option<u16> {
389    let mut best: Option<(usize, usize)> = None;
390    for (i, ws) in workspaces.iter().enumerate() {
391        if path.starts_with(&ws.root) {
392            let depth = ws.root.components().count();
393            if best.is_none_or(|(_, d)| depth > d) {
394                best = Some((i, depth));
395            }
396        }
397    }
398    best.map(|(i, _)| clamp_u16(i))
399}
400
401fn classify_zones(
402    input: &VizBuildInput<'_>,
403    index: &FileIndex<'_>,
404) -> (Vec<VizZone>, FxHashMap<u32, u16>) {
405    let boundaries = &input.config.boundaries;
406    let mut zones: Vec<VizZone> = boundaries
407        .zones
408        .iter()
409        .map(|z| VizZone {
410            name: z.name.clone(),
411            files: 0,
412        })
413        .collect();
414    let name_to_index: FxHashMap<&str, u16> = boundaries
415        .zones
416        .iter()
417        .enumerate()
418        .map(|(i, z)| (z.name.as_str(), clamp_u16(i)))
419        .collect();
420
421    let mut zone_by_file = FxHashMap::default();
422    if zones.is_empty() {
423        return (zones, zone_by_file);
424    }
425
426    for (i, file) in index.ordered.iter().enumerate() {
427        let rel = relative_path(&file.path, &input.config.root);
428        if let Some(zone_name) = boundaries.classify_zone(&rel)
429            && let Some(&zone_idx) = name_to_index.get(zone_name)
430        {
431            zone_by_file.insert(clamp_u32(i), zone_idx);
432            zones[zone_idx as usize].files += 1;
433        }
434    }
435
436    (zones, zone_by_file)
437}
438
439/// Clone payload maps: kept groups, per-file group ids, per-file duplicated
440/// lines, and how many kept-groups the payload cap dropped.
441type CloneMaps = (
442    Vec<VizCloneGroup>,
443    FxHashMap<u32, Vec<u32>>,
444    FxHashMap<u32, u32>,
445    u32,
446);
447
448fn build_clones(
449    duplication: &DuplicationReport,
450    index: &FileIndex<'_>,
451    max_groups: usize,
452) -> CloneMaps {
453    let mut clones = Vec::new();
454    let mut groups_by_file: FxHashMap<u32, Vec<u32>> = FxHashMap::default();
455    let mut dup_lines_by_file: FxHashMap<u32, u32> = FxHashMap::default();
456    let mut truncated: usize = 0;
457
458    for group in &duplication.clone_groups {
459        let instances: Vec<VizCloneInstance> = group
460            .instances
461            .iter()
462            .filter_map(|inst| {
463                index
464                    .index_of_path(&inst.file)
465                    .map(|file| VizCloneInstance {
466                        file,
467                        start_line: clamp_u32(inst.start_line),
468                        end_line: clamp_u32(inst.end_line),
469                    })
470            })
471            .collect();
472        if instances.len() < 2 {
473            continue;
474        }
475        if clones.len() >= max_groups {
476            truncated += 1;
477            continue;
478        }
479
480        let group_idx = clamp_u32(clones.len());
481        for inst in &instances {
482            let entry = groups_by_file.entry(inst.file).or_default();
483            if entry.last() != Some(&group_idx) {
484                entry.push(group_idx);
485            }
486            *dup_lines_by_file.entry(inst.file).or_default() +=
487                inst.end_line.saturating_sub(inst.start_line) + 1;
488        }
489
490        let (preview, highlight_start, highlight_lines) = group
491            .instances
492            .first()
493            .map(build_clone_preview)
494            .unwrap_or_default();
495
496        clones.push(VizCloneGroup {
497            lines: group.line_count,
498            tokens: group.token_count,
499            instances,
500            preview,
501            highlight_start,
502            highlight_lines,
503        });
504    }
505
506    (
507        clones,
508        groups_by_file,
509        dup_lines_by_file,
510        clamp_u32(truncated),
511    )
512}
513
514fn truncate_preview(fragment: &str) -> String {
515    let mut out = String::new();
516    for (i, line) in fragment.lines().enumerate() {
517        if i >= CLONE_PREVIEW_MAX_LINES || out.len() + line.len() > CLONE_PREVIEW_MAX_BYTES {
518            out.push('\u{2026}');
519            break;
520        }
521        if i > 0 {
522            out.push('\n');
523        }
524        out.push_str(line);
525    }
526    out
527}
528
529/// Build the representative clone preview: a context window around the
530/// duplicated block, with the highlight range located within it. Returns
531/// `(preview, highlight_start, highlight_lines)` where `highlight_start`
532/// is the 0-based index of the first copied line among the preview lines
533/// and `highlight_lines` is the copied line count present in `preview`.
534///
535/// Falls back to the bare fragment with the whole block highlighted on
536/// any read failure, empty source, or an out-of-range line span. Never
537/// panics.
538fn build_clone_preview(inst: &CloneInstance) -> (String, u32, u32) {
539    let Ok(source) = std::fs::read_to_string(&inst.file) else {
540        return fragment_fallback(&inst.fragment);
541    };
542    let lines: Vec<&str> = source.lines().collect();
543    let total = lines.len();
544    if total == 0 || inst.start_line == 0 || inst.start_line > total {
545        return fragment_fallback(&inst.fragment);
546    }
547
548    // Block bounds as a 0-based `[block_start, block_end)` range, clamped
549    // to the file and guaranteed to hold at least one line.
550    let block_start = inst.start_line - 1;
551    let block_end = inst.end_line.min(total).max(inst.start_line);
552    let mut block_lines = block_end - block_start;
553    let mut before = block_start.min(CLONE_PREVIEW_CONTEXT);
554    let mut after = (total - block_end).min(CLONE_PREVIEW_CONTEXT);
555
556    // Line cap: when the block plus its context fits, trim context
557    // symmetrically to fit. When the block alone fills the cap, keep the
558    // leading context (so the highlight always reads against some dimmed
559    // lines) and truncate the block's tail, always keeping >= 1 block line.
560    if before + block_lines + after > CLONE_PREVIEW_MAX_LINES {
561        if before + block_lines >= CLONE_PREVIEW_MAX_LINES {
562            after = 0;
563            block_lines = CLONE_PREVIEW_MAX_LINES.saturating_sub(before).max(1);
564        } else {
565            trim_context(
566                &mut before,
567                &mut after,
568                CLONE_PREVIEW_MAX_LINES - block_lines,
569            );
570        }
571    }
572
573    enforce_byte_cap(
574        &lines,
575        block_start,
576        &mut before,
577        &mut after,
578        &mut block_lines,
579    );
580
581    let win_start = block_start - before;
582    let win_end = win_start + before + block_lines + after;
583    let preview = lines[win_start..win_end].join("\n");
584    (preview, clamp_u32(before), clamp_u32(block_lines))
585}
586
587/// Fallback preview: the bare fragment, capped, with the whole block
588/// highlighted (nothing dimmed).
589fn fragment_fallback(fragment: &str) -> (String, u32, u32) {
590    let preview = truncate_preview(fragment);
591    let highlight_lines = if preview.is_empty() {
592        0
593    } else {
594        preview.lines().count()
595    };
596    (preview, 0, clamp_u32(highlight_lines))
597}
598
599/// Reduce `before`/`after` so their sum fits `budget`, dropping from the
600/// larger side first (ties favor keeping `after`) so the two flanks stay
601/// balanced. Deterministic.
602fn trim_context(before: &mut usize, after: &mut usize, budget: usize) {
603    while *before + *after > budget {
604        if *before >= *after {
605            *before -= 1;
606        } else {
607            *after -= 1;
608        }
609    }
610}
611
612/// Trim the preview window to `CLONE_PREVIEW_MAX_BYTES`, dropping context
613/// lines (larger side first) before ever cutting into the highlighted
614/// block. If the block alone still overflows, its tail lines are dropped,
615/// but at least one line is always kept.
616fn enforce_byte_cap(
617    lines: &[&str],
618    block_start: usize,
619    before: &mut usize,
620    after: &mut usize,
621    block_lines: &mut usize,
622) {
623    let window_bytes = |before: usize, after: usize, block_lines: usize| -> usize {
624        let start = block_start - before;
625        let end = start + before + block_lines + after;
626        let separators = (end - start).saturating_sub(1);
627        lines[start..end].iter().map(|l| l.len()).sum::<usize>() + separators
628    };
629    while window_bytes(*before, *after, *block_lines) > CLONE_PREVIEW_MAX_BYTES {
630        if *before + *after > 0 {
631            if *before >= *after {
632                *before -= 1;
633            } else {
634                *after -= 1;
635            }
636        } else if *block_lines > 1 {
637            *block_lines -= 1;
638        } else {
639            break;
640        }
641    }
642}
643
644fn build_cycles(results: &AnalysisResults, index: &FileIndex<'_>) -> Vec<Vec<u32>> {
645    results
646        .circular_dependencies
647        .iter()
648        .filter_map(|cd| {
649            let ids: Vec<u32> = cd
650                .cycle
651                .files
652                .iter()
653                .filter_map(|p| index.index_of_path(p))
654                .collect();
655            (ids.len() == cd.cycle.files.len()).then_some(ids)
656        })
657        .collect()
658}
659
660fn build_violations(
661    results: &AnalysisResults,
662    zones: &[VizZone],
663    index: &FileIndex<'_>,
664) -> Vec<VizViolation> {
665    let name_to_index: FxHashMap<&str, u16> = zones
666        .iter()
667        .enumerate()
668        .map(|(i, z)| (z.name.as_str(), clamp_u16(i)))
669        .collect();
670
671    results
672        .boundary_violations
673        .iter()
674        .filter_map(|finding| {
675            let v = &finding.violation;
676            let from = index.index_of_path(&v.from_path)?;
677            let to = index.index_of_path(&v.to_path)?;
678            let from_zone = *name_to_index.get(v.from_zone.as_str())?;
679            let to_zone = *name_to_index.get(v.to_zone.as_str())?;
680            Some(VizViolation {
681                from,
682                to,
683                from_zone,
684                to_zone,
685                line: v.line,
686                specifier: v.import_specifier.clone(),
687            })
688        })
689        .collect()
690}
691
692fn build_edges(graph: &RetainedModuleGraph, index: &FileIndex<'_>) -> Vec<[u32; 3]> {
693    let graph = graph.as_graph();
694    let mut edges = Vec::with_capacity(graph.edge_count());
695    for node in &graph.modules {
696        let Some(source) = index.index_of_file_id(node.file_id.0) else {
697            continue;
698        };
699        for (target_id, all_type_only, _span) in graph.outgoing_edge_summaries(node.file_id) {
700            let Some(target) = index.index_of_file_id(target_id.0) else {
701                continue;
702            };
703            let flags = if all_type_only {
704                EDGE_FLAG_TYPE_ONLY
705            } else {
706                0
707            };
708            edges.push([source, target, flags]);
709        }
710    }
711    edges
712}
713
714/// Complexity aggregates for one file, folded from its parsed functions.
715#[derive(Default)]
716struct ComplexityRollup {
717    fn_count: u16,
718    max_cyclomatic: u16,
719    max_cognitive: u16,
720    react_hooks: u16,
721    jsx_depth: u16,
722    functions: Vec<VizFunction>,
723}
724
725fn rollup_complexity(functions: &[FunctionComplexity]) -> ComplexityRollup {
726    let mut rollup = ComplexityRollup {
727        fn_count: clamp_u16(functions.len()),
728        ..ComplexityRollup::default()
729    };
730    for f in functions {
731        rollup.max_cyclomatic = rollup.max_cyclomatic.max(f.cyclomatic);
732        rollup.max_cognitive = rollup.max_cognitive.max(f.cognitive);
733        rollup.react_hooks = rollup.react_hooks.saturating_add(f.react_hook_count);
734        rollup.jsx_depth = rollup.jsx_depth.max(f.react_jsx_max_depth);
735    }
736
737    // Named functions only, hardest-first: the panel lists these and folds the
738    // (often many) anonymous arrow/callback functions into a single count via
739    // `fn_count`. Placeholder names for unnamed functions are `<arrow>` /
740    // `<anonymous>`, so a leading `<` marks the ones to fold away.
741    let mut named: Vec<&FunctionComplexity> = functions
742        .iter()
743        .filter(|f| !f.name.starts_with('<'))
744        .collect();
745    named.sort_by(|a, b| {
746        b.cyclomatic
747            .cmp(&a.cyclomatic)
748            .then(b.cognitive.cmp(&a.cognitive))
749    });
750    rollup.functions = named
751        .into_iter()
752        .map(|f| VizFunction {
753            name: f.name.clone(),
754            line: f.line,
755            cyclomatic: f.cyclomatic,
756            cognitive: f.cognitive,
757            lines: f.line_count,
758            hooks: f.react_hook_count,
759            jsx_depth: f.react_jsx_max_depth,
760            props: f.react_prop_count,
761        })
762        .collect();
763    rollup
764}
765
766fn build_files(
767    input: &VizBuildInput<'_>,
768    index: &FileIndex<'_>,
769    maps: &FilePropertyMaps<'_>,
770) -> Vec<VizFile> {
771    let graph = input.graph.as_graph();
772    let unused_file_paths: rustc_hash::FxHashSet<&Path> = input
773        .results
774        .unused_files
775        .iter()
776        .map(|f| f.file.path.as_path())
777        .collect();
778
779    let mut unused_exports_by_file: FxHashMap<&Path, Vec<String>> = FxHashMap::default();
780    for export in &input.results.unused_exports {
781        unused_exports_by_file
782            .entry(export.export.path.as_path())
783            .or_default()
784            .push(export.export.export_name.clone());
785    }
786    for export in &input.results.unused_types {
787        unused_exports_by_file
788            .entry(export.export.path.as_path())
789            .or_default()
790            .push(export.export.export_name.clone());
791    }
792
793    let mut complexity_by_file_id: FxHashMap<u32, ComplexityRollup> = FxHashMap::default();
794    if let Some(modules) = input.modules {
795        for module in modules {
796            if !module.complexity.is_empty() {
797                complexity_by_file_id
798                    .insert(module.file_id.0, rollup_complexity(&module.complexity));
799            }
800        }
801    }
802
803    let mut in_cycle = vec![false; index.ordered.len()];
804    for cycle in maps.cycles {
805        for &idx in cycle {
806            if let Some(slot) = in_cycle.get_mut(idx as usize) {
807                *slot = true;
808            }
809        }
810    }
811
812    index
813        .ordered
814        .iter()
815        .enumerate()
816        .map(|(i, file)| {
817            let viz_idx = clamp_u32(i);
818            let node_idx = file.id.0 as usize;
819            let node = graph.modules.get(node_idx);
820            let is_entry = node.is_some_and(|n| n.is_entry_point());
821            let export_count = node.map_or(0, |n| clamp_u16(n.exports.len()));
822            let import_count = clamp_u16(graph.edges_for(file.id).len());
823            let importer_count = clamp_u16(input.graph.direct_importer_count(file.id));
824
825            let unused_export_names = unused_exports_by_file
826                .remove(file.path.as_path())
827                .unwrap_or_default();
828            let unused_export_count = clamp_u16(unused_export_names.len());
829
830            let status = if unused_file_paths.contains(file.path.as_path()) {
831                VizFileStatus::Unused
832            } else if unused_export_count > 0 {
833                VizFileStatus::HasUnusedExports
834            } else if is_entry {
835                VizFileStatus::EntryPoint
836            } else {
837                VizFileStatus::Clean
838            };
839
840            let complexity = complexity_by_file_id.remove(&file.id.0).unwrap_or_default();
841
842            VizFile {
843                path: relative_path(&file.path, &input.config.root),
844                size: file.size_bytes,
845                status,
846                export_count,
847                unused_export_count,
848                is_entry,
849                importer_count,
850                import_count,
851                workspace: workspace_index_for(&file.path, input.workspaces),
852                zone: maps.zone_by_file.get(&viz_idx).copied(),
853                unused_exports: unused_export_names,
854                fn_count: complexity.fn_count,
855                max_cyclomatic: complexity.max_cyclomatic,
856                max_cognitive: complexity.max_cognitive,
857                react_hooks: complexity.react_hooks,
858                jsx_depth: complexity.jsx_depth,
859                functions: complexity.functions,
860                dup_lines: maps.dup_lines_by_file.get(&viz_idx).copied().unwrap_or(0),
861                clone_groups: maps
862                    .clone_groups_by_file
863                    .get(&viz_idx)
864                    .cloned()
865                    .unwrap_or_default(),
866                in_cycle: in_cycle[i],
867            }
868        })
869        .collect()
870}
871
872fn build_summary(
873    input: &VizBuildInput<'_>,
874    files: &[VizFile],
875    clones: &[VizCloneGroup],
876    cycles: &[Vec<u32>],
877    violations: &[VizViolation],
878    clone_groups_truncated: u32,
879) -> VizSummary {
880    let results = input.results;
881    VizSummary {
882        total_files: files.len(),
883        total_size: files.iter().map(|f| f.size).sum(),
884        total_edges: input.graph.edge_count(),
885        unused_files: results.unused_files.len(),
886        unused_exports: results.unused_exports.len() + results.unused_types.len(),
887        unused_types: results.unused_types.len(),
888        unused_deps: results.unused_dependencies.len()
889            + results.unused_dev_dependencies.len()
890            + results.unused_optional_dependencies.len(),
891        unresolved_imports: results.unresolved_imports.len(),
892        circular_deps: cycles.len(),
893        clone_groups: clones.len(),
894        duplicated_lines: clones.iter().map(|c| c.lines * c.instances.len()).sum(),
895        boundary_violations: violations.len(),
896        hotspot_files: files
897            .iter()
898            .filter(|f| f.max_cyclomatic >= HOTSPOT_CYCLOMATIC_FLOOR)
899            .count(),
900        clone_groups_truncated: (clone_groups_truncated > 0).then_some(clone_groups_truncated),
901    }
902}
903
904fn clamp_u16(value: usize) -> u16 {
905    u16::try_from(value).unwrap_or(u16::MAX)
906}
907
908fn clamp_u32(value: usize) -> u32 {
909    u32::try_from(value).unwrap_or(u32::MAX)
910}
911
912#[cfg(test)]
913mod tests {
914    use std::path::PathBuf;
915
916    use fallow_config::{BoundaryConfig, BoundaryZone, FallowConfig};
917    use fallow_graph::graph::ModuleGraph;
918    use fallow_graph::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
919    use fallow_types::duplicates::{CloneGroup, CloneInstance};
920    use fallow_types::extract::{ImportInfo, ImportedName};
921    use fallow_types::output_dead_code::{BoundaryViolationFinding, CircularDependencyFinding};
922    use fallow_types::output_format::OutputFormat;
923    use fallow_types::results::{BoundaryViolation, CircularDependency};
924
925    use super::*;
926    use crate::discover::{EntryPoint, EntryPointSource, FileId};
927
928    /// Owned fixture parts backing one [`VizBuildInput`].
929    struct Fixture {
930        config: ResolvedConfig,
931        files: Vec<DiscoveredFile>,
932        results: AnalysisResults,
933        graph: crate::module_graph::RetainedModuleGraph,
934        duplication: DuplicationReport,
935        workspaces: Vec<WorkspaceInfo>,
936    }
937
938    impl Fixture {
939        fn input(&self) -> VizBuildInput<'_> {
940            VizBuildInput {
941                results: &self.results,
942                graph: &self.graph,
943                modules: None,
944                files: &self.files,
945                duplication: &self.duplication,
946                workspaces: &self.workspaces,
947                config: &self.config,
948            }
949        }
950    }
951
952    fn project_root() -> PathBuf {
953        PathBuf::from("/viz-project")
954    }
955
956    fn discovered(id: u32, path: PathBuf, size_bytes: u64) -> DiscoveredFile {
957        DiscoveredFile {
958            id: FileId(id),
959            path,
960            size_bytes,
961        }
962    }
963
964    fn import_of(target: FileId, specifier: &str) -> ResolvedImport {
965        ResolvedImport {
966            info: ImportInfo {
967                source: specifier.to_owned(),
968                imported_name: ImportedName::Named("value".to_owned()),
969                local_name: "value".to_owned(),
970                is_type_only: false,
971                from_style: false,
972                span: oxc_span::Span::new(0, 0),
973                source_span: oxc_span::Span::new(0, 0),
974            },
975            target: ResolveResult::InternalModule(target),
976        }
977    }
978
979    fn zone(name: &str, pattern: &str) -> BoundaryZone {
980        BoundaryZone {
981            name: name.to_owned(),
982            patterns: vec![pattern.to_owned()],
983            auto_discover: Vec::new(),
984            root: None,
985        }
986    }
987
988    fn resolved_config(root: &Path) -> ResolvedConfig {
989        let config = FallowConfig {
990            boundaries: BoundaryConfig {
991                zones: vec![zone("app", "src/**"), zone("shared", "lib/**")],
992                ..BoundaryConfig::default()
993            },
994            ..FallowConfig::default()
995        };
996        config.resolve(root.to_path_buf(), OutputFormat::Json, 1, false, true, None)
997    }
998
999    fn cycle_finding(files: Vec<PathBuf>) -> CircularDependencyFinding {
1000        let length = files.len();
1001        CircularDependencyFinding::with_actions(CircularDependency {
1002            files,
1003            length,
1004            line: 1,
1005            col: 0,
1006            edges: Vec::new(),
1007            is_cross_package: false,
1008        })
1009    }
1010
1011    fn violation_finding(from_path: PathBuf, to_path: PathBuf) -> BoundaryViolationFinding {
1012        BoundaryViolationFinding::with_actions(BoundaryViolation {
1013            from_path,
1014            to_path,
1015            from_zone: "app".to_owned(),
1016            to_zone: "shared".to_owned(),
1017            import_specifier: "../lib/c".to_owned(),
1018            line: 2,
1019            col: 0,
1020        })
1021    }
1022
1023    fn clone_instance(file: PathBuf, start_line: usize, end_line: usize) -> CloneInstance {
1024        CloneInstance {
1025            file,
1026            start_line,
1027            end_line,
1028            start_col: 0,
1029            end_col: 0,
1030            fragment: "const shared = 1;\nconst repeated = 2;\nconst block = 3;".to_owned(),
1031        }
1032    }
1033
1034    fn clone_group(instances: Vec<CloneInstance>) -> CloneGroup {
1035        CloneGroup {
1036            instances,
1037            token_count: 12,
1038            line_count: 3,
1039        }
1040    }
1041
1042    /// Synthetic project: 3 files, one import edge a to b, one resolvable
1043    /// cycle (a, b) plus one unresolvable, one clone group over (a, c) plus a
1044    /// dropped and a same-file group, one resolvable boundary violation a to
1045    /// c plus one unresolvable, two zones, one workspace over `lib/`.
1046    fn fixture_with(extra_graph_file: bool) -> Fixture {
1047        let root = project_root();
1048        let a = root.join("src/a.ts");
1049        let b = root.join("src/b.ts");
1050        let c = root.join("lib/c.ts");
1051        let missing = root.join("src/missing.ts");
1052
1053        let files = vec![
1054            discovered(0, a.clone(), 100),
1055            discovered(1, b.clone(), 50),
1056            discovered(2, c.clone(), 25),
1057        ];
1058
1059        let mut graph_files = files.clone();
1060        let mut imports = vec![import_of(FileId(1), "./b")];
1061        if extra_graph_file {
1062            graph_files.push(discovered(3, root.join("src/d.ts"), 10));
1063            imports.push(import_of(FileId(3), "./d"));
1064        }
1065        let resolved = vec![ResolvedModule {
1066            file_id: FileId(0),
1067            path: a.clone(),
1068            resolved_imports: imports,
1069            ..ResolvedModule::default()
1070        }];
1071        let entry_points = vec![EntryPoint {
1072            path: a.clone(),
1073            source: EntryPointSource::PackageJsonMain,
1074        }];
1075        let graph = crate::module_graph::RetainedModuleGraph::from(ModuleGraph::build(
1076            &resolved,
1077            &entry_points,
1078            &graph_files,
1079        ));
1080
1081        let results = AnalysisResults {
1082            circular_dependencies: vec![
1083                cycle_finding(vec![a.clone(), b]),
1084                cycle_finding(vec![a.clone(), missing.clone()]),
1085            ],
1086            boundary_violations: vec![
1087                violation_finding(a.clone(), c.clone()),
1088                violation_finding(a.clone(), missing),
1089            ],
1090            ..AnalysisResults::default()
1091        };
1092
1093        let duplication = DuplicationReport {
1094            clone_groups: vec![
1095                clone_group(vec![
1096                    clone_instance(a.clone(), 1, 3),
1097                    clone_instance(c, 10, 12),
1098                ]),
1099                clone_group(vec![
1100                    clone_instance(a.clone(), 20, 22),
1101                    clone_instance(root.join("outside.ts"), 1, 3),
1102                ]),
1103                clone_group(vec![
1104                    clone_instance(a.clone(), 30, 32),
1105                    clone_instance(a, 40, 42),
1106                ]),
1107            ],
1108            ..DuplicationReport::default()
1109        };
1110
1111        let workspaces = vec![WorkspaceInfo {
1112            root: root.join("lib"),
1113            name: "shared-lib".to_owned(),
1114            is_internal_dependency: false,
1115        }];
1116
1117        Fixture {
1118            config: resolved_config(&root),
1119            files,
1120            results,
1121            graph,
1122            duplication,
1123            workspaces,
1124        }
1125    }
1126
1127    fn fixture() -> Fixture {
1128        fixture_with(false)
1129    }
1130
1131    #[test]
1132    fn files_and_edges_use_stable_indices() {
1133        let fx = fixture();
1134        let data = build_viz_data(&fx.input());
1135
1136        let paths: Vec<&str> = data.files.iter().map(|f| f.path.as_str()).collect();
1137        assert_eq!(paths, ["src/a.ts", "src/b.ts", "lib/c.ts"]);
1138        assert_eq!(data.edges, vec![[0, 1, 0]]);
1139        assert!(data.files[0].is_entry);
1140        assert!(matches!(data.files[0].status, VizFileStatus::EntryPoint));
1141        assert!(matches!(data.files[1].status, VizFileStatus::Clean));
1142        assert_eq!(data.files[0].import_count, 1);
1143        assert_eq!(data.files[1].importer_count, 1);
1144        assert_eq!(data.files[0].workspace, None);
1145        assert_eq!(data.files[2].workspace, Some(0));
1146        assert_eq!(data.workspaces.len(), 1);
1147        assert_eq!(data.workspaces[0].root, "lib");
1148    }
1149
1150    #[test]
1151    fn edges_to_files_missing_from_input_are_dropped() {
1152        let fx = fixture_with(true);
1153        let data = build_viz_data(&fx.input());
1154
1155        // The graph carries a to b AND a to d, but d is not in `input.files`,
1156        // so build_edges drops the second edge instead of emitting a
1157        // dangling index.
1158        assert_eq!(fx.graph.edge_count(), 2);
1159        assert_eq!(data.edges, vec![[0, 1, 0]]);
1160    }
1161
1162    #[test]
1163    fn clone_groups_drop_unresolvable_and_dedup_per_file() {
1164        let fx = fixture();
1165        let data = build_viz_data(&fx.input());
1166
1167        // The group whose second instance lives outside `input.files` keeps
1168        // only 1 resolvable instance and is dropped entirely.
1169        assert_eq!(data.clones.len(), 2);
1170        assert_eq!(data.clones[0].instances.len(), 2);
1171        assert_eq!(data.clones[0].instances[0].file, 0);
1172        assert_eq!(data.clones[0].instances[1].file, 2);
1173        assert_eq!(data.clones[0].lines, 3);
1174        assert_eq!(data.clones[0].tokens, 12);
1175        // Two same-file instances in one group dedup to a single group id.
1176        assert_eq!(data.files[0].clone_groups, vec![0, 1]);
1177        assert_eq!(data.files[2].clone_groups, vec![0]);
1178        // dup_lines sums (end minus start plus 1) per resolvable instance.
1179        assert_eq!(data.files[0].dup_lines, 9);
1180        assert_eq!(data.files[2].dup_lines, 3);
1181        assert_eq!(data.files[1].dup_lines, 0);
1182    }
1183
1184    #[test]
1185    fn truncate_preview_caps_lines_and_bytes() {
1186        // Line cap: more lines than the cap in, CLONE_PREVIEW_MAX_LINES out
1187        // plus the ellipsis appended directly after the last kept line.
1188        let last_kept = CLONE_PREVIEW_MAX_LINES - 1;
1189        let many_lines = (0..CLONE_PREVIEW_MAX_LINES + 5)
1190            .map(|i| format!("line {i}"))
1191            .collect::<Vec<_>>();
1192        let out = truncate_preview(&many_lines.join("\n"));
1193        assert_eq!(out.matches('\n').count(), CLONE_PREVIEW_MAX_LINES - 1);
1194        assert!(out.contains(&format!("line {last_kept}")));
1195        assert!(!out.contains(&format!("line {CLONE_PREVIEW_MAX_LINES}")));
1196        assert!(out.ends_with('\u{2026}'));
1197
1198        // Byte budget: the second big line would exceed CLONE_PREVIEW_MAX_BYTES,
1199        // so output stops after the first line.
1200        let big = CLONE_PREVIEW_MAX_BYTES * 3 / 4;
1201        let two_long_lines = format!("{}\n{}", "a".repeat(big), "b".repeat(big));
1202        let out = truncate_preview(&two_long_lines);
1203        assert_eq!(out, format!("{}\u{2026}", "a".repeat(big)));
1204
1205        // Multi-byte content over budget truncates at a line boundary and
1206        // never slices inside a character (4 bytes per emoji, well over budget).
1207        let emoji_line = "\u{1f389}".repeat(CLONE_PREVIEW_MAX_BYTES);
1208        let out = truncate_preview(&emoji_line);
1209        assert_eq!(out, "\u{2026}");
1210    }
1211
1212    #[test]
1213    fn clone_preview_windows_context_around_the_block() {
1214        use std::io::Write as _;
1215
1216        // 20 numbered source lines; the copied block covers lines 8..=11.
1217        let mut file = tempfile::NamedTempFile::new().expect("temp file");
1218        let body = (1..=20)
1219            .map(|i| format!("line {i}"))
1220            .collect::<Vec<_>>()
1221            .join("\n");
1222        file.write_all(body.as_bytes()).expect("write source");
1223        let inst = clone_instance(file.path().to_path_buf(), 8, 11);
1224
1225        let (preview, highlight_start, highlight_lines) = build_clone_preview(&inst);
1226        let preview_lines: Vec<&str> = preview.lines().collect();
1227
1228        // Block (4 lines) plus 4 lines of context each side fits the cap, so
1229        // the full window is kept: 4 dimmed + 4 highlighted + 4 dimmed.
1230        assert_eq!(preview_lines.len(), 12);
1231        assert_eq!(highlight_start, 4);
1232        assert_eq!(highlight_lines, 4);
1233        assert_eq!(preview_lines.first(), Some(&"line 4"));
1234        let start = highlight_start as usize;
1235        let end = start + highlight_lines as usize;
1236        assert_eq!(
1237            &preview_lines[start..end],
1238            ["line 8", "line 9", "line 10", "line 11"],
1239        );
1240        // The line directly above the block is dimmed context, not copied.
1241        assert_eq!(preview_lines[start - 1], "line 7");
1242    }
1243
1244    #[test]
1245    fn clone_preview_keeps_leading_context_when_the_block_fills_the_cap() {
1246        use std::io::Write as _;
1247
1248        // A block far larger than the cap. The old logic zeroed the context
1249        // and highlighted the whole (truncated) window; the fix keeps the
1250        // leading context dimmed so the highlight still reads against it.
1251        let mut file = tempfile::NamedTempFile::new().expect("temp file");
1252        let body = (1..=200)
1253            .map(|i| format!("line {i}"))
1254            .collect::<Vec<_>>()
1255            .join("\n");
1256        file.write_all(body.as_bytes()).expect("write source");
1257        let inst = clone_instance(file.path().to_path_buf(), 50, 150);
1258
1259        let (preview, highlight_start, highlight_lines) = build_clone_preview(&inst);
1260        let preview_lines: Vec<&str> = preview.lines().collect();
1261
1262        assert_eq!(highlight_start, CLONE_PREVIEW_CONTEXT as u32);
1263        assert!(
1264            highlight_start > 0,
1265            "leading context must survive a huge block"
1266        );
1267        assert_eq!(preview_lines.len(), CLONE_PREVIEW_MAX_LINES);
1268        assert_eq!(
1269            highlight_lines as usize,
1270            CLONE_PREVIEW_MAX_LINES - CLONE_PREVIEW_CONTEXT,
1271        );
1272        assert_eq!(preview_lines[highlight_start as usize - 1], "line 49");
1273        assert_eq!(preview_lines[highlight_start as usize], "line 50");
1274    }
1275
1276    #[test]
1277    fn clone_preview_clamps_context_at_file_start() {
1278        use std::io::Write as _;
1279
1280        let mut file = tempfile::NamedTempFile::new().expect("temp file");
1281        file.write_all(b"line 1\nline 2\nline 3\nline 4\nline 5")
1282            .expect("write source");
1283        // Block at the very top: no context fits above it, so the highlight
1284        // starts at index 0 and the trailing lines are dimmed context.
1285        let inst = clone_instance(file.path().to_path_buf(), 1, 2);
1286
1287        let (preview, highlight_start, highlight_lines) = build_clone_preview(&inst);
1288        assert_eq!(highlight_start, 0);
1289        assert_eq!(highlight_lines, 2);
1290        assert_eq!(preview, "line 1\nline 2\nline 3\nline 4\nline 5");
1291    }
1292
1293    #[test]
1294    fn clone_preview_falls_back_when_source_is_unreadable() {
1295        // A missing file forces the fragment fallback: the whole block is
1296        // highlighted so nothing is dimmed.
1297        let inst = clone_instance(project_root().join("does-not-exist.ts"), 1, 3);
1298        let (preview, highlight_start, highlight_lines) = build_clone_preview(&inst);
1299        assert_eq!(preview, inst.fragment);
1300        assert_eq!(highlight_start, 0);
1301        assert_eq!(highlight_lines as usize, preview.lines().count());
1302    }
1303
1304    #[test]
1305    fn cycles_drop_when_any_member_unresolved() {
1306        let fx = fixture();
1307        let data = build_viz_data(&fx.input());
1308
1309        // The a/b cycle resolves fully; the cycle referencing the missing
1310        // file yields no entry at all (not a partial one).
1311        assert_eq!(data.cycles, vec![vec![0, 1]]);
1312        assert!(data.files[0].in_cycle);
1313        assert!(data.files[1].in_cycle);
1314        assert!(!data.files[2].in_cycle);
1315        // The summary counts the rendered cycles, not the raw results, so
1316        // the dropped cycle does not inflate the header number.
1317        assert_eq!(data.summary.circular_deps, data.cycles.len());
1318    }
1319
1320    #[test]
1321    fn violations_resolve_zone_and_file_indices() {
1322        let fx = fixture();
1323        let data = build_viz_data(&fx.input());
1324
1325        assert_eq!(data.zones.len(), 2);
1326        assert_eq!(data.zones[0].name, "app");
1327        assert_eq!(data.zones[0].files, 2);
1328        assert_eq!(data.zones[1].name, "shared");
1329        assert_eq!(data.zones[1].files, 1);
1330        assert_eq!(data.files[0].zone, Some(0));
1331        assert_eq!(data.files[1].zone, Some(0));
1332        assert_eq!(data.files[2].zone, Some(1));
1333
1334        // The violation whose to_path is not in `input.files` is dropped.
1335        assert_eq!(data.violations.len(), 1);
1336        let v = &data.violations[0];
1337        assert_eq!((v.from, v.to), (0, 2));
1338        assert_eq!((v.from_zone, v.to_zone), (0, 1));
1339        assert_eq!(v.line, 2);
1340        assert_eq!(v.specifier, "../lib/c");
1341    }
1342
1343    #[test]
1344    fn clone_group_cap_counts_truncated_groups() {
1345        let fx = fixture();
1346        let index = FileIndex::new(&fx.files);
1347
1348        // The fixture report has two keepable groups plus one dropped for
1349        // unresolvable instances; a cap of 1 keeps the first keepable group
1350        // and counts only the second as truncated (the unresolvable drop is
1351        // not a truncation).
1352        let (clones, groups_by_file, _dup_lines, truncated) =
1353            build_clones(&fx.duplication, &index, 1);
1354        assert_eq!(clones.len(), 1);
1355        assert_eq!(truncated, 1);
1356        assert!(
1357            groups_by_file
1358                .values()
1359                .all(|ids| ids.iter().all(|&id| (id as usize) < clones.len()))
1360        );
1361
1362        // The default cap leaves a small report untouched and unflagged.
1363        let data = build_viz_data(&fx.input());
1364        assert_eq!(data.clones.len(), 2);
1365        assert_eq!(data.summary.clone_groups_truncated, None);
1366    }
1367
1368    #[test]
1369    fn summary_flags_clone_truncation_only_when_nonzero() {
1370        let fx = fixture();
1371        let data = build_viz_data(&fx.input());
1372
1373        let summary = build_summary(&fx.input(), &data.files, &data.clones, &[], &[], 3);
1374        assert_eq!(summary.clone_groups_truncated, Some(3));
1375        let summary = build_summary(&fx.input(), &data.files, &data.clones, &[], &[], 0);
1376        assert_eq!(summary.clone_groups_truncated, None);
1377    }
1378
1379    #[test]
1380    fn summary_counts_match_rendered_arrays() {
1381        let fx = fixture();
1382        let data = build_viz_data(&fx.input());
1383        let s = &data.summary;
1384
1385        assert_eq!(s.total_files, data.files.len());
1386        assert_eq!(s.total_size, 175);
1387        assert_eq!(s.total_edges, data.edges.len());
1388        assert_eq!(s.clone_groups, data.clones.len());
1389        assert_eq!(s.duplicated_lines, 12);
1390        assert_eq!(s.hotspot_files, 0);
1391        assert_eq!(s.unused_files, 0);
1392        assert_eq!(s.unused_exports, 0);
1393        // The raw results carry one unresolvable cycle and one unresolvable
1394        // violation; the header counts only what the arrays render.
1395        assert_eq!(s.circular_deps, data.cycles.len());
1396        assert_eq!(s.circular_deps, 1);
1397        assert_eq!(s.boundary_violations, data.violations.len());
1398        assert_eq!(s.boundary_violations, 1);
1399    }
1400}