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