Skip to main content

fallow_api/runtime/
duplication.rs

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