1use std::path::Path;
4use std::time::Duration;
5
6use fallow_types::envelope::{ElapsedMs, SchemaVersion, TelemetryMeta, ToolVersion};
7use fallow_types::results::{FeatureFlag, FlagConfidence, FlagKind};
8use serde::Serialize;
9
10use crate::root_envelopes::{RootEnvelopeMode, attach_telemetry_meta, serialize_named_json_output};
11
12pub const FEATURE_FLAGS_SCHEMA_VERSION: u32 = 8;
14
15#[cfg(feature = "schema")]
17#[allow(dead_code, reason = "schema-only type used by the field projection")]
18#[derive(schemars::JsonSchema)]
19#[schemars(extend("const" = FEATURE_FLAGS_SCHEMA_VERSION))]
20struct FeatureFlagsSchemaVersion(u32);
21
22pub struct FeatureFlagsOutputInput<'a> {
24 pub schema_version: u32,
26 pub version: String,
28 pub elapsed: Duration,
30 pub flags: &'a [FeatureFlag],
32 pub root: &'a Path,
34 pub meta: Option<FeatureFlagsMeta>,
36}
37
38#[derive(Debug, Clone, Serialize)]
40#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
41#[cfg_attr(feature = "schema", schemars(title = "fallow flags --format json"))]
42pub struct FeatureFlagsOutput {
43 #[cfg_attr(feature = "schema", schemars(with = "FeatureFlagsSchemaVersion"))]
45 pub schema_version: SchemaVersion,
46 pub version: ToolVersion,
48 pub elapsed_ms: ElapsedMs,
50 pub feature_flags: Vec<FeatureFlagFinding>,
52 pub total_flags: usize,
54 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
56 pub meta: Option<FeatureFlagsMeta>,
57}
58
59#[derive(Debug, Clone, Serialize)]
61#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
62pub struct FeatureFlagFinding {
63 pub path: String,
65 pub flag_name: String,
67 pub kind: FeatureFlagKind,
69 pub confidence: FeatureFlagConfidence,
71 pub line: u32,
73 pub col: u32,
75 pub actions: Vec<FeatureFlagAction>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub sdk_name: Option<String>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub dead_code_overlap: Option<FeatureFlagDeadCodeOverlap>,
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
87#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
88#[serde(rename_all = "snake_case")]
89pub enum FeatureFlagKind {
90 EnvironmentVariable,
92 SdkCall,
94 ConfigObject,
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
100#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
101#[serde(rename_all = "lowercase")]
102pub enum FeatureFlagConfidence {
103 High,
105 Medium,
107 Low,
109}
110
111#[derive(Debug, Clone, Serialize)]
113#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
114pub struct FeatureFlagAction {
115 #[serde(rename = "type")]
117 pub kind: FeatureFlagActionType,
118 pub auto_fixable: bool,
120 pub description: String,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub comment: Option<String>,
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
129#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
130#[serde(rename_all = "kebab-case")]
131pub enum FeatureFlagActionType {
132 InvestigateFlag,
134 SuppressLine,
136}
137
138#[derive(Debug, Clone, Serialize)]
140#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
141pub struct FeatureFlagDeadCodeOverlap {
142 pub guarded_lines: u32,
144 pub dead_export_count: usize,
146 pub dead_exports: Vec<String>,
148}
149
150#[derive(Debug, Clone, Serialize)]
157#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
158pub struct FeatureFlagsMeta {
159 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub feature_flags: Option<FeatureFlagsMetaDetails>,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub telemetry: Option<TelemetryMeta>,
165}
166
167#[derive(Debug, Clone, Serialize)]
169#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
170pub struct FeatureFlagsMetaDetails {
171 pub description: &'static str,
173 pub kinds: FeatureFlagsKindMeta,
175 pub confidence: FeatureFlagsConfidenceMeta,
177 pub docs: &'static str,
179}
180
181#[derive(Debug, Clone, Serialize)]
183#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
184pub struct FeatureFlagsKindMeta {
185 pub environment_variable: &'static str,
187 pub sdk_call: &'static str,
189 pub config_object: &'static str,
191}
192
193#[derive(Debug, Clone, Serialize)]
195#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
196pub struct FeatureFlagsConfidenceMeta {
197 pub high: &'static str,
199 pub medium: &'static str,
201 pub low: &'static str,
203}
204
205#[must_use]
207pub fn build_feature_flags_output(input: FeatureFlagsOutputInput<'_>) -> FeatureFlagsOutput {
208 let feature_flags = input
209 .flags
210 .iter()
211 .map(|flag| feature_flag_finding(flag, input.root))
212 .collect();
213 FeatureFlagsOutput {
214 schema_version: SchemaVersion(input.schema_version),
215 version: ToolVersion(input.version),
216 elapsed_ms: ElapsedMs(input.elapsed.as_millis() as u64),
217 feature_flags,
218 total_flags: input.flags.len(),
219 meta: input.meta,
220 }
221}
222
223pub fn serialize_feature_flags_json_output(
230 output: FeatureFlagsOutput,
231 mode: RootEnvelopeMode,
232 analysis_run_id: Option<&str>,
233) -> Result<serde_json::Value, serde_json::Error> {
234 let mut value = serialize_named_json_output(output, "feature-flags", mode)?;
235 attach_telemetry_meta(&mut value, analysis_run_id);
236 Ok(value)
237}
238
239#[must_use]
241pub const fn feature_flags_meta() -> FeatureFlagsMeta {
242 FeatureFlagsMeta {
243 telemetry: None,
244 feature_flags: Some(FeatureFlagsMetaDetails {
245 description: "Feature flag patterns detected via AST analysis",
246 kinds: FeatureFlagsKindMeta {
247 environment_variable: "process.env.FEATURE_* pattern (high confidence)",
248 sdk_call: "Feature flag SDK function call (high confidence)",
249 config_object: "Config object property access matching flag keywords (low confidence, heuristic)",
250 },
251 confidence: FeatureFlagsConfidenceMeta {
252 high: "Unambiguous pattern match (env vars, direct SDK calls)",
253 medium: "Pattern match with some ambiguity",
254 low: "Heuristic match (config objects), may produce false positives",
255 },
256 docs: "https://docs.fallow.tools/cli/flags",
257 }),
258 }
259}
260
261fn feature_flag_finding(flag: &FeatureFlag, root: &Path) -> FeatureFlagFinding {
262 let path = flag
263 .path
264 .strip_prefix(root)
265 .unwrap_or(&flag.path)
266 .to_string_lossy()
267 .replace('\\', "/");
268 FeatureFlagFinding {
269 path,
270 flag_name: flag.flag_name.clone(),
271 kind: feature_flag_kind(flag.kind),
272 confidence: feature_flag_confidence(flag.confidence),
273 line: flag.line,
274 col: flag.col,
275 actions: feature_flag_actions(&flag.flag_name),
276 sdk_name: flag.sdk_name.clone(),
277 dead_code_overlap: feature_flag_dead_code_overlap(flag),
278 }
279}
280
281const fn feature_flag_kind(kind: FlagKind) -> FeatureFlagKind {
282 match kind {
283 FlagKind::EnvironmentVariable => FeatureFlagKind::EnvironmentVariable,
284 FlagKind::SdkCall => FeatureFlagKind::SdkCall,
285 FlagKind::ConfigObject => FeatureFlagKind::ConfigObject,
286 }
287}
288
289const fn feature_flag_confidence(confidence: FlagConfidence) -> FeatureFlagConfidence {
290 match confidence {
291 FlagConfidence::High => FeatureFlagConfidence::High,
292 FlagConfidence::Medium => FeatureFlagConfidence::Medium,
293 FlagConfidence::Low => FeatureFlagConfidence::Low,
294 }
295}
296
297fn feature_flag_actions(flag_name: &str) -> Vec<FeatureFlagAction> {
298 vec![
299 FeatureFlagAction {
300 kind: FeatureFlagActionType::InvestigateFlag,
301 auto_fixable: false,
302 description: format!("Verify whether feature flag '{flag_name}' is still active"),
303 comment: None,
304 },
305 FeatureFlagAction {
306 kind: FeatureFlagActionType::SuppressLine,
307 auto_fixable: false,
308 description: "Suppress with an inline comment".to_string(),
309 comment: Some("// fallow-ignore-next-line feature-flag".to_string()),
310 },
311 ]
312}
313
314fn feature_flag_dead_code_overlap(flag: &FeatureFlag) -> Option<FeatureFlagDeadCodeOverlap> {
315 if flag.guarded_dead_exports.is_empty() {
316 return None;
317 }
318 let guarded_lines = flag
319 .guard_line_start
320 .and_then(|start| flag.guard_line_end.map(|end| end.saturating_sub(start) + 1))
321 .unwrap_or(0);
322 Some(FeatureFlagDeadCodeOverlap {
323 guarded_lines,
324 dead_export_count: flag.guarded_dead_exports.len(),
325 dead_exports: flag.guarded_dead_exports.clone(),
326 })
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332 use std::path::PathBuf;
333
334 fn flag() -> FeatureFlag {
335 FeatureFlag {
336 path: PathBuf::from("/repo/src/app.ts"),
337 flag_name: "FEATURE_CHECKOUT".to_string(),
338 kind: FlagKind::EnvironmentVariable,
339 confidence: FlagConfidence::High,
340 line: 10,
341 col: 4,
342 guard_span_start: None,
343 guard_span_end: None,
344 sdk_name: None,
345 guard_line_start: Some(10),
346 guard_line_end: Some(12),
347 guarded_dead_exports: vec!["legacyCheckout".to_string()],
348 }
349 }
350
351 #[test]
352 fn feature_flags_json_output_uses_output_owned_root_contract() {
353 let output = build_feature_flags_output(FeatureFlagsOutputInput {
354 schema_version: 7,
355 version: "0.0.0".to_string(),
356 elapsed: Duration::from_millis(4),
357 flags: &[flag()],
358 root: Path::new("/repo"),
359 meta: Some(feature_flags_meta()),
360 });
361
362 let value = serialize_feature_flags_json_output(
363 output,
364 RootEnvelopeMode::Tagged,
365 Some("run-flags"),
366 )
367 .expect("feature flags output should serialize");
368
369 assert_eq!(value["kind"], "feature-flags");
370 assert_eq!(value["feature_flags"][0]["path"], "src/app.ts");
371 assert_eq!(
372 value["feature_flags"][0]["dead_code_overlap"]["guarded_lines"],
373 3
374 );
375 assert_eq!(
376 value["_meta"]["feature_flags"]["docs"],
377 "https://docs.fallow.tools/cli/flags"
378 );
379 assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-flags");
380 }
381
382 #[test]
383 fn feature_flags_json_output_without_explain_emits_telemetry_only_meta() {
384 let output = build_feature_flags_output(FeatureFlagsOutputInput {
389 schema_version: 7,
390 version: "0.0.0".to_string(),
391 elapsed: Duration::from_millis(4),
392 flags: &[flag()],
393 root: Path::new("/repo"),
394 meta: None,
395 });
396
397 let value = serialize_feature_flags_json_output(
398 output,
399 RootEnvelopeMode::Tagged,
400 Some("run-flags"),
401 )
402 .expect("feature flags output should serialize");
403
404 assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-flags");
405 assert!(
406 value["_meta"].get("feature_flags").is_none(),
407 "feature_flags details are absent without --explain"
408 );
409 }
410}