Skip to main content

provenant/cli/run/
mod.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use crate::app::request::{InputMode, OutputTarget, ScanRequest};
5use crate::app::scan_pipeline::execute_request;
6use crate::cli::{Cli, Command, ScanArgs};
7use crate::compare::compare_json_files;
8use crate::license_detection::dataset::export_embedded_license_dataset;
9use crate::models::{DatasourceId, FileType, Output};
10use crate::output::{OutputWriteConfig, write_output_file};
11use crate::progress::{ProgressMode, ScanProgress, init_cli_logger};
12use crate::serve::run as run_serve_shell;
13use crate::time::format_scancode_timestamp;
14use anyhow::{Result, anyhow};
15use chrono::Utc;
16use std::path::Path;
17use std::process::ExitCode;
18use std::sync::Arc;
19use std::time::Instant;
20
21/// Exit code returned when the `--fail-on` license-policy gate trips, distinct from
22/// the code used for scan/runtime errors (see ADR 0011).
23const POLICY_GATE_EXIT_CODE: u8 = 3;
24
25/// Exit code returned when one or more `--from-json` SBOM output targets were
26/// refused by [`hollow_from_json_sbom_refusal`] because the reshaped input
27/// never ran package detection. Distinct from the scan/runtime error code (1)
28/// and the license-policy gate code (3), so CI can tell "an SBOM target was
29/// skipped for honesty reasons" apart from "the tool broke" or "a policy
30/// violation." Non-SBOM output targets in the same request are still written
31/// (see the output loop in `run`), matching the ADR 0011 pattern of never
32/// losing an artifact to a gate.
33const HOLLOW_SBOM_GUARD_EXIT_CODE: u8 = 4;
34
35pub fn run() -> Result<ExitCode> {
36    #[cfg(feature = "golden-tests")]
37    touch_license_golden_symbols();
38
39    let cli = Cli::parse();
40
41    // Every subcommand except `scan` installs a plain global logger here so its
42    // `log::*` diagnostics are actually emitted (respecting `-q`/`-v`). `scan`
43    // installs an indicatif-aware bridge later, once its progress system exists.
44    match &cli.command {
45        Command::Scan(_) => {}
46        Command::Serve(args) => init_cli_logger(args.verbosity.verbosity().log_level()),
47        Command::Compare(args) => init_cli_logger(args.verbosity.verbosity().log_level()),
48        Command::ExportLicenseDataset(args) => {
49            init_cli_logger(args.verbosity.verbosity().log_level())
50        }
51        Command::ShowAttribution => init_cli_logger(crate::cli::Verbosity::Normal.log_level()),
52    }
53
54    match &cli.command {
55        Command::ShowAttribution => {
56            print!("{}", include_str!("../../../NOTICE"));
57            return Ok(ExitCode::SUCCESS);
58        }
59        Command::Serve(args) => {
60            return run_serve_shell(args).map(|()| ExitCode::SUCCESS);
61        }
62        Command::Compare(args) => {
63            log::info!(
64                "Comparing ScanCode JSON {} against Provenant JSON {}...",
65                args.scancode_json.display(),
66                args.provenant_json.display()
67            );
68            let result = compare_json_files(
69                &args.scancode_json,
70                &args.provenant_json,
71                args.artifact_dir.as_deref(),
72            )?;
73            println!("Comparison status: {}", result.comparison_status);
74            println!("Artifacts:");
75            println!("  Artifact directory: {}", result.artifact_dir.display());
76            println!("  Run manifest:       {}", result.manifest_path.display());
77            println!("  Raw ScanCode JSON:  {}", result.scancode_json.display());
78            println!("  Raw Provenant JSON: {}", result.provenant_json.display());
79            println!("  Summary JSON:       {}", result.summary_json.display());
80            println!("  Summary TSV:        {}", result.summary_tsv.display());
81            println!("  Sample artifacts:   {}", result.samples_dir.display());
82            return Ok(ExitCode::SUCCESS);
83        }
84        Command::ExportLicenseDataset(args) => {
85            let dir = Path::new(&args.dir);
86            log::info!("Exporting built-in license dataset to {}...", dir.display());
87
88            let started = Instant::now();
89            let outcome = export_embedded_license_dataset(dir)?;
90
91            log::info!(
92                "Exported {} license dataset files to {} in {:.1}s (SPDX list {})",
93                outcome.files_written,
94                dir.display(),
95                started.elapsed().as_secs_f64(),
96                outcome.manifest.spdx_license_list_version
97            );
98            return Ok(ExitCode::SUCCESS);
99        }
100        Command::Scan(_) => {}
101    }
102
103    let cli = cli
104        .scan_args()
105        .expect("scan arguments should exist after command dispatch");
106
107    validate_scan_option_compatibility(cli)?;
108
109    let request = ScanRequest::from(cli);
110    let executed = execute_request(&request)?;
111    let output = executed.output;
112    let progress = executed.progress;
113    let start_time = executed.start_time;
114
115    // A hollow-SBOM refusal only blocks the specific SBOM output target(s)
116    // below; it must not short-circuit non-SBOM outputs in the same request
117    // (e.g. `--spdx-tv out.spdx --json out.json`), so it is computed as a
118    // value up front instead of using `?` to bail out of `run` immediately.
119    // Covers two independent causes: a `--from-json` reshape whose source
120    // scan never ran package detection, and a native `--package-only`/
121    // `--no-assemble` scan that unconditionally skipped assembly this run.
122    let hollow_sbom_refusal = hollow_from_json_sbom_refusal(
123        &request,
124        &output,
125        executed.has_hollow_package_detection_input,
126    )
127    .or_else(|| assembly_skipped_sbom_refusal(&request, &output));
128
129    // Unlike the refusal above, this only warns: `--paths-file` assembly can
130    // produce a genuinely well-formed SBOM, just one that may understate the
131    // repository if the selection omitted sibling manifests or a workspace
132    // root. Skipped once a hollow-SBOM refusal already blocks every SBOM
133    // target this run, since there is then no written SBOM left to warn
134    // about.
135    if hollow_sbom_refusal.is_none()
136        && let Some(warning) = paths_file_sbom_completeness_warning(&request, &output)
137    {
138        emit_sbom_guard_diagnostic(request.progress_mode, &warning, false);
139    }
140
141    let output_schema_output =
142        crate::output_schema::Output::from_with_compat_mode(&output, cli.compat_mode);
143    progress.start_output();
144    for target in &request.output_targets {
145        if hollow_sbom_refusal.is_some() && crate::output::is_sbom_format(target.format) {
146            emit_sbom_guard_diagnostic(
147                request.progress_mode,
148                &format!(
149                    "Skipping {:?} output to {}: {}",
150                    target.format,
151                    target.file,
152                    hollow_sbom_refusal.as_deref().unwrap_or_default()
153                ),
154                true,
155            );
156            continue;
157        }
158
159        let output_config = OutputWriteConfig {
160            format: target.format,
161            custom_template: target.custom_template.clone(),
162            scanned_path: if request.input_paths.len() == 1 {
163                request.input_paths.first().cloned()
164            } else {
165                None
166            },
167        };
168
169        let timing_name = format!("output:{:?}", target.format).to_lowercase();
170        record_detail_timing(&progress, timing_name, || {
171            write_output_file(&target.file, &output_schema_output, &output_config)
172        })?;
173        progress.output_written(&format!(
174            "{:?} output written to {}",
175            target.format, target.file
176        ));
177    }
178    progress.record_final_counts(&output.files);
179    progress.record_final_header_counts(&output.headers);
180    progress.finish_output();
181
182    let summary_end = Utc::now();
183    progress.display_summary(
184        &format_scancode_timestamp(&start_time),
185        &format_scancode_timestamp(&summary_end),
186    );
187
188    // Fails the run after all allowed outputs are already written (same
189    // never-lose-the-artifact shape as the license-policy gate below).
190    if let Some(refusal) = hollow_sbom_refusal {
191        emit_sbom_guard_diagnostic(
192            request.progress_mode,
193            &format!(
194                "Hollow-SBOM guard: {refusal} Failing with exit code {HOLLOW_SBOM_GUARD_EXIT_CODE}."
195            ),
196            true,
197        );
198        return Ok(ExitCode::from(HOLLOW_SBOM_GUARD_EXIT_CODE));
199    }
200
201    // License-policy gate: evaluated after the report is written so the artifact is
202    // never lost to a failing gate (ADR 0011). Covers file-level detections plus
203    // package/dependency declared licenses.
204    if let Some(threshold) = request.fail_on {
205        let file_violations = count_policy_violations(&output.files, threshold);
206        // Fail closed: propagate a policy re-evaluation failure instead of treating
207        // it as zero package/dependency violations.
208        let declared_violations = match request.license_policy.as_deref() {
209            Some(policy_path) => crate::post_processing::count_declared_license_policy_violations(
210                Path::new(policy_path),
211                &output.packages,
212                &output.dependencies,
213                threshold,
214            )?,
215            None => 0,
216        };
217        let violations = file_violations + declared_violations;
218        if violations > 0 {
219            log::error!(
220                "License policy gate: {file_violations} file(s) and {declared_violations} package/dependency license(s) match a policy at or above `{threshold:?}` severity; failing with exit code {POLICY_GATE_EXIT_CODE}."
221            );
222            return Ok(ExitCode::from(POLICY_GATE_EXIT_CODE));
223        }
224    }
225
226    Ok(ExitCode::SUCCESS)
227}
228
229/// Count files whose license policy carries a `compliance_alert` at or above `threshold`.
230fn count_policy_violations(
231    files: &[crate::models::FileInfo],
232    threshold: crate::models::ComplianceAlert,
233) -> usize {
234    files
235        .iter()
236        .filter(|file| {
237            file.license_policy.as_ref().is_some_and(|entries| {
238                entries.iter().any(|entry| {
239                    entry
240                        .compliance_alert
241                        .is_some_and(|alert| alert >= threshold)
242                })
243            })
244        })
245        .count()
246}
247
248#[cfg(feature = "golden-tests")]
249fn touch_license_golden_symbols() {
250    let _ = crate::license_detection::golden_utils::read_golden_input_content;
251    let _ = crate::license_detection::golden_utils::detect_matches_for_golden;
252    let _ = crate::license_detection::golden_utils::detect_license_expressions_for_golden;
253    let _ = crate::license_detection::LicenseDetectionEngine::detect_matches_with_kind;
254}
255
256fn validate_scan_option_compatibility(cli: &ScanArgs) -> Result<()> {
257    if cli.from_json
258        && (cli.package
259            || cli.system_package
260            || cli.package_in_compiled
261            || cli.package_only
262            || cli.copyright
263            || cli.email
264            || cli.url
265            || cli.generated)
266    {
267        return Err(anyhow!(
268            "When using --from-json, file scan options like --package/--copyright/--email/--url/--generated are not allowed"
269        ));
270    }
271
272    if cli.from_json && !cli.paths_file.is_empty() {
273        return Err(anyhow!(
274            "--paths-file is only supported for native scan mode, not --from-json"
275        ));
276    }
277
278    if cli.from_json && cli.incremental {
279        return Err(anyhow!(
280            "--incremental is only supported for directory scan mode, not --from-json"
281        ));
282    }
283
284    if !cli.paths_file.is_empty() && cli.dir_path.len() != 1 {
285        return Err(anyhow!(
286            "--paths-file requires exactly one positional scan root"
287        ));
288    }
289
290    if !cli.from_json && cli.dir_path.is_empty() {
291        return Err(anyhow!("Directory path is required for scan operations"));
292    }
293
294    if cli.tallies_by_facet && cli.facet.is_empty() {
295        return Err(anyhow!(
296            "--tallies-by-facet requires at least one --facet <facet>=<pattern> definition"
297        ));
298    }
299
300    if cli.mark_source && !cli.info {
301        return Err(anyhow!("--mark-source requires --info"));
302    }
303
304    Ok(())
305}
306
307/// Emits an SBOM-guard diagnostic (a refusal explanation or a completeness
308/// warning) so it is never silently dropped.
309///
310/// `ScanProgress::init_logging_bridge` deliberately never installs a logger
311/// at all in `--quiet` mode (see `src/progress.rs`), so a plain
312/// `log::error!`/`log::warn!` call would vanish with no trace — even though a
313/// refusal still changes the process's exit code, and a `--quiet` caller who
314/// only checks the exit code would otherwise have no way to learn why an SBOM
315/// target was skipped. Falling back to a direct `eprintln!` in that case
316/// keeps the message honest without depending on whether a logger happens to
317/// be installed. In Default/Verbose mode the bridge is installed and active,
318/// so this routes through `log` as usual to stay interleaved with progress
319/// bars instead of writing over them.
320fn emit_sbom_guard_diagnostic(progress_mode: ProgressMode, message: &str, is_error: bool) {
321    if progress_mode == ProgressMode::Quiet {
322        eprintln!("{message}");
323    } else if is_error {
324        log::error!("{message}");
325    } else {
326        log::warn!("{message}");
327    }
328}
329
330/// CLI flags for whichever SBOM-oriented formats (see
331/// `crate::output::is_sbom_format`; SPDX and CycloneDX today) appear among
332/// `output_targets`, in request order. Empty when none were requested.
333fn requested_sbom_flags(output_targets: &[OutputTarget]) -> Vec<&'static str> {
334    output_targets
335        .iter()
336        .map(|target| target.format)
337        .filter(|format| crate::output::is_sbom_format(*format))
338        .map(crate::output::cli_flag_for)
339        .collect()
340}
341
342/// Returns a refusal message when a `--from-json` reshape requests an SBOM
343/// format (`--spdx-tv`/`--spdx-rdf`/`--cyclonedx`/`--cyclonedx-xml`) but at
344/// least one merged input would contribute a hollow document: real scanned
345/// files that no package detection ever examined.
346///
347/// `--from-json` reshapes an existing scan without rescanning (see
348/// `docs/CLI_GUIDE.md`), so it cannot recover package data the original scan
349/// never collected. Emitting the SBOM anyway would silently succeed with an
350/// empty (or partially examined) inventory: CycloneDX would write an empty
351/// `components` array and SPDX would fall back to its single-synthetic-package
352/// "no packages" projection, both indistinguishable from a normal successful
353/// export.
354///
355/// This is a query, not a hard gate: it returns `Some(message)` instead of an
356/// `Err` so the caller can skip only the requested SBOM output target(s)
357/// while still writing any other requested output formats in the same
358/// request, then fail the run's exit code once all allowed outputs are
359/// written (see the output loop and [`HOLLOW_SBOM_GUARD_EXIT_CODE`] in `run`).
360///
361/// `has_hollow_package_detection_input` is `true` when *any* merged
362/// `--from-json` input had scanned files, no packages of its own, and never
363/// requested package detection in its own recorded header options
364/// (`--package`, `--package-only`, `--system-package`, or
365/// `--package-in-compiled`) — see `is_hollow_package_detection_input` in
366/// `src/scan_result_shaping/json_input.rs`. This is tracked per input at
367/// merge time, so **one merged input honestly requesting package detection
368/// (or already carrying real packages) can never silence another merged
369/// input's hollow contribution**: the refusal fires even if the overall
370/// `output.packages` ends up non-empty because of a different input.
371///
372/// This intentionally does **not** fire for:
373/// - native scans (only `--from-json` reshapes lack a fresh detection pass);
374/// - requests that do not ask for an SBOM format;
375/// - a truly empty scan document (no files at all) — that keeps the existing
376///   documented empty-SBOM sentinel behavior;
377/// - a `--from-json` input (or merge of inputs) where every input either had
378///   no files or actually requested package detection upstream, since zero
379///   packages then means the codebase honestly has none, not that nothing
380///   looked.
381fn hollow_from_json_sbom_refusal(
382    request: &ScanRequest,
383    output: &Output,
384    has_hollow_package_detection_input: bool,
385) -> Option<String> {
386    if !matches!(request.input_mode, InputMode::FromJson) || !has_hollow_package_detection_input {
387        return None;
388    }
389
390    let scanned_file_count = output
391        .files
392        .iter()
393        .filter(|file| file.file_type == FileType::File)
394        .count();
395    if scanned_file_count == 0 {
396        return None;
397    }
398
399    let requested_sbom_flags = requested_sbom_flags(&request.output_targets);
400    if requested_sbom_flags.is_empty() {
401        return None;
402    }
403
404    Some(format!(
405        "Refusing to write a hollow SBOM for {}: the merged --from-json input has {} scanned file(s) \
406         overall, but at least one merged source's files were never examined for packages (its recorded \
407         scan options never requested --package, --package-only, --system-package, or \
408         --package-in-compiled), even though another merged source may have. Rerun that source's original \
409         scan with one of those flags before reshaping to an SBOM format, or drop {} if a package-less \
410         scan was intended.",
411        requested_sbom_flags.join(", "),
412        scanned_file_count,
413        requested_sbom_flags.join(", "),
414    ))
415}
416
417/// Returns a refusal message when a **native** scan requests an SBOM format
418/// while `--package-only` or `--no-assemble` forced top-level package
419/// assembly to be skipped for the whole run (see `skip_assembly` in
420/// `src/app/scan_pipeline.rs`).
421///
422/// Both flags unconditionally zero out `output.packages`/`output.dependencies`
423/// regardless of what was scanned: `--package-only` intentionally trades the
424/// top-level assembled view for a faster, narrower per-file pass, and
425/// `--no-assemble` disables assembly outright. Either way, CycloneDX would
426/// write an empty `components` array and SPDX would fall back to its
427/// no-package projection — both indistinguishable from a normal, honest
428/// export with zero packages, even though the scanned files may carry
429/// per-file package manifests that were simply never assembled into the
430/// top-level view the SBOM formats read from.
431///
432/// Like [`hollow_from_json_sbom_refusal`], this is a query, not a hard gate:
433/// it returns `Some(message)` so the caller can skip only the requested SBOM
434/// output target(s) while still writing any other requested output formats
435/// in the same request (see the output loop and
436/// [`HOLLOW_SBOM_GUARD_EXIT_CODE`] in `run`).
437///
438/// This intentionally does **not** fire for:
439/// - `--from-json` reshapes (covered separately by
440///   [`hollow_from_json_sbom_refusal`], which reasons about the *source*
441///   scan's recorded options rather than this run's flags);
442/// - requests that do not ask for an SBOM format;
443/// - a truly empty scan document (no files at all);
444/// - the (currently impossible, but defensively checked) case where
445///   `output.packages` is non-empty despite the skip, so this guard degrades
446///   gracefully rather than fires a false positive if that invariant ever
447///   changes.
448fn assembly_skipped_sbom_refusal(request: &ScanRequest, output: &Output) -> Option<String> {
449    if !matches!(request.input_mode, InputMode::Native) {
450        return None;
451    }
452    if !(request.package_only || request.no_assemble) {
453        return None;
454    }
455    if !output.packages.is_empty() {
456        return None;
457    }
458
459    let scanned_file_count = output
460        .files
461        .iter()
462        .filter(|file| file.file_type == FileType::File)
463        .count();
464    if scanned_file_count == 0 {
465        return None;
466    }
467
468    let requested_sbom_flags = requested_sbom_flags(&request.output_targets);
469    if requested_sbom_flags.is_empty() {
470        return None;
471    }
472
473    let cause_flag = if request.package_only {
474        "--package-only"
475    } else {
476        "--no-assemble"
477    };
478
479    Some(format!(
480        "Refusing to write a hollow SBOM for {}: {cause_flag} skips top-level package assembly for \
481         this entire scan, so the {} scanned file(s) were never assembled into the top-level \
482         packages/dependencies view that SBOM export reads from, even if some of those files carry \
483         their own per-file package manifests. Rerun without {cause_flag} (e.g. with --package \
484         instead) before requesting an SBOM format, or drop {} if a package-less export was intended.",
485        requested_sbom_flags.join(", "),
486        scanned_file_count,
487        requested_sbom_flags.join(", "),
488    ))
489}
490
491/// Returns a loud, non-blocking warning when a **native** `--paths-file` scan
492/// requests an SBOM format.
493///
494/// `--paths-file` deliberately narrows collection to a caller-selected subset
495/// of files under the scan root (see `docs/CLI_GUIDE.md`), so assembly only
496/// ever sees the manifests, lockfiles, and workspace context that fell inside
497/// that selection. Unlike [`assembly_skipped_sbom_refusal`], assembly still
498/// runs and can produce a genuinely non-empty, well-formed SBOM — but that
499/// SBOM can silently understate a monorepo's real inventory whenever the
500/// selection omits sibling member manifests or a workspace root that
501/// topology-aware assembly would otherwise use to complete the picture.
502/// Provenant has no bounded, static way to tell "this selection happens to be
503/// complete" apart from "this selection quietly dropped sibling manifests"
504/// without rescanning the whole tree, which would defeat the point of
505/// `--paths-file`. So this warns instead of refusing: the export is still
506/// written, and callers who need a guaranteed-complete inventory should
507/// rerun without `--paths-file`.
508///
509/// Beyond that generic caution, [`topology_root_gaps`] can sometimes name the
510/// *specific* problem instead of just gesturing at the possibility: when a
511/// selected member manifest carries a marker that is only valid Cargo/npm/Mix
512/// syntax inside a real local workspace/umbrella, and no root manifest for
513/// that family was selected, this appends a family-specific paragraph naming
514/// the affected member(s).
515///
516/// Returns `None` when `--paths-file` was not used, the request is not a
517/// native scan, or no SBOM format was requested.
518fn paths_file_sbom_completeness_warning(request: &ScanRequest, output: &Output) -> Option<String> {
519    if !matches!(request.input_mode, InputMode::Native) || request.paths_files.is_empty() {
520        return None;
521    }
522
523    let requested_sbom_flags = requested_sbom_flags(&request.output_targets);
524    if requested_sbom_flags.is_empty() {
525        return None;
526    }
527
528    let mut message = format!(
529        "--paths-file restricted this scan to a caller-selected subset of files, so the {} export \
530         only reflects packages whose manifests fell inside that selection. If this repository has \
531         sibling member manifests, lockfiles, or a workspace root outside the selection, the \
532         resulting inventory may understate the full repository. Rerun without --paths-file (or \
533         widen the selection to include the full workspace) if you need a guaranteed-complete SBOM.",
534        requested_sbom_flags.join(", "),
535    );
536
537    for gap in topology_root_gaps(output) {
538        message.push_str(&describe_topology_root_gap(&gap));
539    }
540
541    Some(message)
542}
543
544/// A family/root-manifest kind where at least one selected file gives
545/// unambiguous, self-declared evidence that it is a *member* of an external
546/// workspace/reactor, yet no root manifest for that family was among the
547/// files this `--paths-file` scan actually selected.
548struct TopologyRootGap {
549    /// Human-readable family name, e.g. `"Cargo workspace"`.
550    family: &'static str,
551    /// What kind of root manifest is missing, e.g. `"a Cargo.toml with a
552    /// [workspace] table"`.
553    root_hint: &'static str,
554    /// Paths of the selected member manifests that carried the evidence,
555    /// sorted and deduplicated.
556    member_paths: Vec<String>,
557}
558
559/// Cargo `field.workspace = true` marker fields whose literal `"workspace"`
560/// extra_data value (see `src/parsers/cargo.rs`) survives untouched on
561/// [`crate::models::PackageData`] whenever no [`CargoWorkspace`] domain ever
562/// claimed the file (i.e. no `[workspace]` root was found in this scan) —
563/// `src/assembly/cargo_workspace_merge.rs::apply_workspace_inheritance` is the
564/// only code that consumes/removes these markers, and it only runs on files
565/// that a discovered workspace domain actually owns.
566///
567/// [`CargoWorkspace`]: crate::assembly
568const CARGO_WORKSPACE_INHERITABLE_MARKER_FIELDS: &[&str] = &[
569    "version",
570    "license",
571    "homepage",
572    "repository",
573    "categories",
574    "keywords",
575    "authors",
576    "description",
577    "include",
578    "exclude",
579    "rust-version",
580    "edition",
581    "documentation",
582    "license-file",
583    "readme",
584    "publish",
585];
586
587/// Detects the subset of topology families where "a selected member manifest
588/// declares workspace/umbrella membership, but the declaring root was not
589/// selected" is decidable from already-parsed scan output alone, with no
590/// re-parsing and no filesystem access beyond the files this `--paths-file`
591/// scan actually selected.
592///
593/// Each family below is included only because at least one of its member
594/// manifests carries a marker that is *only valid* Cargo/npm/Mix syntax when
595/// a real local workspace/umbrella root exists somewhere on disk:
596/// - Cargo: `<field> = { workspace = true }` only resolves inside a real
597///   `[workspace]`; `cargo build` would otherwise fail.
598/// - npm/pnpm/yarn: a `"workspace:"` protocol dependency version only
599///   resolves inside a real workspace install.
600/// - Mix: `{:sibling, in_umbrella: true}` only makes sense inside a real
601///   umbrella project.
602///
603/// Other topology families (Dart workspaces, Maven reactors, Gradle
604/// multi-project, uv workspaces) are deliberately excluded: their member
605/// manifests carry no comparably unambiguous self-declared marker. Maven's
606/// `<parent>` element, for instance, commonly points at a
607/// published, non-local parent POM (e.g. `spring-boot-starter-parent`), so
608/// treating it as reactor-membership evidence would false-positive on the
609/// most common Maven project shape. Guessing there would trade the generic
610/// warning's honest uncertainty for confident wrongness, which is worse.
611fn topology_root_gaps(output: &Output) -> Vec<TopologyRootGap> {
612    let mut gaps = Vec::new();
613
614    if let Some(member_paths) = cargo_workspace_gap_paths(output) {
615        gaps.push(TopologyRootGap {
616            family: "Cargo workspace",
617            root_hint: "a Cargo.toml with a [workspace] table",
618            member_paths,
619        });
620    }
621    if let Some(member_paths) = npm_workspace_gap_paths(output) {
622        gaps.push(TopologyRootGap {
623            family: "npm/pnpm/yarn workspace",
624            root_hint: "a package.json with a \"workspaces\" field, or a pnpm-workspace.yaml",
625            member_paths,
626        });
627    }
628    if let Some(member_paths) = mix_umbrella_gap_paths(output) {
629        gaps.push(TopologyRootGap {
630            family: "Mix umbrella",
631            root_hint: "a mix.exs with an apps_path",
632            member_paths,
633        });
634    }
635
636    gaps
637}
638
639fn cargo_workspace_gap_paths(output: &Output) -> Option<Vec<String>> {
640    // Any `[workspace]` table at all — even with an empty or omitted `members`
641    // list, which is valid Cargo syntax for a single-package workspace — means
642    // a real Cargo workspace root was selected. Requiring a non-empty
643    // `members` array here would false-positive on that legitimate shape.
644    let has_root = output.files.iter().any(|file| {
645        file.package_data.iter().any(|pkg| {
646            pkg.datasource_id == Some(DatasourceId::CargoToml)
647                && pkg
648                    .extra_data
649                    .as_ref()
650                    .is_some_and(|extra| extra.get("workspace").is_some())
651        })
652    });
653    if has_root {
654        return None;
655    }
656
657    let mut member_paths: Vec<String> = output
658        .files
659        .iter()
660        .filter(|file| {
661            file.package_data.iter().any(|pkg| {
662                pkg.datasource_id == Some(DatasourceId::CargoToml)
663                    && pkg.extra_data.as_ref().is_some_and(|extra| {
664                        CARGO_WORKSPACE_INHERITABLE_MARKER_FIELDS
665                            .iter()
666                            .any(|field| {
667                                extra.get(*field).and_then(|value| value.as_str())
668                                    == Some("workspace")
669                            })
670                    })
671            })
672        })
673        .map(|file| file.path.clone())
674        .collect();
675
676    member_paths.extend(
677        output
678            .dependencies
679            .iter()
680            .filter(|dependency| {
681                dependency.datasource_id == DatasourceId::CargoToml
682                    && dependency
683                        .extra_data
684                        .as_ref()
685                        .and_then(|extra| extra.get("workspace"))
686                        .and_then(|value| value.as_bool())
687                        == Some(true)
688            })
689            .map(|dependency| dependency.datafile_path.clone()),
690    );
691
692    member_paths.sort();
693    member_paths.dedup();
694    (!member_paths.is_empty()).then_some(member_paths)
695}
696
697fn npm_workspace_gap_paths(output: &Output) -> Option<Vec<String>> {
698    // A workspace root is either a package.json with a "workspaces" field
699    // (npm/yarn) or a pnpm-workspace.yaml (pnpm keeps its workspace
700    // declaration in a separate file rather than package.json).
701    let has_root = output.files.iter().any(|file| {
702        file.package_data.iter().any(|pkg| {
703            (pkg.datasource_id == Some(DatasourceId::NpmPackageJson)
704                && pkg
705                    .extra_data
706                    .as_ref()
707                    .is_some_and(|extra| extra.contains_key("workspaces")))
708                || pkg.datasource_id == Some(DatasourceId::PnpmWorkspaceYaml)
709        })
710    });
711    if has_root {
712        return None;
713    }
714
715    let mut member_paths: Vec<String> = output
716        .dependencies
717        .iter()
718        .filter(|dependency| {
719            dependency.datasource_id == DatasourceId::NpmPackageJson
720                && dependency
721                    .extracted_requirement
722                    .as_deref()
723                    .is_some_and(|requirement| requirement.starts_with("workspace:"))
724        })
725        .map(|dependency| dependency.datafile_path.clone())
726        .collect();
727
728    member_paths.sort();
729    member_paths.dedup();
730    (!member_paths.is_empty()).then_some(member_paths)
731}
732
733fn mix_umbrella_gap_paths(output: &Output) -> Option<Vec<String>> {
734    let has_root = output.files.iter().any(|file| {
735        file.package_data.iter().any(|pkg| {
736            pkg.datasource_id == Some(DatasourceId::HexMixExs)
737                && pkg
738                    .extra_data
739                    .as_ref()
740                    .is_some_and(|extra| extra.contains_key("apps_path"))
741        })
742    });
743    if has_root {
744        return None;
745    }
746
747    let mut member_paths: Vec<String> = output
748        .dependencies
749        .iter()
750        .filter(|dependency| {
751            dependency.datasource_id == DatasourceId::HexMixExs
752                && dependency
753                    .extra_data
754                    .as_ref()
755                    .and_then(|extra| extra.get("in_umbrella"))
756                    .and_then(|value| value.as_bool())
757                    == Some(true)
758        })
759        .map(|dependency| dependency.datafile_path.clone())
760        .collect();
761
762    member_paths.sort();
763    member_paths.dedup();
764    (!member_paths.is_empty()).then_some(member_paths)
765}
766
767/// At most this many member paths are named individually in a
768/// [`TopologyRootGap`] message before collapsing the rest into "and N more".
769const MAX_NAMED_GAP_MEMBER_PATHS: usize = 3;
770
771fn describe_topology_root_gap(gap: &TopologyRootGap) -> String {
772    let TopologyRootGap {
773        family,
774        root_hint,
775        member_paths,
776    } = gap;
777
778    let mut named = member_paths
779        .iter()
780        .take(MAX_NAMED_GAP_MEMBER_PATHS)
781        .cloned()
782        .collect::<Vec<_>>()
783        .join(", ");
784    let remaining = member_paths
785        .len()
786        .saturating_sub(MAX_NAMED_GAP_MEMBER_PATHS);
787    if remaining > 0 {
788        named.push_str(&format!(", and {remaining} more"));
789    }
790
791    format!(
792        " Specifically, this selection includes {} {family} member manifest(s) that declare \
793         workspace/umbrella membership ({named}), but never included its {root_hint}: without it, \
794         their inherited package identity, license, and dependency data cannot be resolved, so the \
795         {family} inventory in this export is understated beyond the general risk above.",
796        member_paths.len(),
797    )
798}
799
800fn record_detail_timing<T, F>(progress: &Arc<ScanProgress>, name: impl Into<String>, f: F) -> T
801where
802    F: FnOnce() -> T,
803{
804    let started = Instant::now();
805    let result = f();
806    progress.record_detail_timing(name.into(), started.elapsed().as_secs_f64());
807    result
808}
809
810#[cfg(test)]
811mod tests;