Skip to main content

fallow_api/runtime/
duplication.rs

1use std::time::Instant;
2
3use fallow_config::{DetectionMode, DuplicatesConfig, ProductionAnalysis};
4use fallow_engine::{
5    project_config::{ProjectConfig, ProjectConfigOptions},
6    session::AnalysisSession,
7};
8use fallow_output::{
9    DUPES_PROGRAMMATIC_SCHEMA_VERSION, DupesNextStepsInput, DupesOutput, DupesOutputInput,
10    build_dupes_next_steps, build_dupes_output, dupes_meta,
11};
12use fallow_types::output_format::OutputFormat;
13use rustc_hash::FxHashSet;
14
15use crate::{
16    DupesReportPayload, DuplicationGroup, DuplicationMode, DuplicationOptions,
17    DuplicationProgrammaticOutput, ProgrammaticError,
18    analysis_context::{
19        ProgrammaticAnalysisContext, changed_files_for_run,
20        resolve_programmatic_analysis_context_deferred_workspace, workspace_roots_for_session,
21    },
22    next_steps::{setup_pointer_applicable, suggestions_enabled},
23};
24
25use super::ProgrammaticResult;
26
27/// Run duplication analysis and return typed API output before serialization.
28///
29/// # Errors
30///
31/// Returns a structured programmatic error for invalid options, config load
32/// failures, or git changed-file failures.
33pub fn run_duplication(
34    options: &DuplicationOptions,
35) -> ProgrammaticResult<DuplicationProgrammaticOutput> {
36    let resolved = resolve_programmatic_analysis_context_deferred_workspace(&options.analysis)?;
37    resolved.install(|| run_duplication_inner(options, &resolved))
38}
39
40fn run_duplication_inner(
41    options: &DuplicationOptions,
42    resolved: &ProgrammaticAnalysisContext,
43) -> ProgrammaticResult<DuplicationProgrammaticOutput> {
44    let start = Instant::now();
45    resolved.ensure_not_cancelled("config load and file discovery")?;
46    let session = load_duplication_session(options, resolved)?;
47    run_duplication_with_session(options, resolved, &session, None, start)
48}
49
50pub(super) fn run_duplication_with_session(
51    options: &DuplicationOptions,
52    resolved: &ProgrammaticAnalysisContext,
53    session: &AnalysisSession,
54    changed_files: Option<&FxHashSet<std::path::PathBuf>>,
55    start: Instant,
56) -> ProgrammaticResult<DuplicationProgrammaticOutput> {
57    resolved.ensure_not_cancelled("duplication detection")?;
58    let dupes_config = build_dupes_config(options, &session.config().duplicates);
59    let resolved_changed_files = if changed_files.is_some() {
60        None
61    } else {
62        changed_files_for_run(resolved)?
63    };
64    let cache_dir = (!resolved.no_cache).then_some(session.config().cache_dir.as_path());
65    let report = if let Some(changed_files) = changed_files.or(resolved_changed_files.as_ref()) {
66        resolved
67            .measure_changed_since_scope(session.files().iter().map(|file| file.path.as_path()));
68        let changed_files = changed_files.iter().cloned().collect::<Vec<_>>();
69        session
70            .find_duplicates_touching_files_with_defaults(&dupes_config, &changed_files, cache_dir)
71            .report
72    } else {
73        session
74            .find_duplicates_with_defaults(&dupes_config, cache_dir)
75            .report
76    };
77
78    // Duplication detection cannot fail, so a token set while it ran has to be
79    // reported here rather than dressed up as a complete report.
80    resolved.ensure_not_cancelled("the duplication report")?;
81    run_duplication_report_with_session(options, resolved, session, report, start)
82}
83
84pub(super) fn run_duplication_report_with_session(
85    options: &DuplicationOptions,
86    resolved: &ProgrammaticAnalysisContext,
87    session: &AnalysisSession,
88    mut report: fallow_engine::duplicates::DuplicationReport,
89    start: Instant,
90) -> ProgrammaticResult<DuplicationProgrammaticOutput> {
91    let dupes_config = build_dupes_config(options, &session.config().duplicates);
92    let workspace_roots = workspace_roots_for_session(resolved, session.workspaces())?;
93    fallow_engine::duplicates::apply_scope(
94        &mut report,
95        &fallow_engine::duplicates::DuplicationScope {
96            changed_files: None,
97            diff: resolved.diff.as_ref(),
98            workspace_roots: workspace_roots.as_deref(),
99        },
100        session.root(),
101    );
102    if let Some(top) = options.top {
103        fallow_engine::duplicates::apply_top(&mut report, top, session.root());
104    }
105
106    let root = session.root();
107    let payload = DupesReportPayload::from_report_with_fragments(
108        &report,
109        options.include_fragments.unwrap_or(true),
110    );
111    let clone_fingerprints = payload
112        .clone_groups
113        .iter()
114        .map(|group| group.fingerprint.as_str())
115        .collect::<Vec<_>>();
116    let next_steps = build_dupes_next_steps(DupesNextStepsInput {
117        suggestions_enabled: suggestions_enabled(),
118        clone_fingerprints: &clone_fingerprints,
119        offer_setup: setup_pointer_applicable(root),
120        impact_digest: None,
121        audit_changed: fallow_engine::churn::is_git_repo(root),
122        baseline_recheck: None,
123    });
124    let output: DupesOutput<DupesReportPayload, DuplicationGroup> =
125        build_dupes_output(DupesOutputInput {
126            gate_outcomes: None,
127            request_outcomes: resolved.request_outcomes(),
128            schema_version: DUPES_PROGRAMMATIC_SCHEMA_VERSION,
129            version: env!("CARGO_PKG_VERSION").to_string(),
130            elapsed: start.elapsed(),
131            report: payload,
132            clone_groups_shown: report.clone_groups_shown(),
133            clone_groups_omitted: report.clone_groups_omitted(),
134            clone_families_shown: report.clone_families_shown(),
135            clone_families_omitted: report.clone_families_omitted(),
136            grouped_by: None,
137            total_issues: None,
138            groups: None,
139            // The programmatic duplication route loads no baseline, so there is
140            // no staleness to report.
141            baseline_staleness: None,
142            meta: resolved.explain_enabled().then(dupes_meta),
143            workspace_diagnostics: session.workspace_diagnostics().to_vec(),
144            next_steps,
145        });
146    Ok(DuplicationProgrammaticOutput {
147        output,
148        root: session.root().to_path_buf(),
149        threshold: dupes_config.threshold,
150        telemetry_analysis_run_id: None,
151    })
152}
153
154pub(super) fn load_duplication_session(
155    options: &DuplicationOptions,
156    resolved: &ProgrammaticAnalysisContext,
157) -> ProgrammaticResult<AnalysisSession> {
158    let project_config = fallow_engine::project_config::config_for_project_analysis(
159        &resolved.root,
160        resolved.config_path.as_deref(),
161        ProjectConfigOptions {
162            output: OutputFormat::Json,
163            no_cache: resolved.no_cache,
164            threads: resolved.threads,
165            production_override: resolved.production_override,
166            quiet: true,
167            analysis: ProductionAnalysis::Dupes,
168            allow_remote_extends: resolved.allow_remote_extends(),
169        },
170    )
171    .map_err(|err| {
172        ProgrammaticError::new(format!("failed to load config: {err}"), 2)
173            .with_code("FALLOW_CONFIG_LOAD_FAILED")
174            .with_context("analysis.configPath")
175    })?;
176    let project_config = configure_project_for_duplication(project_config, options);
177    Ok(super::dead_code::attach_cancellation(
178        AnalysisSession::from_config(project_config),
179        resolved,
180    ))
181}
182
183fn configure_project_for_duplication(
184    mut project_config: ProjectConfig,
185    options: &DuplicationOptions,
186) -> ProjectConfig {
187    project_config.config.duplicates =
188        build_dupes_config(options, &project_config.config.duplicates);
189    project_config
190}
191
192pub(super) fn build_dupes_config(
193    options: &DuplicationOptions,
194    config: &DuplicatesConfig,
195) -> DuplicatesConfig {
196    DuplicatesConfig {
197        enabled: true,
198        mode: options.mode.map_or(config.mode, duplication_mode_to_config),
199        near: options.near.unwrap_or(config.near),
200        min_tokens: options.min_tokens.unwrap_or(config.min_tokens),
201        min_lines: options.min_lines.unwrap_or(config.min_lines),
202        min_occurrences: options.min_occurrences.unwrap_or(config.min_occurrences),
203        threshold: options.threshold.unwrap_or(config.threshold),
204        ignore: config.ignore.clone(),
205        ignored_clones: config.ignored_clones.clone(),
206        ignore_defaults: config.ignore_defaults,
207        skip_local: options.skip_local.unwrap_or(config.skip_local),
208        cross_language: options.cross_language.unwrap_or(config.cross_language),
209        ignore_imports: options.ignore_imports.unwrap_or(config.ignore_imports),
210        normalization: config.normalization.clone(),
211        min_corpus_size_for_shingle_filter: config.min_corpus_size_for_shingle_filter,
212        min_corpus_size_for_token_cache: config.min_corpus_size_for_token_cache,
213    }
214}
215
216const fn duplication_mode_to_config(mode: DuplicationMode) -> DetectionMode {
217    match mode {
218        DuplicationMode::Strict => DetectionMode::Strict,
219        DuplicationMode::Mild => DetectionMode::Mild,
220        DuplicationMode::Weak => DetectionMode::Weak,
221        DuplicationMode::Semantic => DetectionMode::Semantic,
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn duplication_options_override_near_and_preserve_reviewed_clones() {
231        let config = DuplicatesConfig {
232            near: true,
233            ignored_clones: vec!["dup:12345678:2".to_string()],
234            ..DuplicatesConfig::default()
235        };
236        let options = DuplicationOptions {
237            near: Some(false),
238            ..DuplicationOptions::default()
239        };
240
241        let merged = build_dupes_config(&options, &config);
242
243        assert!(!merged.near);
244        assert_eq!(merged.ignored_clones, config.ignored_clones);
245    }
246}