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