Skip to main content

fallow_api/runtime/
feature_flags.rs

1use std::time::Instant;
2
3use fallow_engine::{project_config::ProjectConfig, session::AnalysisSession};
4use fallow_output::{
5    FEATURE_FLAGS_SCHEMA_VERSION, FeatureFlagsOutputInput, build_feature_flags_output,
6    feature_flags_meta,
7};
8use fallow_types::output_format::OutputFormat;
9use fallow_types::results::FeatureFlag;
10
11use crate::{
12    FeatureFlagsOptions, FeatureFlagsProgrammaticOutput, ProgrammaticError,
13    analysis_context::{
14        ProgrammaticAnalysisContext, changed_files_for_run,
15        resolve_programmatic_analysis_context_deferred_workspace, workspace_roots_for_session,
16    },
17};
18
19use super::ProgrammaticResult;
20
21/// Run feature-flag analysis and return typed API output before JSON.
22///
23/// # Errors
24///
25/// Returns a structured programmatic error for invalid options, config load
26/// failures, git changed-file failures, or analysis failures, and
27/// `FALLOW_CANCELLED` when the caller's cancellation token is set. The scan
28/// observes the token at its entry, on both sides of the parse loop, and at
29/// the stage boundaries of the dead-code correlation behind it.
30pub fn run_feature_flags(
31    options: &FeatureFlagsOptions,
32) -> ProgrammaticResult<FeatureFlagsProgrammaticOutput> {
33    let resolved = resolve_programmatic_analysis_context_deferred_workspace(&options.analysis)?;
34    resolved.install(|| run_feature_flags_inner(options, &resolved))
35}
36
37fn run_feature_flags_inner(
38    options: &FeatureFlagsOptions,
39    resolved: &ProgrammaticAnalysisContext,
40) -> ProgrammaticResult<FeatureFlagsProgrammaticOutput> {
41    let start = Instant::now();
42    resolved.ensure_not_cancelled("config load and file discovery")?;
43    let session = load_feature_flags_session(resolved)?;
44    let analysis =
45        fallow_engine::flags::analyze_feature_flags_with_session(&session).map_err(|err| {
46            super::dead_code::map_engine_error(
47                &err,
48                "feature-flag analysis failed",
49                "FALLOW_FEATURE_FLAGS_FAILED",
50                "feature-flags",
51            )
52        })?;
53    if analysis.files_scanned == 0 {
54        return Err(ProgrammaticError::new("no files discovered", 2)
55            .with_code("FALLOW_NO_FILES_DISCOVERED")
56            .with_context("feature-flags"));
57    }
58
59    let mut flags = analysis.flags;
60    apply_feature_flags_scope(&mut flags, resolved, &session)?;
61    sort_and_limit_feature_flags(&mut flags, options.top);
62
63    let output = build_feature_flags_output(FeatureFlagsOutputInput {
64        schema_version: FEATURE_FLAGS_SCHEMA_VERSION,
65        version: env!("CARGO_PKG_VERSION").to_string(),
66        elapsed: start.elapsed(),
67        flags: &flags,
68        root: session.root(),
69        // Read live, like the dead-code route: the parse stage records
70        // `source-read-failure` and `source-parse-degraded` after the session
71        // captured its walk snapshot, and both are reasons a flag is missing.
72        workspace_diagnostics: session.current_workspace_diagnostics(),
73        // The diff this route resolved and applied above, or the reason it
74        // stood down. This route filters flags by the diff, unlike the CLI
75        // `flags` command, so an applied entry states a real narrowing.
76        request_outcomes: resolved.request_outcomes(),
77        meta: resolved.explain_enabled().then(feature_flags_meta),
78    });
79
80    Ok(FeatureFlagsProgrammaticOutput {
81        output,
82        telemetry_analysis_run_id: None,
83    })
84}
85
86fn load_feature_flags_session(
87    resolved: &ProgrammaticAnalysisContext,
88) -> ProgrammaticResult<AnalysisSession> {
89    let project_config = fallow_engine::project_config::config_for_project_with_load_options(
90        &resolved.root,
91        resolved.config_path.as_deref(),
92        fallow_config::ConfigLoadOptions {
93            allow_remote_extends: resolved.allow_remote_extends(),
94        },
95    )
96    .map_err(|err| {
97        ProgrammaticError::new(format!("failed to load config: {err}"), 2)
98            .with_code("FALLOW_CONFIG_LOAD_FAILED")
99            .with_context("analysis.configPath")
100    })?;
101    Ok(super::dead_code::attach_cancellation(
102        AnalysisSession::from_config(configure_project_for_feature_flags(
103            project_config,
104            resolved,
105        )),
106        resolved,
107    ))
108}
109
110fn configure_project_for_feature_flags(
111    mut project_config: ProjectConfig,
112    resolved: &ProgrammaticAnalysisContext,
113) -> ProjectConfig {
114    project_config.config.output = OutputFormat::Json;
115    project_config.config.no_cache = resolved.no_cache;
116    project_config.config.threads = resolved.threads;
117    project_config.config.production = resolved
118        .production_override
119        .unwrap_or(project_config.config.production);
120    project_config
121}
122
123fn apply_feature_flags_scope(
124    flags: &mut Vec<FeatureFlag>,
125    resolved: &ProgrammaticAnalysisContext,
126    session: &AnalysisSession,
127) -> ProgrammaticResult<()> {
128    let workspace_roots = workspace_roots_for_session(resolved, session.workspaces())?;
129    if let Some(workspace_roots) = workspace_roots.as_ref() {
130        flags.retain(|flag| {
131            workspace_roots
132                .iter()
133                .any(|root| flag.path.starts_with(root))
134        });
135    }
136    if let Some(changed_files) = changed_files_for_run(resolved)? {
137        resolved
138            .measure_changed_since_scope(session.files().iter().map(|file| file.path.as_path()));
139        flags.retain(|flag| changed_files.contains(&flag.path));
140    }
141    if let Some(diff) = resolved.diff.as_ref() {
142        flags.retain(|flag| {
143            diff.key_for(&flag.path, session.root())
144                .is_none_or(|rel| diff.touches_file(&rel))
145        });
146    }
147    Ok(())
148}
149
150fn sort_and_limit_feature_flags(flags: &mut Vec<FeatureFlag>, top: Option<usize>) {
151    flags.sort_by(|a, b| {
152        a.path
153            .cmp(&b.path)
154            .then(a.line.cmp(&b.line))
155            .then(a.flag_name.cmp(&b.flag_name))
156    });
157
158    if let Some(top) = top {
159        flags.truncate(top);
160    }
161}