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    ProductionModes, ProgrammaticResult, health_may_consume_dead_code_artifacts,
26    health_may_consume_duplication_report, resolve_effective_production_modes, run_duplication,
27    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            telemetry_analysis_run_id: None,
100            request_outcomes: resolved.request_outcomes(),
101        })
102    })
103}
104
105fn prepare_combined_options(
106    options: &CombinedOptions,
107    production_modes: ProductionModes,
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: ProductionModes,
122) -> ProgrammaticResult<CombinedSectionRun> {
123    let share_health =
124        options.dead_code && options.health && production_modes.dead_code_matches_health();
125    let share_dupes =
126        options.dead_code && options.duplication && production_modes.dead_code_matches_dupes();
127    if share_health || share_dupes {
128        return run_combined_with_dead_code_session(
129            options,
130            resolved,
131            prepared,
132            changed_files,
133            share_health,
134            share_dupes,
135        );
136    }
137    run_combined_sections_isolated(options, resolved, prepared)
138}
139
140fn run_combined_with_dead_code_session(
141    options: &CombinedOptions,
142    resolved: &crate::analysis_context::ProgrammaticAnalysisContext,
143    prepared: &PreparedCombinedOptions,
144    changed_files: Option<&FxHashSet<PathBuf>>,
145    share_health: bool,
146    share_dupes: bool,
147) -> ProgrammaticResult<CombinedSectionRun> {
148    resolved.ensure_not_cancelled("config load and file discovery")?;
149    let session = super::dead_code::load_dead_code_session(&prepared.dead_code, resolved)?;
150    if share_dupes {
151        return run_combined_with_project_artifacts(CombinedProjectArtifactRun {
152            options,
153            resolved,
154            prepared,
155            changed_files,
156            share_health,
157            session: &session,
158        });
159    }
160    let ctx = DeadCodeSessionRun {
161        options,
162        resolved,
163        prepared,
164        changed_files,
165        session: &session,
166    };
167    let (dead_code, dead_code_artifacts) =
168        run_dead_code_with_optional_artifacts(&ctx, options.health && share_health)?;
169    let duplication = run_combined_duplication(&ctx, share_dupes)?;
170    resolved.ensure_not_cancelled("the health section")?;
171    let health = run_combined_health(&ctx, share_health, dead_code_artifacts, None)?;
172    Ok(CombinedSectionRun {
173        dead_code,
174        duplication,
175        health,
176        root: session.root().to_path_buf(),
177        workspaces: Some(session.workspaces().to_vec()),
178    })
179}
180
181#[derive(Clone, Copy)]
182struct CombinedProjectArtifactRun<'a> {
183    options: &'a CombinedOptions,
184    resolved: &'a crate::analysis_context::ProgrammaticAnalysisContext,
185    prepared: &'a PreparedCombinedOptions,
186    changed_files: Option<&'a FxHashSet<PathBuf>>,
187    share_health: bool,
188    session: &'a AnalysisSession,
189}
190
191fn run_combined_with_project_artifacts(
192    run: CombinedProjectArtifactRun<'_>,
193) -> ProgrammaticResult<CombinedSectionRun> {
194    let CombinedProjectArtifactRun {
195        options,
196        resolved,
197        prepared,
198        changed_files,
199        share_health,
200        session,
201    } = run;
202    let retain_dead_code_artifacts =
203        share_health && health_may_consume_dead_code_artifacts(&prepared.health, session.config());
204    let section_start = Instant::now();
205    let project = analyze_project_artifacts_for_combined(&run, retain_dead_code_artifacts)?;
206    let dead_code = super::dead_code::run_dead_code_from_artifacts(
207        &prepared.dead_code,
208        resolved,
209        session,
210        changed_files,
211        project.dead_code,
212        section_start,
213    )?;
214    let pre_computed_duplication_for_health =
215        should_precompute_duplication_for_combined_health(options, prepared, share_health)
216            .then(|| project.duplication.clone());
217    let duplication = run_project_artifact_duplication(
218        options,
219        prepared,
220        resolved,
221        session,
222        project.duplication,
223        section_start,
224    )?;
225    let super::dead_code::DeadCodeProgrammaticRunWithArtifacts {
226        output: dead_code,
227        artifacts,
228    } = dead_code;
229    let dead_code_artifacts = retain_dead_code_artifacts.then_some(artifacts);
230    resolved.ensure_not_cancelled("the health section")?;
231    let health = run_combined_health(
232        &DeadCodeSessionRun {
233            options,
234            resolved,
235            prepared,
236            changed_files,
237            session,
238        },
239        share_health,
240        dead_code_artifacts,
241        pre_computed_duplication_for_health,
242    )?;
243
244    Ok(CombinedSectionRun {
245        dead_code: Some(dead_code),
246        duplication,
247        health,
248        root: session.root().to_path_buf(),
249        workspaces: Some(session.workspaces().to_vec()),
250    })
251}
252
253fn run_project_artifact_duplication(
254    options: &CombinedOptions,
255    prepared: &PreparedCombinedOptions,
256    resolved: &crate::analysis_context::ProgrammaticAnalysisContext,
257    session: &AnalysisSession,
258    duplication: fallow_engine::duplicates::DuplicationReport,
259    section_start: Instant,
260) -> ProgrammaticResult<Option<crate::DuplicationProgrammaticOutput>> {
261    options
262        .duplication
263        .then(|| {
264            super::duplication::run_duplication_report_with_session(
265                &prepared.duplication,
266                resolved,
267                session,
268                duplication,
269                section_start,
270            )
271        })
272        .transpose()
273}
274
275fn should_precompute_duplication_for_combined_health(
276    options: &CombinedOptions,
277    prepared: &PreparedCombinedOptions,
278    share_health: bool,
279) -> bool {
280    options.health
281        && share_health
282        && health_may_consume_duplication_report(&prepared.health)
283        && duplication_options_preserve_health_config(&prepared.duplication)
284}
285
286fn analyze_project_artifacts_for_combined(
287    run: &CombinedProjectArtifactRun<'_>,
288    retain_dead_code_artifacts: bool,
289) -> ProgrammaticResult<ProjectAnalysisArtifacts> {
290    let dupes_config = super::duplication::build_dupes_config(
291        &run.prepared.duplication,
292        &run.session.config().duplicates,
293    );
294    run.session
295        .analyze_project_with_artifacts(
296            &dupes_config,
297            ProjectAnalysisArtifactOptions {
298                retain_complexity_artifacts: retain_dead_code_artifacts,
299                retain_graph: retain_dead_code_artifacts,
300                changed_files: run.changed_files.cloned(),
301                collect_source_fingerprints: false,
302            },
303        )
304        .map_err(|err| {
305            super::dead_code::map_engine_error(
306                &err,
307                "combined analysis failed",
308                "FALLOW_COMBINED_FAILED",
309                "combined",
310            )
311        })
312}
313
314fn run_dead_code_with_optional_artifacts(
315    ctx: &DeadCodeSessionRun<'_>,
316    share_health: bool,
317) -> ProgrammaticResult<(
318    Option<crate::DeadCodeProgrammaticOutput>,
319    Option<DeadCodeAnalysisArtifacts>,
320)> {
321    let retain_artifacts = share_health
322        && health_may_consume_dead_code_artifacts(&ctx.prepared.health, ctx.session.config());
323    if retain_artifacts {
324        let dead_code = super::dead_code::run_dead_code_with_session_artifacts(
325            &ctx.prepared.dead_code,
326            ctx.resolved,
327            ctx.session,
328            ctx.changed_files,
329            |_| {},
330            Instant::now(),
331        )?;
332        return Ok((Some(dead_code.output), Some(dead_code.artifacts)));
333    }
334    let dead_code = super::dead_code::run_dead_code_with_session(
335        &ctx.prepared.dead_code,
336        ctx.resolved,
337        ctx.session,
338        ctx.changed_files,
339        |_| {},
340        Instant::now(),
341    )?;
342    Ok((Some(dead_code), None))
343}
344
345fn run_combined_duplication(
346    ctx: &DeadCodeSessionRun<'_>,
347    share_dupes: bool,
348) -> ProgrammaticResult<Option<crate::DuplicationProgrammaticOutput>> {
349    if !ctx.options.duplication {
350        return Ok(None);
351    }
352    if !share_dupes {
353        return run_duplication(&ctx.prepared.duplication).map(Some);
354    }
355    super::duplication::run_duplication_with_session(
356        &ctx.prepared.duplication,
357        ctx.resolved,
358        ctx.session,
359        ctx.changed_files,
360        Instant::now(),
361    )
362    .map(Some)
363}
364
365fn run_combined_health(
366    ctx: &DeadCodeSessionRun<'_>,
367    share_health: bool,
368    dead_code_artifacts: Option<DeadCodeAnalysisArtifacts>,
369    pre_computed_duplication: Option<fallow_engine::duplicates::DuplicationReport>,
370) -> ProgrammaticResult<Option<crate::HealthProgrammaticOutput>> {
371    if !ctx.options.health {
372        return Ok(None);
373    }
374    if !share_health {
375        return run_health(&ctx.prepared.health).map(Some);
376    }
377    run_health_with_session_artifacts(
378        &ctx.prepared.health,
379        ctx.resolved,
380        ctx.session,
381        ctx.changed_files,
382        dead_code_artifacts,
383        pre_computed_duplication,
384    )
385    .map(Some)
386}
387
388fn run_combined_sections_isolated(
389    options: &CombinedOptions,
390    resolved: &crate::analysis_context::ProgrammaticAnalysisContext,
391    prepared: &PreparedCombinedOptions,
392) -> ProgrammaticResult<CombinedSectionRun> {
393    Ok(CombinedSectionRun {
394        dead_code: options
395            .dead_code
396            .then(|| super::dead_code::run_dead_code(&prepared.dead_code))
397            .transpose()?,
398        duplication: options
399            .duplication
400            .then(|| run_duplication(&prepared.duplication))
401            .transpose()?,
402        health: options
403            .health
404            .then(|| run_health(&prepared.health))
405            .transpose()?,
406        root: resolved.root().to_path_buf(),
407        workspaces: None,
408    })
409}
410
411fn combined_dead_code_options(options: &CombinedOptions, production: bool) -> DeadCodeOptions {
412    DeadCodeOptions {
413        analysis: analysis_with_effective_production(&options.analysis, production),
414        filters: DeadCodeFilters::default(),
415        files: Vec::new(),
416        include_entry_exports: options.include_entry_exports,
417    }
418}
419
420fn combined_duplication_options(options: &CombinedOptions, production: bool) -> DuplicationOptions {
421    let mut duplication = options.duplication_options.clone();
422    duplication.analysis = analysis_with_effective_production(&options.analysis, production);
423    duplication
424}
425
426fn duplication_options_preserve_health_config(options: &DuplicationOptions) -> bool {
427    options.mode.is_none()
428        && options.min_tokens.is_none()
429        && options.min_lines.is_none()
430        && options.min_occurrences.is_none()
431        && options.threshold.is_none()
432        && options.skip_local.is_none()
433        && options.cross_language.is_none()
434        && options.ignore_imports.is_none()
435}
436
437fn combined_health_options(options: &CombinedOptions, production: bool) -> ComplexityOptions {
438    let mut health = options.health_options.clone();
439    health.analysis = analysis_with_effective_production(&options.analysis, production);
440    health
441}
442
443fn analysis_with_effective_production(
444    analysis: &AnalysisOptions,
445    production: bool,
446) -> AnalysisOptions {
447    AnalysisOptions {
448        production,
449        production_override: Some(production),
450        ..analysis.clone()
451    }
452}
453
454fn combined_next_steps(
455    dead_code: Option<&crate::DeadCodeProgrammaticOutput>,
456    duplication: Option<&crate::DuplicationProgrammaticOutput>,
457    health: Option<&crate::HealthProgrammaticOutput>,
458    root: &std::path::Path,
459    workspaces: Option<&[WorkspaceInfo]>,
460) -> Vec<fallow_types::output::NextStep> {
461    let clone_fingerprints = duplication
462        .map(|duplication| {
463            duplication
464                .output
465                .report
466                .clone_groups
467                .iter()
468                .map(|group| group.fingerprint.as_str())
469                .collect::<Vec<_>>()
470        })
471        .unwrap_or_default();
472    let audit_changed = fallow_engine::churn::is_git_repo(root);
473    let workspace_ref = audit_changed
474        .then(|| {
475            workspaces.map_or_else(
476                || default_workspace_ref(root),
477                |workspaces| default_workspace_ref_for_workspaces(root, workspaces),
478            )
479        })
480        .flatten();
481    build_combined_next_steps(&CombinedNextStepsInput {
482        suggestions_enabled: suggestions_enabled(),
483        has_dead_code_findings: dead_code
484            .is_some_and(|dead_code| dead_code.output.results.total_issues() > 0),
485        trace_unused_export: dead_code.and_then(|dead_code| {
486            fallow_output::trace_unused_export_input(&dead_code.output.results, root)
487        }),
488        workspace_ref: workspace_ref.as_deref(),
489        clone_fingerprints: &clone_fingerprints,
490        has_complexity_findings: health.is_some_and(|health| !health.report.findings.is_empty()),
491        offer_setup: setup_pointer_applicable(root),
492        impact_digest: None,
493        audit_changed,
494        has_external_plugins: !fallow_config::discover_external_plugins(root, &[]).is_empty(),
495        has_unused_files: dead_code
496            .is_some_and(|dead_code| !dead_code.output.results.unused_files.is_empty()),
497        // The programmatic runtime has no process-wide record of a loaded
498        // baseline to read, and its caller passes the paths itself, so there is
499        // nothing to point a re-check at here.
500        baseline_recheck: None,
501    })
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507    use crate::DuplicationMode;
508
509    #[test]
510    fn health_reuses_combined_duplication_only_without_detector_overrides() {
511        assert!(duplication_options_preserve_health_config(
512            &DuplicationOptions::default()
513        ));
514        assert!(!duplication_options_preserve_health_config(
515            &DuplicationOptions {
516                min_tokens: Some(1),
517                ..DuplicationOptions::default()
518            }
519        ));
520        assert!(!duplication_options_preserve_health_config(
521            &DuplicationOptions {
522                mode: Some(DuplicationMode::Semantic),
523                ..DuplicationOptions::default()
524            }
525        ));
526    }
527}