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    CHECK_SCHEMA_VERSION, FeatureFlagsOutputInput, build_feature_flags_output, feature_flags_meta,
6};
7use fallow_types::output_format::OutputFormat;
8use fallow_types::results::FeatureFlag;
9
10use crate::{
11    FeatureFlagsOptions, FeatureFlagsProgrammaticOutput, ProgrammaticError,
12    analysis_context::{
13        ProgrammaticAnalysisContext, changed_files_for_run,
14        resolve_programmatic_analysis_context_deferred_workspace, workspace_roots_for_session,
15    },
16};
17
18use super::{ProgrammaticResult, root_envelope_mode};
19
20/// Run feature-flag analysis and return typed API output before JSON.
21///
22/// # Errors
23///
24/// Returns a structured programmatic error for invalid options, config load
25/// failures, git changed-file failures, or analysis failures.
26pub fn run_feature_flags(
27    options: &FeatureFlagsOptions,
28) -> ProgrammaticResult<FeatureFlagsProgrammaticOutput> {
29    let resolved = resolve_programmatic_analysis_context_deferred_workspace(&options.analysis)?;
30    resolved.install(|| run_feature_flags_inner(options, &resolved))
31}
32
33fn run_feature_flags_inner(
34    options: &FeatureFlagsOptions,
35    resolved: &ProgrammaticAnalysisContext,
36) -> ProgrammaticResult<FeatureFlagsProgrammaticOutput> {
37    let start = Instant::now();
38    let session = load_feature_flags_session(resolved)?;
39    let analysis = fallow_engine::flags::analyze_feature_flags_with_session(&session);
40    if analysis.files_scanned == 0 {
41        return Err(ProgrammaticError::new("no files discovered", 2)
42            .with_code("FALLOW_NO_FILES_DISCOVERED")
43            .with_context("feature-flags"));
44    }
45
46    let mut flags = analysis.flags;
47    apply_feature_flags_scope(&mut flags, resolved, &session)?;
48    sort_and_limit_feature_flags(&mut flags, options.top);
49
50    let output = build_feature_flags_output(FeatureFlagsOutputInput {
51        schema_version: CHECK_SCHEMA_VERSION,
52        version: env!("CARGO_PKG_VERSION").to_string(),
53        elapsed: start.elapsed(),
54        flags: &flags,
55        root: session.root(),
56        meta: resolved.explain_enabled().then(feature_flags_meta),
57    });
58
59    Ok(FeatureFlagsProgrammaticOutput {
60        output,
61        envelope_mode: root_envelope_mode(),
62        telemetry_analysis_run_id: None,
63    })
64}
65
66fn load_feature_flags_session(
67    resolved: &ProgrammaticAnalysisContext,
68) -> ProgrammaticResult<AnalysisSession> {
69    let project_config = fallow_engine::project_config::config_for_project_with_load_options(
70        &resolved.root,
71        resolved.config_path.as_deref(),
72        fallow_config::ConfigLoadOptions {
73            allow_remote_extends: resolved.allow_remote_extends(),
74        },
75    )
76    .map_err(|err| {
77        ProgrammaticError::new(format!("failed to load config: {err}"), 2)
78            .with_code("FALLOW_CONFIG_LOAD_FAILED")
79            .with_context("analysis.configPath")
80    })?;
81    Ok(AnalysisSession::from_config(
82        configure_project_for_feature_flags(project_config, resolved),
83    ))
84}
85
86fn configure_project_for_feature_flags(
87    mut project_config: ProjectConfig,
88    resolved: &ProgrammaticAnalysisContext,
89) -> ProjectConfig {
90    project_config.config.output = OutputFormat::Json;
91    project_config.config.no_cache = resolved.no_cache;
92    project_config.config.threads = resolved.threads;
93    project_config.config.production = resolved
94        .production_override
95        .unwrap_or(project_config.config.production);
96    project_config
97}
98
99fn apply_feature_flags_scope(
100    flags: &mut Vec<FeatureFlag>,
101    resolved: &ProgrammaticAnalysisContext,
102    session: &AnalysisSession,
103) -> ProgrammaticResult<()> {
104    let workspace_roots = workspace_roots_for_session(resolved, session.workspaces())?;
105    if let Some(workspace_roots) = workspace_roots.as_ref() {
106        flags.retain(|flag| {
107            workspace_roots
108                .iter()
109                .any(|root| flag.path.starts_with(root))
110        });
111    }
112    if let Some(changed_files) = changed_files_for_run(resolved)? {
113        flags.retain(|flag| changed_files.contains(&flag.path));
114    }
115    if let Some(diff) = resolved.diff.as_ref() {
116        flags.retain(|flag| {
117            diff.key_for(&flag.path, session.root())
118                .is_none_or(|rel| diff.touches_file(&rel))
119        });
120    }
121    Ok(())
122}
123
124fn sort_and_limit_feature_flags(flags: &mut Vec<FeatureFlag>, top: Option<usize>) {
125    flags.sort_by(|a, b| {
126        a.path
127            .cmp(&b.path)
128            .then(a.line.cmp(&b.line))
129            .then(a.flag_name.cmp(&b.flag_name))
130    });
131
132    if let Some(top) = top {
133        flags.truncate(top);
134    }
135}