Skip to main content

fallow_api/runtime/
feature_flags.rs

1use std::path::Path;
2use std::time::Instant;
3
4use fallow_engine::flag_report::{RetirementRequest, build_retirement_report};
5use fallow_engine::flag_retirement::{RetirementFacts, RetirementOptions};
6use fallow_engine::flag_vendor::{VendorExport, load_flag_state};
7
8use fallow_engine::{project_config::ProjectConfig, session::AnalysisSession};
9use fallow_output::{
10    DiffIndex, FEATURE_FLAGS_SCHEMA_VERSION, FeatureFlagsOutputInput, build_feature_flags_output,
11    feature_flags_meta,
12};
13use fallow_types::output_format::OutputFormat;
14use fallow_types::results::FeatureFlag;
15
16use crate::{
17    FeatureFlagsOptions, FeatureFlagsProgrammaticOutput, ProgrammaticError,
18    analysis_context::{
19        ProgrammaticAnalysisContext, changed_files_for_run,
20        resolve_programmatic_analysis_context_deferred_workspace, workspace_roots_for_session,
21    },
22};
23
24use super::ProgrammaticResult;
25
26/// Run feature-flag analysis and return typed API output before JSON.
27///
28/// # Errors
29///
30/// Returns a structured programmatic error for invalid options, config load
31/// failures, git changed-file failures, or analysis failures, and
32/// `FALLOW_CANCELLED` when the caller's cancellation token is set. The scan
33/// observes the token at its entry, on both sides of the parse loop, and at
34/// the stage boundaries of the dead-code correlation behind it.
35pub fn run_feature_flags(
36    options: &FeatureFlagsOptions,
37) -> ProgrammaticResult<FeatureFlagsProgrammaticOutput> {
38    let resolved = resolve_programmatic_analysis_context_deferred_workspace(&options.analysis)?;
39    resolved.install(|| run_feature_flags_inner(options, &resolved))
40}
41
42fn run_feature_flags_inner(
43    options: &FeatureFlagsOptions,
44    resolved: &ProgrammaticAnalysisContext,
45) -> ProgrammaticResult<FeatureFlagsProgrammaticOutput> {
46    let start = Instant::now();
47    let vendor_export = load_vendor_export(options, &resolved.root)?;
48    resolved.ensure_not_cancelled("config load and file discovery")?;
49    let session = load_feature_flags_session(resolved)?;
50    let scan = if options.retirement.is_some() {
51        fallow_engine::flags::analyze_feature_flags_for_retirement(&session)
52    } else {
53        fallow_engine::flags::analyze_feature_flags_with_session(&session)
54            .map(|analysis| (analysis, RetirementFacts::default()))
55    };
56    let (analysis, retirement_facts) = scan.map_err(|err| {
57        super::dead_code::map_engine_error(
58            &err,
59            "feature-flag analysis failed",
60            "FALLOW_FEATURE_FLAGS_FAILED",
61            "feature-flags",
62        )
63    })?;
64    if analysis.files_scanned == 0 {
65        return Err(ProgrammaticError::new("no files discovered", 2)
66            .with_code("FALLOW_NO_FILES_DISCOVERED")
67            .with_context("feature-flags"));
68    }
69
70    let scope = feature_flags_scope(resolved, &session)?;
71    let all_flags = if options.retirement.is_some() {
72        analysis.flags.clone()
73    } else {
74        Vec::new()
75    };
76    let mut flags = analysis.flags;
77    flags.retain(|flag| scope.contains(&flag.path, session.root()));
78    let mut workspace_diagnostics = session.current_workspace_diagnostics();
79    let retirement = options.retirement.as_ref().map(|retirement| {
80        let build = build_retirement_report(RetirementRequest {
81            root: session.root(),
82            workspaces: session.workspaces(),
83            sites: retirement_facts.sites_for(&all_flags),
84            in_scope: &|path| scope.contains(path, session.root()),
85            whole_project: scope.is_whole_project(),
86            age_mode: retirement.flag_age,
87            cache_dir: (!resolved.no_cache).then_some(session.config().cache_dir.as_path()),
88            progress: None,
89            vendor_export: vendor_export.as_ref(),
90            vendor_key_prefix: session.config().flags.vendor_key_prefix.as_deref(),
91            max_flag_age: retirement.max_flag_age,
92            options: RetirementOptions {
93                sort: retirement.sort,
94                min_age_days: retirement.min_age_days,
95                reasons: retirement.reasons.clone(),
96                top: options.top,
97            },
98        });
99        workspace_diagnostics.extend(build.diagnostics.into_iter().map(|kind| {
100            fallow_config::WorkspaceDiagnostic::new(
101                session.root(),
102                session.root().to_path_buf(),
103                kind,
104            )
105        }));
106        build.report
107    });
108    sort_and_limit_feature_flags(&mut flags, options.top);
109
110    let output = build_feature_flags_output(FeatureFlagsOutputInput {
111        schema_version: FEATURE_FLAGS_SCHEMA_VERSION,
112        version: env!("CARGO_PKG_VERSION").to_string(),
113        elapsed: start.elapsed(),
114        flags: &flags,
115        root: session.root(),
116        // Read live, like the dead-code route: the parse stage records
117        // `source-read-failure` and `source-parse-degraded` after the session
118        // captured its walk snapshot, and both are reasons a flag is missing.
119        workspace_diagnostics,
120        // The diff this route resolved and applied above, or the reason it
121        // stood down. This route filters flags by the diff, unlike the CLI
122        // `flags` command, so an applied entry states a real narrowing.
123        request_outcomes: resolved.request_outcomes(),
124        meta: resolved.explain_enabled().then(feature_flags_meta),
125        retirement,
126    });
127
128    Ok(FeatureFlagsProgrammaticOutput {
129        output,
130        telemetry_analysis_run_id: None,
131    })
132}
133
134fn load_feature_flags_session(
135    resolved: &ProgrammaticAnalysisContext,
136) -> ProgrammaticResult<AnalysisSession> {
137    let project_config = fallow_engine::project_config::config_for_project_with_load_options(
138        &resolved.root,
139        resolved.config_path.as_deref(),
140        fallow_config::ConfigLoadOptions {
141            allow_remote_extends: resolved.allow_remote_extends(),
142        },
143    )
144    .map_err(|err| {
145        ProgrammaticError::new(format!("failed to load config: {err}"), 2)
146            .with_code("FALLOW_CONFIG_LOAD_FAILED")
147            .with_context("analysis.configPath")
148    })?;
149    Ok(super::dead_code::attach_cancellation(
150        AnalysisSession::from_config(configure_project_for_feature_flags(
151            project_config,
152            resolved,
153        )),
154        resolved,
155    ))
156}
157
158fn configure_project_for_feature_flags(
159    mut project_config: ProjectConfig,
160    resolved: &ProgrammaticAnalysisContext,
161) -> ProjectConfig {
162    project_config.config.output = OutputFormat::Json;
163    project_config.config.no_cache = resolved.no_cache;
164    project_config.config.threads = resolved.threads;
165    project_config.config.production = resolved
166        .production_override
167        .unwrap_or(project_config.config.production);
168    project_config
169}
170
171/// The files a flags run reports on, from the workspace, changed-since and
172/// diff options.
173struct FeatureFlagsScope<'a> {
174    workspace_roots: Option<Vec<std::path::PathBuf>>,
175    changed_files: Option<rustc_hash::FxHashSet<std::path::PathBuf>>,
176    diff: Option<&'a DiffIndex>,
177}
178
179impl FeatureFlagsScope<'_> {
180    fn is_whole_project(&self) -> bool {
181        self.workspace_roots.is_none() && self.changed_files.is_none() && self.diff.is_none()
182    }
183
184    fn contains(&self, path: &Path, root: &Path) -> bool {
185        self.workspace_roots
186            .as_ref()
187            .is_none_or(|roots| roots.iter().any(|workspace| path.starts_with(workspace)))
188            && self
189                .changed_files
190                .as_ref()
191                .is_none_or(|changed| changed.contains(path))
192            && self.diff.as_ref().is_none_or(|diff| {
193                diff.key_for(path, root)
194                    .is_none_or(|rel| diff.touches_file(&rel))
195            })
196    }
197}
198
199fn feature_flags_scope<'a>(
200    resolved: &'a ProgrammaticAnalysisContext,
201    session: &AnalysisSession,
202) -> ProgrammaticResult<FeatureFlagsScope<'a>> {
203    let workspace_roots = workspace_roots_for_session(resolved, session.workspaces())?;
204    let changed_files = changed_files_for_run(resolved)?;
205    if changed_files.is_some() {
206        resolved
207            .measure_changed_since_scope(session.files().iter().map(|file| file.path.as_path()));
208    }
209    Ok(FeatureFlagsScope {
210        workspace_roots,
211        changed_files,
212        diff: resolved.diff_index(),
213    })
214}
215
216/// Read the vendor export before the analysis, so an invalid file fails
217/// fast.
218fn load_vendor_export(
219    options: &FeatureFlagsOptions,
220    root: &Path,
221) -> ProgrammaticResult<Option<VendorExport>> {
222    let Some(path) = options
223        .retirement
224        .as_ref()
225        .and_then(|retirement| retirement.flag_state.as_deref())
226    else {
227        return Ok(None);
228    };
229    load_flag_state(path, root).map(Some).map_err(|error| {
230        ProgrammaticError::new(error.message, 2)
231            .with_code("FALLOW_FLAG_STATE_INVALID")
232            .with_help(error.help)
233            .with_context("feature-flags.retirement.flagState")
234    })
235}
236
237fn sort_and_limit_feature_flags(flags: &mut Vec<FeatureFlag>, top: Option<usize>) {
238    flags.sort_by(|a, b| {
239        a.path
240            .cmp(&b.path)
241            .then(a.line.cmp(&b.line))
242            .then(a.flag_name.cmp(&b.flag_name))
243    });
244
245    if let Some(top) = top {
246        flags.truncate(top);
247    }
248}