Skip to main content

fallow_api/runtime/
combined.rs

1use std::{path::PathBuf, time::Instant};
2
3use fallow_config::WorkspaceInfo;
4use fallow_engine::{
5    dead_code::DeadCodeAnalysisArtifacts,
6    project_analysis::{ProjectAnalysisArtifactOptions, ProjectAnalysisArtifacts},
7    session::AnalysisSession,
8};
9use fallow_output::{CombinedNextStepsInput, build_combined_next_steps};
10use rustc_hash::FxHashSet;
11
12use crate::{
13    AnalysisOptions, CombinedOptions, CombinedProgrammaticOutput, ComplexityOptions,
14    DeadCodeFilters, DeadCodeOptions, DuplicationOptions, ProgrammaticError,
15    analysis_context::{
16        changed_files_for_run, resolve_programmatic_analysis_context_deferred_workspace,
17    },
18    next_steps::{
19        default_workspace_ref, default_workspace_ref_for_workspaces, setup_pointer_applicable,
20        suggestions_enabled,
21    },
22};
23
24use super::{
25    EffectiveProductionModes, ProgrammaticResult, health_may_consume_dead_code_artifacts,
26    health_may_consume_duplication_report, resolve_effective_production_modes, root_envelope_mode,
27    run_duplication, run_health, run_health_with_session_artifacts,
28};
29
30struct PreparedCombinedOptions {
31    dead_code: DeadCodeOptions,
32    duplication: DuplicationOptions,
33    health: ComplexityOptions,
34}
35
36struct CombinedSectionRun {
37    dead_code: Option<crate::DeadCodeProgrammaticOutput>,
38    duplication: Option<crate::DuplicationProgrammaticOutput>,
39    health: Option<crate::HealthProgrammaticOutput>,
40    root: PathBuf,
41    workspaces: Option<Vec<WorkspaceInfo>>,
42}
43
44struct DeadCodeSessionRun<'a> {
45    options: &'a CombinedOptions,
46    resolved: &'a crate::analysis_context::ProgrammaticAnalysisContext,
47    prepared: &'a PreparedCombinedOptions,
48    changed_files: Option<&'a FxHashSet<PathBuf>>,
49    session: &'a AnalysisSession,
50}
51
52/// Run bare combined analysis through one programmatic analysis session.
53///
54/// # Errors
55///
56/// Returns a structured programmatic error for invalid options, config load
57/// failures, or analysis failures.
58pub fn run_combined(options: &CombinedOptions) -> ProgrammaticResult<CombinedProgrammaticOutput> {
59    if !(options.dead_code || options.duplication || options.health) {
60        return Err(ProgrammaticError::new(
61            "combined analysis requires at least one enabled section",
62            2,
63        )
64        .with_code("FALLOW_COMBINED_EMPTY")
65        .with_context("combined"));
66    }
67
68    let start = Instant::now();
69    let resolved = resolve_programmatic_analysis_context_deferred_workspace(&options.analysis)?;
70    resolved.install(|| {
71        resolved.ensure_not_cancelled("combined analysis")?;
72        let production_modes = resolve_effective_production_modes(&resolved, None, None, None)?;
73        let prepared = prepare_combined_options(options, production_modes);
74        let changed_files = changed_files_for_run(&resolved)?;
75        let sections = run_combined_sections(
76            options,
77            &resolved,
78            &prepared,
79            changed_files.as_ref(),
80            production_modes,
81        )?;
82
83        let next_steps = combined_next_steps(
84            sections.dead_code.as_ref(),
85            sections.duplication.as_ref(),
86            sections.health.as_ref(),
87            &sections.root,
88            sections.workspaces.as_deref(),
89        );
90
91        Ok(CombinedProgrammaticOutput {
92            dead_code: sections.dead_code,
93            duplication: sections.duplication,
94            health: sections.health,
95            root: sections.root,
96            elapsed: start.elapsed(),
97            explain: options.analysis.explain,
98            next_steps,
99            envelope_mode: root_envelope_mode(),
100            telemetry_analysis_run_id: None,
101        })
102    })
103}
104
105fn prepare_combined_options(
106    options: &CombinedOptions,
107    production_modes: EffectiveProductionModes,
108) -> PreparedCombinedOptions {
109    PreparedCombinedOptions {
110        dead_code: combined_dead_code_options(options, production_modes.dead_code),
111        duplication: combined_duplication_options(options, production_modes.dupes),
112        health: combined_health_options(options, production_modes.health),
113    }
114}
115
116fn run_combined_sections(
117    options: &CombinedOptions,
118    resolved: &crate::analysis_context::ProgrammaticAnalysisContext,
119    prepared: &PreparedCombinedOptions,
120    changed_files: Option<&FxHashSet<PathBuf>>,
121    production_modes: EffectiveProductionModes,
122) -> ProgrammaticResult<CombinedSectionRun> {
123    let share_health = options.dead_code
124        && options.health
125        && production_modes.dead_code == production_modes.health;
126    let share_dupes = options.dead_code
127        && options.duplication
128        && production_modes.dead_code == production_modes.dupes;
129    if share_health || share_dupes {
130        return run_combined_with_dead_code_session(
131            options,
132            resolved,
133            prepared,
134            changed_files,
135            share_health,
136            share_dupes,
137        );
138    }
139    run_combined_sections_isolated(options, resolved, prepared)
140}
141
142fn run_combined_with_dead_code_session(
143    options: &CombinedOptions,
144    resolved: &crate::analysis_context::ProgrammaticAnalysisContext,
145    prepared: &PreparedCombinedOptions,
146    changed_files: Option<&FxHashSet<PathBuf>>,
147    share_health: bool,
148    share_dupes: bool,
149) -> ProgrammaticResult<CombinedSectionRun> {
150    resolved.ensure_not_cancelled("config load and file discovery")?;
151    let session = super::dead_code::load_dead_code_session(&prepared.dead_code, resolved)?;
152    if share_dupes {
153        return run_combined_with_project_artifacts(CombinedProjectArtifactRun {
154            options,
155            resolved,
156            prepared,
157            changed_files,
158            share_health,
159            session: &session,
160        });
161    }
162    let ctx = DeadCodeSessionRun {
163        options,
164        resolved,
165        prepared,
166        changed_files,
167        session: &session,
168    };
169    let (dead_code, dead_code_artifacts) =
170        run_dead_code_with_optional_artifacts(&ctx, options.health && share_health)?;
171    let duplication = run_combined_duplication(&ctx, share_dupes)?;
172    resolved.ensure_not_cancelled("the health section")?;
173    let health = run_combined_health(&ctx, share_health, dead_code_artifacts, None)?;
174    Ok(CombinedSectionRun {
175        dead_code,
176        duplication,
177        health,
178        root: session.root().to_path_buf(),
179        workspaces: Some(session.workspaces().to_vec()),
180    })
181}
182
183#[derive(Clone, Copy)]
184struct CombinedProjectArtifactRun<'a> {
185    options: &'a CombinedOptions,
186    resolved: &'a crate::analysis_context::ProgrammaticAnalysisContext,
187    prepared: &'a PreparedCombinedOptions,
188    changed_files: Option<&'a FxHashSet<PathBuf>>,
189    share_health: bool,
190    session: &'a AnalysisSession,
191}
192
193fn run_combined_with_project_artifacts(
194    run: CombinedProjectArtifactRun<'_>,
195) -> ProgrammaticResult<CombinedSectionRun> {
196    let CombinedProjectArtifactRun {
197        options,
198        resolved,
199        prepared,
200        changed_files,
201        share_health,
202        session,
203    } = run;
204    let retain_dead_code_artifacts =
205        share_health && health_may_consume_dead_code_artifacts(&prepared.health, session.config());
206    let section_start = Instant::now();
207    let project = analyze_project_artifacts_for_combined(&run, retain_dead_code_artifacts)?;
208    let dead_code = super::dead_code::run_dead_code_from_artifacts(
209        &prepared.dead_code,
210        resolved,
211        session,
212        changed_files,
213        project.dead_code,
214        section_start,
215    )?;
216    let pre_computed_duplication_for_health =
217        should_precompute_duplication_for_combined_health(options, prepared, share_health)
218            .then(|| project.duplication.clone());
219    let duplication = run_project_artifact_duplication(
220        options,
221        prepared,
222        resolved,
223        session,
224        project.duplication,
225        section_start,
226    )?;
227    let super::dead_code::DeadCodeProgrammaticRunWithArtifacts {
228        output: dead_code,
229        artifacts,
230    } = dead_code;
231    let dead_code_artifacts = retain_dead_code_artifacts.then_some(artifacts);
232    resolved.ensure_not_cancelled("the health section")?;
233    let health = run_combined_health(
234        &DeadCodeSessionRun {
235            options,
236            resolved,
237            prepared,
238            changed_files,
239            session,
240        },
241        share_health,
242        dead_code_artifacts,
243        pre_computed_duplication_for_health,
244    )?;
245
246    Ok(CombinedSectionRun {
247        dead_code: Some(dead_code),
248        duplication,
249        health,
250        root: session.root().to_path_buf(),
251        workspaces: Some(session.workspaces().to_vec()),
252    })
253}
254
255fn run_project_artifact_duplication(
256    options: &CombinedOptions,
257    prepared: &PreparedCombinedOptions,
258    resolved: &crate::analysis_context::ProgrammaticAnalysisContext,
259    session: &AnalysisSession,
260    duplication: fallow_engine::duplicates::DuplicationReport,
261    section_start: Instant,
262) -> ProgrammaticResult<Option<crate::DuplicationProgrammaticOutput>> {
263    options
264        .duplication
265        .then(|| {
266            super::duplication::run_duplication_report_with_session(
267                &prepared.duplication,
268                resolved,
269                session,
270                duplication,
271                section_start,
272            )
273        })
274        .transpose()
275}
276
277fn should_precompute_duplication_for_combined_health(
278    options: &CombinedOptions,
279    prepared: &PreparedCombinedOptions,
280    share_health: bool,
281) -> bool {
282    options.health
283        && share_health
284        && health_may_consume_duplication_report(&prepared.health)
285        && duplication_options_preserve_health_config(&prepared.duplication)
286}
287
288fn analyze_project_artifacts_for_combined(
289    run: &CombinedProjectArtifactRun<'_>,
290    retain_dead_code_artifacts: bool,
291) -> ProgrammaticResult<ProjectAnalysisArtifacts> {
292    let dupes_config = super::duplication::build_dupes_config(
293        &run.prepared.duplication,
294        &run.session.config().duplicates,
295    );
296    run.session
297        .analyze_project_with_artifacts(
298            &dupes_config,
299            ProjectAnalysisArtifactOptions {
300                retain_complexity_artifacts: retain_dead_code_artifacts,
301                retain_graph: retain_dead_code_artifacts,
302                changed_files: run.changed_files.cloned(),
303                collect_source_fingerprints: false,
304            },
305        )
306        .map_err(|err| {
307            super::dead_code::map_engine_error(
308                &err,
309                "combined analysis failed",
310                "FALLOW_COMBINED_FAILED",
311                "combined",
312            )
313        })
314}
315
316fn run_dead_code_with_optional_artifacts(
317    ctx: &DeadCodeSessionRun<'_>,
318    share_health: bool,
319) -> ProgrammaticResult<(
320    Option<crate::DeadCodeProgrammaticOutput>,
321    Option<DeadCodeAnalysisArtifacts>,
322)> {
323    let retain_artifacts = share_health
324        && health_may_consume_dead_code_artifacts(&ctx.prepared.health, ctx.session.config());
325    if retain_artifacts {
326        let dead_code = super::dead_code::run_dead_code_with_session_artifacts(
327            &ctx.prepared.dead_code,
328            ctx.resolved,
329            ctx.session,
330            ctx.changed_files,
331            |_| {},
332            Instant::now(),
333        )?;
334        return Ok((Some(dead_code.output), Some(dead_code.artifacts)));
335    }
336    let dead_code = super::dead_code::run_dead_code_with_session(
337        &ctx.prepared.dead_code,
338        ctx.resolved,
339        ctx.session,
340        ctx.changed_files,
341        |_| {},
342        Instant::now(),
343    )?;
344    Ok((Some(dead_code), None))
345}
346
347fn run_combined_duplication(
348    ctx: &DeadCodeSessionRun<'_>,
349    share_dupes: bool,
350) -> ProgrammaticResult<Option<crate::DuplicationProgrammaticOutput>> {
351    if !ctx.options.duplication {
352        return Ok(None);
353    }
354    if !share_dupes {
355        return run_duplication(&ctx.prepared.duplication).map(Some);
356    }
357    super::duplication::run_duplication_with_session(
358        &ctx.prepared.duplication,
359        ctx.resolved,
360        ctx.session,
361        ctx.changed_files,
362        Instant::now(),
363    )
364    .map(Some)
365}
366
367fn run_combined_health(
368    ctx: &DeadCodeSessionRun<'_>,
369    share_health: bool,
370    dead_code_artifacts: Option<DeadCodeAnalysisArtifacts>,
371    pre_computed_duplication: Option<fallow_engine::duplicates::DuplicationReport>,
372) -> ProgrammaticResult<Option<crate::HealthProgrammaticOutput>> {
373    if !ctx.options.health {
374        return Ok(None);
375    }
376    if !share_health {
377        return run_health(&ctx.prepared.health).map(Some);
378    }
379    run_health_with_session_artifacts(
380        &ctx.prepared.health,
381        ctx.resolved,
382        ctx.session,
383        ctx.changed_files,
384        dead_code_artifacts,
385        pre_computed_duplication,
386    )
387    .map(Some)
388}
389
390fn run_combined_sections_isolated(
391    options: &CombinedOptions,
392    resolved: &crate::analysis_context::ProgrammaticAnalysisContext,
393    prepared: &PreparedCombinedOptions,
394) -> ProgrammaticResult<CombinedSectionRun> {
395    Ok(CombinedSectionRun {
396        dead_code: options
397            .dead_code
398            .then(|| super::dead_code::run_dead_code(&prepared.dead_code))
399            .transpose()?,
400        duplication: options
401            .duplication
402            .then(|| run_duplication(&prepared.duplication))
403            .transpose()?,
404        health: options
405            .health
406            .then(|| run_health(&prepared.health))
407            .transpose()?,
408        root: resolved.root().to_path_buf(),
409        workspaces: None,
410    })
411}
412
413fn combined_dead_code_options(options: &CombinedOptions, production: bool) -> DeadCodeOptions {
414    DeadCodeOptions {
415        analysis: analysis_with_effective_production(&options.analysis, production),
416        filters: DeadCodeFilters::default(),
417        files: Vec::new(),
418        include_entry_exports: options.include_entry_exports,
419    }
420}
421
422fn combined_duplication_options(options: &CombinedOptions, production: bool) -> DuplicationOptions {
423    let mut duplication = options.duplication_options.clone();
424    duplication.analysis = analysis_with_effective_production(&options.analysis, production);
425    duplication
426}
427
428fn duplication_options_preserve_health_config(options: &DuplicationOptions) -> bool {
429    options.mode.is_none()
430        && options.min_tokens.is_none()
431        && options.min_lines.is_none()
432        && options.min_occurrences.is_none()
433        && options.threshold.is_none()
434        && options.skip_local.is_none()
435        && options.cross_language.is_none()
436        && options.ignore_imports.is_none()
437}
438
439fn combined_health_options(options: &CombinedOptions, production: bool) -> ComplexityOptions {
440    let mut health = options.health_options.clone();
441    health.analysis = analysis_with_effective_production(&options.analysis, production);
442    health
443}
444
445fn analysis_with_effective_production(
446    analysis: &AnalysisOptions,
447    production: bool,
448) -> AnalysisOptions {
449    AnalysisOptions {
450        production,
451        production_override: Some(production),
452        ..analysis.clone()
453    }
454}
455
456fn combined_next_steps(
457    dead_code: Option<&crate::DeadCodeProgrammaticOutput>,
458    duplication: Option<&crate::DuplicationProgrammaticOutput>,
459    health: Option<&crate::HealthProgrammaticOutput>,
460    root: &std::path::Path,
461    workspaces: Option<&[WorkspaceInfo]>,
462) -> Vec<fallow_types::output::NextStep> {
463    let clone_fingerprints = duplication
464        .map(|duplication| {
465            duplication
466                .output
467                .report
468                .clone_groups
469                .iter()
470                .map(|group| group.fingerprint.as_str())
471                .collect::<Vec<_>>()
472        })
473        .unwrap_or_default();
474    let audit_changed = fallow_engine::churn::is_git_repo(root);
475    let workspace_ref = audit_changed
476        .then(|| {
477            workspaces.map_or_else(
478                || default_workspace_ref(root),
479                |workspaces| default_workspace_ref_for_workspaces(root, workspaces),
480            )
481        })
482        .flatten();
483    build_combined_next_steps(&CombinedNextStepsInput {
484        suggestions_enabled: suggestions_enabled(),
485        has_dead_code_findings: dead_code
486            .is_some_and(|dead_code| dead_code.output.results.total_issues() > 0),
487        trace_unused_export: dead_code.and_then(|dead_code| {
488            fallow_output::trace_unused_export_input(&dead_code.output.results, root)
489        }),
490        workspace_ref: workspace_ref.as_deref(),
491        clone_fingerprints: &clone_fingerprints,
492        has_complexity_findings: health.is_some_and(|health| !health.report.findings.is_empty()),
493        offer_setup: setup_pointer_applicable(root),
494        impact_digest: None,
495        audit_changed,
496        has_external_plugins: !fallow_config::discover_external_plugins(root, &[]).is_empty(),
497        has_unused_files: dead_code
498            .is_some_and(|dead_code| !dead_code.output.results.unused_files.is_empty()),
499    })
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505    use crate::DuplicationMode;
506
507    #[test]
508    fn health_reuses_combined_duplication_only_without_detector_overrides() {
509        assert!(duplication_options_preserve_health_config(
510            &DuplicationOptions::default()
511        ));
512        assert!(!duplication_options_preserve_health_config(
513            &DuplicationOptions {
514                min_tokens: Some(1),
515                ..DuplicationOptions::default()
516            }
517        ));
518        assert!(!duplication_options_preserve_health_config(
519            &DuplicationOptions {
520                mode: Some(DuplicationMode::Semantic),
521                ..DuplicationOptions::default()
522            }
523        ));
524    }
525}