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