Skip to main content

fallow_api/runtime/
duplication.rs

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