1use std::path::Path;
2use std::time::Instant;
3
4use fallow_engine::flag_report::{RetirementRequest, build_retirement_report};
5use fallow_engine::flag_retirement::{RetirementFacts, RetirementOptions};
6use fallow_engine::flag_vendor::{VendorExport, load_flag_state};
7
8use fallow_engine::{project_config::ProjectConfig, session::AnalysisSession};
9use fallow_output::{
10 DiffIndex, FEATURE_FLAGS_SCHEMA_VERSION, FeatureFlagsOutputInput, build_feature_flags_output,
11 feature_flags_meta,
12};
13use fallow_types::output_format::OutputFormat;
14use fallow_types::results::FeatureFlag;
15
16use crate::{
17 FeatureFlagsOptions, FeatureFlagsProgrammaticOutput, ProgrammaticError,
18 analysis_context::{
19 ProgrammaticAnalysisContext, changed_files_for_run,
20 resolve_programmatic_analysis_context_deferred_workspace, workspace_roots_for_session,
21 },
22};
23
24use super::ProgrammaticResult;
25
26pub fn run_feature_flags(
36 options: &FeatureFlagsOptions,
37) -> ProgrammaticResult<FeatureFlagsProgrammaticOutput> {
38 let resolved = resolve_programmatic_analysis_context_deferred_workspace(&options.analysis)?;
39 resolved.install(|| run_feature_flags_inner(options, &resolved))
40}
41
42fn run_feature_flags_inner(
43 options: &FeatureFlagsOptions,
44 resolved: &ProgrammaticAnalysisContext,
45) -> ProgrammaticResult<FeatureFlagsProgrammaticOutput> {
46 let start = Instant::now();
47 let vendor_export = load_vendor_export(options, &resolved.root)?;
48 resolved.ensure_not_cancelled("config load and file discovery")?;
49 let session = load_feature_flags_session(resolved)?;
50 let scan = if options.retirement.is_some() {
51 fallow_engine::flags::analyze_feature_flags_for_retirement(&session)
52 } else {
53 fallow_engine::flags::analyze_feature_flags_with_session(&session)
54 .map(|analysis| (analysis, RetirementFacts::default()))
55 };
56 let (analysis, retirement_facts) = scan.map_err(|err| {
57 super::dead_code::map_engine_error(
58 &err,
59 "feature-flag analysis failed",
60 "FALLOW_FEATURE_FLAGS_FAILED",
61 "feature-flags",
62 )
63 })?;
64 if analysis.files_scanned == 0 {
65 return Err(ProgrammaticError::new("no files discovered", 2)
66 .with_code("FALLOW_NO_FILES_DISCOVERED")
67 .with_context("feature-flags"));
68 }
69
70 let scope = feature_flags_scope(resolved, &session)?;
71 let all_flags = if options.retirement.is_some() {
72 analysis.flags.clone()
73 } else {
74 Vec::new()
75 };
76 let mut flags = analysis.flags;
77 flags.retain(|flag| scope.contains(&flag.path, session.root()));
78 let mut workspace_diagnostics = session.current_workspace_diagnostics();
79 let retirement = options.retirement.as_ref().map(|retirement| {
80 let build = build_retirement_report(RetirementRequest {
81 root: session.root(),
82 workspaces: session.workspaces(),
83 sites: retirement_facts.sites_for(&all_flags),
84 in_scope: &|path| scope.contains(path, session.root()),
85 whole_project: scope.is_whole_project(),
86 age_mode: retirement.flag_age,
87 cache_dir: (!resolved.no_cache).then_some(session.config().cache_dir.as_path()),
88 progress: None,
89 vendor_export: vendor_export.as_ref(),
90 vendor_key_prefix: session.config().flags.vendor_key_prefix.as_deref(),
91 max_flag_age: retirement.max_flag_age,
92 options: RetirementOptions {
93 sort: retirement.sort,
94 min_age_days: retirement.min_age_days,
95 reasons: retirement.reasons.clone(),
96 top: options.top,
97 },
98 });
99 workspace_diagnostics.extend(build.diagnostics.into_iter().map(|kind| {
100 fallow_config::WorkspaceDiagnostic::new(
101 session.root(),
102 session.root().to_path_buf(),
103 kind,
104 )
105 }));
106 build.report
107 });
108 sort_and_limit_feature_flags(&mut flags, options.top);
109
110 let output = build_feature_flags_output(FeatureFlagsOutputInput {
111 schema_version: FEATURE_FLAGS_SCHEMA_VERSION,
112 version: env!("CARGO_PKG_VERSION").to_string(),
113 elapsed: start.elapsed(),
114 flags: &flags,
115 root: session.root(),
116 workspace_diagnostics,
120 request_outcomes: resolved.request_outcomes(),
124 meta: resolved.explain_enabled().then(feature_flags_meta),
125 retirement,
126 });
127
128 Ok(FeatureFlagsProgrammaticOutput {
129 output,
130 telemetry_analysis_run_id: None,
131 })
132}
133
134fn load_feature_flags_session(
135 resolved: &ProgrammaticAnalysisContext,
136) -> ProgrammaticResult<AnalysisSession> {
137 let project_config = fallow_engine::project_config::config_for_project_with_load_options(
138 &resolved.root,
139 resolved.config_path.as_deref(),
140 fallow_config::ConfigLoadOptions {
141 allow_remote_extends: resolved.allow_remote_extends(),
142 },
143 )
144 .map_err(|err| {
145 ProgrammaticError::new(format!("failed to load config: {err}"), 2)
146 .with_code("FALLOW_CONFIG_LOAD_FAILED")
147 .with_context("analysis.configPath")
148 })?;
149 Ok(super::dead_code::attach_cancellation(
150 AnalysisSession::from_config(configure_project_for_feature_flags(
151 project_config,
152 resolved,
153 )),
154 resolved,
155 ))
156}
157
158fn configure_project_for_feature_flags(
159 mut project_config: ProjectConfig,
160 resolved: &ProgrammaticAnalysisContext,
161) -> ProjectConfig {
162 project_config.config.output = OutputFormat::Json;
163 project_config.config.no_cache = resolved.no_cache;
164 project_config.config.threads = resolved.threads;
165 project_config.config.production = resolved
166 .production_override
167 .unwrap_or(project_config.config.production);
168 project_config
169}
170
171struct FeatureFlagsScope<'a> {
174 workspace_roots: Option<Vec<std::path::PathBuf>>,
175 changed_files: Option<rustc_hash::FxHashSet<std::path::PathBuf>>,
176 diff: Option<&'a DiffIndex>,
177}
178
179impl FeatureFlagsScope<'_> {
180 fn is_whole_project(&self) -> bool {
181 self.workspace_roots.is_none() && self.changed_files.is_none() && self.diff.is_none()
182 }
183
184 fn contains(&self, path: &Path, root: &Path) -> bool {
185 self.workspace_roots
186 .as_ref()
187 .is_none_or(|roots| roots.iter().any(|workspace| path.starts_with(workspace)))
188 && self
189 .changed_files
190 .as_ref()
191 .is_none_or(|changed| changed.contains(path))
192 && self.diff.as_ref().is_none_or(|diff| {
193 diff.key_for(path, root)
194 .is_none_or(|rel| diff.touches_file(&rel))
195 })
196 }
197}
198
199fn feature_flags_scope<'a>(
200 resolved: &'a ProgrammaticAnalysisContext,
201 session: &AnalysisSession,
202) -> ProgrammaticResult<FeatureFlagsScope<'a>> {
203 let workspace_roots = workspace_roots_for_session(resolved, session.workspaces())?;
204 let changed_files = changed_files_for_run(resolved)?;
205 if changed_files.is_some() {
206 resolved
207 .measure_changed_since_scope(session.files().iter().map(|file| file.path.as_path()));
208 }
209 Ok(FeatureFlagsScope {
210 workspace_roots,
211 changed_files,
212 diff: resolved.diff_index(),
213 })
214}
215
216fn load_vendor_export(
219 options: &FeatureFlagsOptions,
220 root: &Path,
221) -> ProgrammaticResult<Option<VendorExport>> {
222 let Some(path) = options
223 .retirement
224 .as_ref()
225 .and_then(|retirement| retirement.flag_state.as_deref())
226 else {
227 return Ok(None);
228 };
229 load_flag_state(path, root).map(Some).map_err(|error| {
230 ProgrammaticError::new(error.message, 2)
231 .with_code("FALLOW_FLAG_STATE_INVALID")
232 .with_help(error.help)
233 .with_context("feature-flags.retirement.flagState")
234 })
235}
236
237fn sort_and_limit_feature_flags(flags: &mut Vec<FeatureFlag>, top: Option<usize>) {
238 flags.sort_by(|a, b| {
239 a.path
240 .cmp(&b.path)
241 .then(a.line.cmp(&b.line))
242 .then(a.flag_name.cmp(&b.flag_name))
243 });
244
245 if let Some(top) = top {
246 flags.truncate(top);
247 }
248}