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