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