fallow_types/flag_retirement.rs
1//! Flag retirement report types.
2//!
3//! `fallow flags --retirement` groups the per-site flag findings into one row
4//! per flag and attaches the evidence that the flag can be retired. A person
5//! makes the decision. Every action is `auto_fixable: false`, and Fallow never
6//! removes code for this report.
7
8use std::collections::BTreeMap;
9
10#[cfg(feature = "schema")]
11use schemars::JsonSchema;
12use serde::{Deserialize, Serialize};
13
14/// Why a flag is a retirement candidate.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
16#[cfg_attr(feature = "schema", derive(JsonSchema))]
17#[serde(rename_all = "kebab-case")]
18pub enum RetirementReason {
19 /// The flag has exactly one read site.
20 SingleReadSite,
21 /// Every read site is in a test, story or mock file.
22 TestOnly,
23 /// The flag is a `const` bound to a literal and used as a guard.
24 LiteralConstant,
25 /// The guarded branch and the other branch are the same code.
26 IdenticalBranches,
27 /// No branch of the guard holds code, so the flag does nothing.
28 EmptyBranch,
29 /// The guarded block holds unused exports.
30 GuardsDeadCode,
31 /// The flag is defined, but no code reads it.
32 DefinedNeverRead,
33 /// The vendor export says the flag is rolled out, or that the flag
34 /// serves one variation.
35 FullyRolledOut,
36 /// The vendor export says the flag is archived.
37 ArchivedInVendor,
38 /// The code reads the flag, but the vendor export does not hold its key.
39 MissingInVendor,
40 /// The vendor export holds the flag, but no code reads it.
41 VendorOnly,
42}
43
44impl RetirementReason {
45 /// Every reason, in report order.
46 pub const ALL: [Self; 11] = [
47 Self::SingleReadSite,
48 Self::TestOnly,
49 Self::LiteralConstant,
50 Self::IdenticalBranches,
51 Self::EmptyBranch,
52 Self::GuardsDeadCode,
53 Self::DefinedNeverRead,
54 Self::FullyRolledOut,
55 Self::ArchivedInVendor,
56 Self::MissingInVendor,
57 Self::VendorOnly,
58 ];
59
60 /// The wire code of the reason.
61 #[must_use]
62 pub const fn code(self) -> &'static str {
63 match self {
64 Self::SingleReadSite => "single-read-site",
65 Self::TestOnly => "test-only",
66 Self::LiteralConstant => "literal-constant",
67 Self::IdenticalBranches => "identical-branches",
68 Self::EmptyBranch => "empty-branch",
69 Self::GuardsDeadCode => "guards-dead-code",
70 Self::DefinedNeverRead => "defined-never-read",
71 Self::FullyRolledOut => "fully-rolled-out",
72 Self::ArchivedInVendor => "archived-in-vendor",
73 Self::MissingInVendor => "missing-in-vendor",
74 Self::VendorOnly => "vendor-only",
75 }
76 }
77}
78
79/// How a retirement row's flag was detected.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
81#[cfg_attr(feature = "schema", derive(JsonSchema))]
82#[serde(rename_all = "snake_case")]
83pub enum RetirementFlagKind {
84 /// Environment-variable read used as a toggle.
85 EnvironmentVariable,
86 /// Feature-flag SDK evaluation call or definition.
87 SdkCall,
88 /// Flag key in a configuration object.
89 ConfigObject,
90 /// A `const` binding with a flag-style name and a literal value. It is
91 /// in the retirement block only, not in `feature_flags[]`.
92 Constant,
93 /// A key in the `--flag-state` vendor export that no code reads. The
94 /// row has no sites.
95 VendorExport,
96}
97
98/// What a site does with the flag.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
100#[cfg_attr(feature = "schema", derive(JsonSchema))]
101#[serde(rename_all = "lowercase")]
102pub enum FlagSiteRole {
103 /// The site reads the flag value.
104 Read,
105 /// The site defines the flag.
106 Definition,
107}
108
109/// How the report measures the age of a flag.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
111#[cfg_attr(feature = "schema", derive(JsonSchema))]
112#[serde(rename_all = "lowercase")]
113pub enum FlagAgeMode {
114 /// `git blame` of the flag sites. The age is a lower bound: it is the age
115 /// of the oldest line that still holds the flag.
116 #[default]
117 Blame,
118 /// `git log -S` per flag name. The age is the date of the first commit
119 /// that added the name.
120 Pickaxe,
121 /// No age.
122 Off,
123}
124
125/// State of a flag in a `--flag-state` vendor export.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
127#[cfg_attr(feature = "schema", derive(JsonSchema))]
128#[serde(rename_all = "snake_case")]
129pub enum VendorFlagState {
130 /// The flag is on and can serve more than one variation.
131 On,
132 /// The flag is off.
133 Off,
134 /// The flag serves one variation to every user.
135 RolledOut,
136 /// The vendor archived the flag.
137 Archived,
138 /// The flag runs an experiment.
139 Experiment,
140}
141
142/// The vendor state of one flag in the retirement report.
143#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
144#[cfg_attr(feature = "schema", derive(JsonSchema))]
145pub struct RetirementVendor {
146 /// The key in the vendor export, before `flags.vendorKeyPrefix` is
147 /// removed.
148 pub key: String,
149 /// The state in the vendor export.
150 pub state: VendorFlagState,
151 /// Whether the flag serves one variation, when the export says so.
152 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub serves_single_variation: Option<bool>,
154 /// When the vendor created the flag, as the export gives it.
155 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub created_at: Option<String>,
157 /// When the vendor last evaluated the flag, as the export gives it.
158 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub last_evaluated_at: Option<String>,
160}
161
162/// The `--flag-state` vendor export that the report read.
163#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
164#[cfg_attr(feature = "schema", derive(JsonSchema))]
165pub struct RetirementVendorState {
166 /// The vendor name from the export, for example `launchdarkly`.
167 pub source: String,
168 /// When the export was made, as the export gives it.
169 pub exported_at: String,
170 /// Days between `exported_at` and the analysis clock. `null` when the
171 /// date cannot be read.
172 pub export_age_days: Option<u64>,
173 /// Number of flags in the export.
174 pub flags: usize,
175}
176
177/// One site of a flag in the retirement report.
178#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
179#[cfg_attr(feature = "schema", derive(JsonSchema))]
180pub struct RetirementSite {
181 /// File path relative to the analysed root.
182 pub path: String,
183 /// 1-based line.
184 pub line: u32,
185 /// 0-based byte column.
186 pub col: u32,
187 /// What the site does with the flag.
188 pub role: FlagSiteRole,
189 /// Whether the file is a test, story or mock file.
190 pub in_test: bool,
191}
192
193/// A commit that git history links to a flag.
194#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
195#[cfg_attr(feature = "schema", derive(JsonSchema))]
196pub struct FlagCommit {
197 /// Abbreviated commit hash.
198 pub commit: String,
199 /// Commit date in UTC, as `YYYY-MM-DD`.
200 pub date: String,
201}
202
203/// One piece of evidence for a retirement reason.
204#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
205#[cfg_attr(feature = "schema", derive(JsonSchema))]
206pub struct RetirementEvidence {
207 /// The reason this evidence supports.
208 pub reason: RetirementReason,
209 /// File path relative to the analysed root. For `vendor-only`, the path
210 /// of the `--flag-state` file: relative to the root when the file is
211 /// inside it, else as given.
212 pub path: String,
213 /// 1-based line.
214 pub line: u32,
215 /// What the evidence shows.
216 pub detail: String,
217}
218
219/// Action discriminants for a retirement row.
220#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
221#[cfg_attr(feature = "schema", derive(JsonSchema))]
222#[serde(rename_all = "kebab-case")]
223pub enum RetirementActionType {
224 /// A person reviews the flag for retirement.
225 ReviewRetirement,
226}
227
228/// A follow-up action for a retirement candidate.
229#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
230#[cfg_attr(feature = "schema", derive(JsonSchema))]
231pub struct RetirementAction {
232 /// Action discriminator, serialized as `type`.
233 #[serde(rename = "type")]
234 pub kind: RetirementActionType,
235 /// Always `false`: Fallow never removes a flag.
236 pub auto_fixable: bool,
237 /// Human-readable action description.
238 pub description: String,
239}
240
241/// One flag in the retirement report.
242#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
243#[cfg_attr(feature = "schema", derive(JsonSchema))]
244pub struct RetirementFlag {
245 /// Flag identifier.
246 pub flag_name: String,
247 /// How the flag was detected.
248 pub kind: RetirementFlagKind,
249 /// Flag SDK, for SDK flags with a known provider.
250 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub sdk_name: Option<String>,
252 /// Workspace root relative to the analysed root, when the project has
253 /// workspaces and the flag is inside one. Part of the flag identity.
254 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub workspace: Option<String>,
256 /// Every site of the flag, sorted by path, line and column.
257 pub sites: Vec<RetirementSite>,
258 /// Number of sites in this row that read the flag.
259 pub read_sites: usize,
260 /// Whether every read site is in a test, story or mock file. Read sites
261 /// of the same flag in other workspaces count too.
262 pub test_only: bool,
263 /// First commit that added the flag name. Set in `pickaxe` mode only.
264 pub first_seen: Option<FlagCommit>,
265 /// Oldest commit among the lines that still hold the flag.
266 pub oldest_surviving_site: Option<FlagCommit>,
267 /// Newest commit among the lines that still hold the flag.
268 pub last_touched: Option<FlagCommit>,
269 /// Days between the flag's oldest known commit and the analysis clock.
270 /// In `blame` mode this is a lower bound.
271 pub age_days: Option<u64>,
272 /// Retirement reasons, in report order. Empty for a flag that is not a
273 /// candidate.
274 pub reasons: Vec<RetirementReason>,
275 /// Evidence for each reason.
276 pub evidence: Vec<RetirementEvidence>,
277 /// Follow-up actions. Empty for a flag that is not a candidate.
278 pub actions: Vec<RetirementAction>,
279 /// The vendor state of the flag. Present only with `--flag-state`, for
280 /// a flag whose key is in the export.
281 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub vendor: Option<RetirementVendor>,
283}
284
285/// Totals of the retirement report.
286#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
287#[cfg_attr(feature = "schema", derive(JsonSchema))]
288pub struct RetirementSummary {
289 /// Distinct flags in the code in scope, before `--min-age` and
290 /// `--reason`. The `vendor-only` rows of a `--flag-state` export do not
291 /// count here, so the count does not change when a key is added in the
292 /// vendor only. `by_reason` counts them.
293 pub distinct_flags: usize,
294 /// Rows in scope with at least one reason, `vendor-only` rows included.
295 pub candidates: usize,
296 /// Number of rows in scope per reason.
297 pub by_reason: BTreeMap<RetirementReason, usize>,
298}
299
300impl RetirementSummary {
301 /// All rows in scope: the flags in the code and the `vendor-only` rows.
302 #[must_use]
303 pub fn listed_flags(&self) -> usize {
304 self.distinct_flags
305 + self
306 .by_reason
307 .get(&RetirementReason::VendorOnly)
308 .copied()
309 .unwrap_or(0)
310 }
311}
312
313/// The `retirement` block of `fallow flags --retirement --format json`.
314#[derive(Debug, Clone, PartialEq, Serialize)]
315#[cfg_attr(feature = "schema", derive(JsonSchema))]
316pub struct FlagRetirementReport {
317 /// The analysis clock that ages count from, as an RFC 3339 UTC
318 /// timestamp. `null` when the age mode is `off`, and also when no git
319 /// history is available: outside a repository, on a branch without
320 /// commits, or in a shallow clone. A `workspace_diagnostics` entry then
321 /// gives the reason.
322 pub generated_at_clock: Option<String>,
323 /// How the report measured flag age.
324 pub age_mode: FlagAgeMode,
325 /// The vendor export that the report read. Present only with
326 /// `--flag-state`.
327 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub vendor_state: Option<RetirementVendorState>,
329 /// Totals for the flags in scope.
330 pub summary: RetirementSummary,
331 /// Verdict of `--fail-on-regression` against a flags regression
332 /// baseline. Present only when the gate ran.
333 #[serde(default, skip_serializing_if = "Option::is_none")]
334 pub regression: Option<FlagRegressionResult>,
335 /// Verdict of `--max-flag-age`. Present only with that option.
336 #[serde(default, skip_serializing_if = "Option::is_none")]
337 pub max_flag_age: Option<FlagAgeGate>,
338 /// One row per flag after the `--min-age`, `--reason`, `--sort` and
339 /// `--top` options. A row with an empty `reasons` array is not a
340 /// candidate.
341 pub flags: Vec<RetirementFlag>,
342}
343
344/// One count that the flags regression gate compares.
345#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
346#[cfg_attr(feature = "schema", derive(JsonSchema))]
347pub struct FlagRegressionMetric {
348 /// `distinct_flags`, or a reason code from `--reason`.
349 pub metric: String,
350 /// The count in the baseline.
351 pub baseline: usize,
352 /// The count in this run.
353 pub current: usize,
354 /// `current - baseline`.
355 pub delta: i64,
356 /// Whether the growth is more than the tolerance.
357 pub exceeded: bool,
358}
359
360/// Verdict of the flags regression gate.
361#[derive(Debug, Clone, PartialEq, Serialize)]
362#[cfg_attr(feature = "schema", derive(JsonSchema))]
363pub struct FlagRegressionResult {
364 /// Outcome of the gate.
365 pub status: crate::envelope::RegressionStatus,
366 /// The `--tolerance` value. Absent when the status is `skipped`.
367 #[serde(default, skip_serializing_if = "Option::is_none")]
368 pub tolerance: Option<f64>,
369 /// How to read `tolerance`. Absent when the status is `skipped`.
370 #[serde(default, skip_serializing_if = "Option::is_none")]
371 pub tolerance_kind: Option<crate::envelope::RegressionToleranceKind>,
372 /// The compared counts: `distinct_flags` first, then each `--reason`
373 /// code. Empty when the status is `skipped`.
374 pub metrics: Vec<FlagRegressionMetric>,
375 /// Whether one count grew more than the tolerance.
376 pub exceeded: bool,
377 /// Why the gate did not run. Present only when the status is `skipped`.
378 #[serde(default, skip_serializing_if = "Option::is_none")]
379 pub reason: Option<String>,
380}
381
382/// A flag that is older than `--max-flag-age`.
383#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
384#[cfg_attr(feature = "schema", derive(JsonSchema))]
385pub struct FlagAgeGateEntry {
386 /// Flag identifier.
387 pub flag_name: String,
388 /// How the flag was detected.
389 pub kind: RetirementFlagKind,
390 /// Flag SDK, for SDK flags with a known provider.
391 #[serde(default, skip_serializing_if = "Option::is_none")]
392 pub sdk_name: Option<String>,
393 /// Workspace root of the flag, in a project with workspaces.
394 #[serde(default, skip_serializing_if = "Option::is_none")]
395 pub workspace: Option<String>,
396 /// Age of the flag in days.
397 pub age_days: u64,
398}
399
400/// Verdict of `--max-flag-age`.
401#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
402#[cfg_attr(feature = "schema", derive(JsonSchema))]
403pub struct FlagAgeGate {
404 /// Outcome of the gate. `skipped` when git history is not available (a
405 /// shallow clone or no repository), so no age was measured. A skipped
406 /// gate does not fail the run.
407 pub status: crate::envelope::RegressionStatus,
408 /// The `--max-flag-age` value in days.
409 pub max_days: u64,
410 /// Whether one flag in scope is older than `max_days`.
411 pub exceeded: bool,
412 /// Flags in the code in scope without a measured age. The gate cannot
413 /// check these flags.
414 pub unmeasured: usize,
415 /// Why the gate did not run. Present only when the status is `skipped`.
416 #[serde(default, skip_serializing_if = "Option::is_none")]
417 pub reason: Option<String>,
418 /// The flags in scope that are older than `max_days`, oldest first.
419 /// The `--reason`, `--min-age` and `--top` options do not change this
420 /// list.
421 pub flags: Vec<FlagAgeGateEntry>,
422}