Skip to main content

fallow_api/runtime/
trace.rs

1use fallow_engine::session::AnalysisSession;
2use rustc_hash::FxHashSet;
3
4use crate::{
5    ProgrammaticAnalysisContext, ProgrammaticError, TraceCloneOptions,
6    TraceCloneProgrammaticOutput, TraceCloneTarget, TraceDependencyOptions,
7    TraceDependencyProgrammaticOutput, TraceExportOptions, TraceExportProgrammaticOutput,
8    TraceExportTargetOutput, TraceFileOptions, TraceFileProgrammaticOutput,
9};
10
11use super::{ProgrammaticResult, duplication, resolve_programmatic_analysis_context};
12
13struct TraceArtifacts {
14    graph: fallow_engine::module_graph::RetainedModuleGraph,
15    script_used_packages: FxHashSet<String>,
16}
17
18/// Trace why an export is considered used or unused.
19///
20/// # Errors
21///
22/// Returns a structured programmatic error for invalid options, config load
23/// failures, graph construction failures, or missing trace targets.
24pub fn run_trace_export(
25    options: &TraceExportOptions,
26) -> ProgrammaticResult<TraceExportProgrammaticOutput> {
27    validate_non_empty("file", &options.file)?;
28    validate_non_empty("export_name", &options.export_name)?;
29    let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
30    resolved.install(|| {
31        let session = load_trace_session(&resolved)?;
32        let artifacts = trace_artifacts(&session)?;
33        // Resolve a top-level export first; on a miss fall back to a class /
34        // enum / store member trace so the MCP tool and Code Mode match the
35        // CLI's `--trace FILE:MEMBER` behavior instead of a hard not-found
36        // (issue #1744).
37        let output = if let Some(export) = fallow_engine::trace::trace_export(
38            &artifacts.graph,
39            session.root(),
40            &options.file,
41            &options.export_name,
42        ) {
43            TraceExportTargetOutput::Export(export)
44        } else if let Some(member) = fallow_engine::trace::trace_class_member(
45            &artifacts.graph,
46            session.root(),
47            &options.file,
48            &options.export_name,
49        ) {
50            TraceExportTargetOutput::Member(member)
51        } else {
52            return Err(ProgrammaticError::new(
53                format!(
54                    "export or member '{}' not found in '{}'",
55                    options.export_name, options.file
56                ),
57                2,
58            )
59            .with_code("FALLOW_TRACE_TARGET_NOT_FOUND")
60            .with_help(
61                "The name is neither a top-level export nor a class / enum / store member of this \
62                 file. Run trace_file on the file to list its exports, or project_info for the \
63                 project symbol set; confirm the file path is project-relative.",
64            )
65            .with_context("trace_export"));
66        };
67        Ok(TraceExportProgrammaticOutput { output })
68    })
69}
70
71/// Trace all graph edges for a file.
72///
73/// # Errors
74///
75/// Returns a structured programmatic error for invalid options, config load
76/// failures, graph construction failures, or missing trace targets.
77pub fn run_trace_file(
78    options: &TraceFileOptions,
79) -> ProgrammaticResult<TraceFileProgrammaticOutput> {
80    validate_non_empty("file", &options.file)?;
81    let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
82    resolved.install(|| {
83        let session = load_trace_session(&resolved)?;
84        let artifacts = trace_artifacts(&session)?;
85        let output =
86            fallow_engine::trace::trace_file(&artifacts.graph, session.root(), &options.file)
87                .ok_or_else(|| {
88                    ProgrammaticError::new(
89                        format!("file '{}' not found in module graph", options.file),
90                        2,
91                    )
92                    .with_code("FALLOW_TRACE_TARGET_NOT_FOUND")
93                    .with_help(
94                        "The file is not in the analyzed module graph. Run project_info to list \
95                         discovered files; the path must be project-relative and not excluded by \
96                         ignore patterns or outside the analyzed roots.",
97                    )
98                    .with_context("trace_file")
99                })?;
100        Ok(TraceFileProgrammaticOutput { output })
101    })
102}
103
104/// Trace where a dependency is used.
105///
106/// # Errors
107///
108/// Returns a structured programmatic error for invalid options, config load, or
109/// graph construction failures.
110pub fn run_trace_dependency(
111    options: &TraceDependencyOptions,
112) -> ProgrammaticResult<TraceDependencyProgrammaticOutput> {
113    validate_non_empty("package_name", &options.package_name)?;
114    let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
115    resolved.install(|| {
116        let session = load_trace_session(&resolved)?;
117        let artifacts = trace_artifacts(&session)?;
118        let output = fallow_engine::trace::trace_dependency(
119            &artifacts.graph,
120            session.root(),
121            &options.package_name,
122            &artifacts.script_used_packages,
123        );
124        Ok(TraceDependencyProgrammaticOutput { output })
125    })
126}
127
128/// Trace duplicate-code groups by location or stable fingerprint.
129///
130/// # Errors
131///
132/// Returns a structured programmatic error for invalid options, config load
133/// failures, duplicate detection failures, or missing trace targets.
134pub fn run_trace_clone(
135    options: &TraceCloneOptions,
136) -> ProgrammaticResult<TraceCloneProgrammaticOutput> {
137    validate_trace_clone_target(&options.target)?;
138    let resolved = resolve_programmatic_analysis_context(&options.duplication.analysis)?;
139    resolved.install(|| {
140        let session = duplication::load_duplication_session(&options.duplication, &resolved)?;
141        let dupes_config =
142            duplication::build_dupes_config(&options.duplication, &session.config().duplicates);
143        let cache_dir = (!resolved.no_cache).then_some(session.config().cache_dir.as_path());
144        let report = session
145            .find_duplicates_with_defaults(&dupes_config, cache_dir)
146            .report;
147        let (trace, not_found) = match &options.target {
148            TraceCloneTarget::Location { file, line } => (
149                fallow_engine::trace::trace_clone(&report, session.root(), file, *line),
150                format!("no clone found at {file}:{line}"),
151            ),
152            TraceCloneTarget::Fingerprint(fingerprint) => (
153                fallow_engine::trace::trace_clone_by_fingerprint(
154                    &report,
155                    session.root(),
156                    fingerprint,
157                ),
158                format!("no clone group with fingerprint {fingerprint}"),
159            ),
160        };
161        if trace.matched_instance.is_none() {
162            return Err(ProgrammaticError::new(not_found, 2)
163                .with_code("FALLOW_TRACE_TARGET_NOT_FOUND")
164                .with_help(
165                    "No clone matched. Run find_dupes to list clone groups and their fingerprints; \
166                     a location must fall inside a reported clone instance, and a fingerprint must \
167                     be a find_dupes clone_groups[].fingerprint (a dup:<id> value).",
168                )
169                .with_context("trace_clone"));
170        }
171        Ok(TraceCloneProgrammaticOutput { output: trace })
172    })
173}
174
175fn validate_non_empty(field: &str, value: &str) -> ProgrammaticResult<()> {
176    if value.trim().is_empty() {
177        return Err(
178            ProgrammaticError::new(format!("{field} must not be empty"), 2)
179                .with_code("FALLOW_INVALID_TRACE_OPTIONS")
180                .with_context(field.to_string()),
181        );
182    }
183    Ok(())
184}
185
186fn validate_trace_clone_target(target: &TraceCloneTarget) -> ProgrammaticResult<()> {
187    match target {
188        TraceCloneTarget::Location { file, line } => {
189            validate_non_empty("file", file)?;
190            if *line == 0 {
191                return Err(ProgrammaticError::new("line must be greater than 0", 2)
192                    .with_code("FALLOW_INVALID_TRACE_OPTIONS")
193                    .with_context("trace_clone.line"));
194            }
195        }
196        TraceCloneTarget::Fingerprint(fingerprint) => {
197            validate_non_empty("fingerprint", fingerprint)?;
198        }
199    }
200    Ok(())
201}
202
203fn load_trace_session(
204    resolved: &ProgrammaticAnalysisContext,
205) -> ProgrammaticResult<AnalysisSession> {
206    super::dead_code::load_dead_code_session(
207        &super::dead_code::default_dead_code_options_for_context(resolved),
208        resolved,
209    )
210}
211
212fn trace_artifacts(session: &AnalysisSession) -> ProgrammaticResult<TraceArtifacts> {
213    let artifacts = session
214        .analyze_dead_code_with_session_artifacts(false, true, None)
215        .map_err(|err| {
216            ProgrammaticError::new(format!("trace analysis failed: {err}"), 2)
217                .with_code("FALLOW_TRACE_FAILED")
218                .with_context("trace")
219        })?;
220    let graph = artifacts.analysis.graph.ok_or_else(|| {
221        ProgrammaticError::new("trace requires a retained module graph", 2)
222            .with_code("FALLOW_TRACE_GRAPH_UNAVAILABLE")
223            .with_context("trace.graph")
224    })?;
225    Ok(TraceArtifacts {
226        graph,
227        script_used_packages: artifacts.analysis.script_used_packages,
228    })
229}