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, root_envelope_mode};
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        meta: resolved.explain_enabled().then(feature_flags_meta),
70    });
71
72    Ok(FeatureFlagsProgrammaticOutput {
73        output,
74        envelope_mode: root_envelope_mode(),
75        telemetry_analysis_run_id: None,
76    })
77}
78
79fn load_feature_flags_session(
80    resolved: &ProgrammaticAnalysisContext,
81) -> ProgrammaticResult<AnalysisSession> {
82    let project_config = fallow_engine::project_config::config_for_project_with_load_options(
83        &resolved.root,
84        resolved.config_path.as_deref(),
85        fallow_config::ConfigLoadOptions {
86            allow_remote_extends: resolved.allow_remote_extends(),
87        },
88    )
89    .map_err(|err| {
90        ProgrammaticError::new(format!("failed to load config: {err}"), 2)
91            .with_code("FALLOW_CONFIG_LOAD_FAILED")
92            .with_context("analysis.configPath")
93    })?;
94    Ok(super::dead_code::attach_cancellation(
95        AnalysisSession::from_config(configure_project_for_feature_flags(
96            project_config,
97            resolved,
98        )),
99        resolved,
100    ))
101}
102
103fn configure_project_for_feature_flags(
104    mut project_config: ProjectConfig,
105    resolved: &ProgrammaticAnalysisContext,
106) -> ProjectConfig {
107    project_config.config.output = OutputFormat::Json;
108    project_config.config.no_cache = resolved.no_cache;
109    project_config.config.threads = resolved.threads;
110    project_config.config.production = resolved
111        .production_override
112        .unwrap_or(project_config.config.production);
113    project_config
114}
115
116fn apply_feature_flags_scope(
117    flags: &mut Vec<FeatureFlag>,
118    resolved: &ProgrammaticAnalysisContext,
119    session: &AnalysisSession,
120) -> ProgrammaticResult<()> {
121    let workspace_roots = workspace_roots_for_session(resolved, session.workspaces())?;
122    if let Some(workspace_roots) = workspace_roots.as_ref() {
123        flags.retain(|flag| {
124            workspace_roots
125                .iter()
126                .any(|root| flag.path.starts_with(root))
127        });
128    }
129    if let Some(changed_files) = changed_files_for_run(resolved)? {
130        flags.retain(|flag| changed_files.contains(&flag.path));
131    }
132    if let Some(diff) = resolved.diff.as_ref() {
133        flags.retain(|flag| {
134            diff.key_for(&flag.path, session.root())
135                .is_none_or(|rel| diff.touches_file(&rel))
136        });
137    }
138    Ok(())
139}
140
141fn sort_and_limit_feature_flags(flags: &mut Vec<FeatureFlag>, top: Option<usize>) {
142    flags.sort_by(|a, b| {
143        a.path
144            .cmp(&b.path)
145            .then(a.line.cmp(&b.line))
146            .then(a.flag_name.cmp(&b.flag_name))
147    });
148
149    if let Some(top) = top {
150        flags.truncate(top);
151    }
152}