1use std::path::Path;
4use std::time::Duration;
5
6use fallow_types::envelope::{ElapsedMs, SchemaVersion, 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 struct FeatureFlagsOutputInput<'a> {
14 pub schema_version: u32,
15 pub version: String,
16 pub elapsed: Duration,
17 pub flags: &'a [FeatureFlag],
18 pub root: &'a Path,
19 pub meta: Option<FeatureFlagsMeta>,
20}
21
22#[derive(Debug, Clone, Serialize)]
24#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
25#[cfg_attr(feature = "schema", schemars(title = "fallow flags --format json"))]
26pub struct FeatureFlagsOutput {
27 pub schema_version: SchemaVersion,
28 pub version: ToolVersion,
29 pub elapsed_ms: ElapsedMs,
30 pub feature_flags: Vec<FeatureFlagFinding>,
31 pub total_flags: usize,
32 #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
33 pub meta: Option<FeatureFlagsMeta>,
34}
35
36#[derive(Debug, Clone, Serialize)]
38#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
39pub struct FeatureFlagFinding {
40 pub path: String,
41 pub flag_name: String,
42 pub kind: FeatureFlagKind,
43 pub confidence: FeatureFlagConfidence,
44 pub line: u32,
45 pub col: u32,
46 pub actions: Vec<FeatureFlagAction>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub sdk_name: Option<String>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub dead_code_overlap: Option<FeatureFlagDeadCodeOverlap>,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
55#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
56#[serde(rename_all = "snake_case")]
57pub enum FeatureFlagKind {
58 EnvironmentVariable,
59 SdkCall,
60 ConfigObject,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
65#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
66#[serde(rename_all = "lowercase")]
67pub enum FeatureFlagConfidence {
68 High,
69 Medium,
70 Low,
71}
72
73#[derive(Debug, Clone, Serialize)]
75#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
76pub struct FeatureFlagAction {
77 #[serde(rename = "type")]
78 pub kind: FeatureFlagActionType,
79 pub auto_fixable: bool,
80 pub description: String,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub comment: Option<String>,
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
87#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
88#[serde(rename_all = "kebab-case")]
89pub enum FeatureFlagActionType {
90 InvestigateFlag,
91 SuppressLine,
92}
93
94#[derive(Debug, Clone, Serialize)]
96#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
97pub struct FeatureFlagDeadCodeOverlap {
98 pub guarded_lines: u32,
99 pub dead_export_count: usize,
100 pub dead_exports: Vec<String>,
101}
102
103#[derive(Debug, Clone, Serialize)]
105#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
106pub struct FeatureFlagsMeta {
107 pub feature_flags: FeatureFlagsMetaDetails,
108}
109
110#[derive(Debug, Clone, Serialize)]
112#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
113pub struct FeatureFlagsMetaDetails {
114 pub description: &'static str,
115 pub kinds: FeatureFlagsKindMeta,
116 pub confidence: FeatureFlagsConfidenceMeta,
117 pub docs: &'static str,
118}
119
120#[derive(Debug, Clone, Serialize)]
122#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
123pub struct FeatureFlagsKindMeta {
124 pub environment_variable: &'static str,
125 pub sdk_call: &'static str,
126 pub config_object: &'static str,
127}
128
129#[derive(Debug, Clone, Serialize)]
131#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
132pub struct FeatureFlagsConfidenceMeta {
133 pub high: &'static str,
134 pub medium: &'static str,
135 pub low: &'static str,
136}
137
138#[must_use]
140pub fn build_feature_flags_output(input: FeatureFlagsOutputInput<'_>) -> FeatureFlagsOutput {
141 let feature_flags = input
142 .flags
143 .iter()
144 .map(|flag| feature_flag_finding(flag, input.root))
145 .collect();
146 FeatureFlagsOutput {
147 schema_version: SchemaVersion(input.schema_version),
148 version: ToolVersion(input.version),
149 elapsed_ms: ElapsedMs(input.elapsed.as_millis() as u64),
150 feature_flags,
151 total_flags: input.flags.len(),
152 meta: input.meta,
153 }
154}
155
156pub fn serialize_feature_flags_json_output(
163 output: FeatureFlagsOutput,
164 mode: RootEnvelopeMode,
165 analysis_run_id: Option<&str>,
166) -> Result<serde_json::Value, serde_json::Error> {
167 let mut value = serialize_named_json_output(output, "feature-flags", mode)?;
168 attach_telemetry_meta(&mut value, analysis_run_id);
169 Ok(value)
170}
171
172#[must_use]
174pub const fn feature_flags_meta() -> FeatureFlagsMeta {
175 FeatureFlagsMeta {
176 feature_flags: FeatureFlagsMetaDetails {
177 description: "Feature flag patterns detected via AST analysis",
178 kinds: FeatureFlagsKindMeta {
179 environment_variable: "process.env.FEATURE_* pattern (high confidence)",
180 sdk_call: "Feature flag SDK function call (high confidence)",
181 config_object: "Config object property access matching flag keywords (low confidence, heuristic)",
182 },
183 confidence: FeatureFlagsConfidenceMeta {
184 high: "Unambiguous pattern match (env vars, direct SDK calls)",
185 medium: "Pattern match with some ambiguity",
186 low: "Heuristic match (config objects), may produce false positives",
187 },
188 docs: "https://docs.fallow.tools/cli/flags",
189 },
190 }
191}
192
193fn feature_flag_finding(flag: &FeatureFlag, root: &Path) -> FeatureFlagFinding {
194 let path = flag
195 .path
196 .strip_prefix(root)
197 .unwrap_or(&flag.path)
198 .to_string_lossy()
199 .replace('\\', "/");
200 FeatureFlagFinding {
201 path,
202 flag_name: flag.flag_name.clone(),
203 kind: feature_flag_kind(flag.kind),
204 confidence: feature_flag_confidence(flag.confidence),
205 line: flag.line,
206 col: flag.col,
207 actions: feature_flag_actions(&flag.flag_name),
208 sdk_name: flag.sdk_name.clone(),
209 dead_code_overlap: feature_flag_dead_code_overlap(flag),
210 }
211}
212
213const fn feature_flag_kind(kind: FlagKind) -> FeatureFlagKind {
214 match kind {
215 FlagKind::EnvironmentVariable => FeatureFlagKind::EnvironmentVariable,
216 FlagKind::SdkCall => FeatureFlagKind::SdkCall,
217 FlagKind::ConfigObject => FeatureFlagKind::ConfigObject,
218 }
219}
220
221const fn feature_flag_confidence(confidence: FlagConfidence) -> FeatureFlagConfidence {
222 match confidence {
223 FlagConfidence::High => FeatureFlagConfidence::High,
224 FlagConfidence::Medium => FeatureFlagConfidence::Medium,
225 FlagConfidence::Low => FeatureFlagConfidence::Low,
226 }
227}
228
229fn feature_flag_actions(flag_name: &str) -> Vec<FeatureFlagAction> {
230 vec![
231 FeatureFlagAction {
232 kind: FeatureFlagActionType::InvestigateFlag,
233 auto_fixable: false,
234 description: format!("Verify whether feature flag '{flag_name}' is still active"),
235 comment: None,
236 },
237 FeatureFlagAction {
238 kind: FeatureFlagActionType::SuppressLine,
239 auto_fixable: false,
240 description: "Suppress with an inline comment".to_string(),
241 comment: Some("// fallow-ignore-next-line feature-flag".to_string()),
242 },
243 ]
244}
245
246fn feature_flag_dead_code_overlap(flag: &FeatureFlag) -> Option<FeatureFlagDeadCodeOverlap> {
247 if flag.guarded_dead_exports.is_empty() {
248 return None;
249 }
250 let guarded_lines = flag
251 .guard_line_start
252 .and_then(|start| flag.guard_line_end.map(|end| end.saturating_sub(start) + 1))
253 .unwrap_or(0);
254 Some(FeatureFlagDeadCodeOverlap {
255 guarded_lines,
256 dead_export_count: flag.guarded_dead_exports.len(),
257 dead_exports: flag.guarded_dead_exports.clone(),
258 })
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264 use std::path::PathBuf;
265
266 fn flag() -> FeatureFlag {
267 FeatureFlag {
268 path: PathBuf::from("/repo/src/app.ts"),
269 flag_name: "FEATURE_CHECKOUT".to_string(),
270 kind: FlagKind::EnvironmentVariable,
271 confidence: FlagConfidence::High,
272 line: 10,
273 col: 4,
274 guard_span_start: None,
275 guard_span_end: None,
276 sdk_name: None,
277 guard_line_start: Some(10),
278 guard_line_end: Some(12),
279 guarded_dead_exports: vec!["legacyCheckout".to_string()],
280 }
281 }
282
283 #[test]
284 fn feature_flags_json_output_uses_output_owned_root_contract() {
285 let output = build_feature_flags_output(FeatureFlagsOutputInput {
286 schema_version: 7,
287 version: "0.0.0".to_string(),
288 elapsed: Duration::from_millis(4),
289 flags: &[flag()],
290 root: Path::new("/repo"),
291 meta: Some(feature_flags_meta()),
292 });
293
294 let value = serialize_feature_flags_json_output(
295 output,
296 RootEnvelopeMode::Tagged,
297 Some("run-flags"),
298 )
299 .expect("feature flags output should serialize");
300
301 assert_eq!(value["kind"], "feature-flags");
302 assert_eq!(value["feature_flags"][0]["path"], "src/app.ts");
303 assert_eq!(
304 value["feature_flags"][0]["dead_code_overlap"]["guarded_lines"],
305 3
306 );
307 assert_eq!(
308 value["_meta"]["feature_flags"]["docs"],
309 "https://docs.fallow.tools/cli/flags"
310 );
311 assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-flags");
312 }
313}