Skip to main content

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}