use std::fmt::Write as _;
use fallow_output::{codeclimate_fingerprint_hash, markdown_table_code_span};
use fallow_types::flag_retirement::{
FlagRetirementReport, FlagSiteRole, RetirementFlag, RetirementFlagKind, RetirementReason,
};
pub const SARIF_RULE_ID: &str = "fallow/flag-retirement-candidate";
pub const CODECLIMATE_CHECK_NAME: &str = "fallow/flag-retirement";
const COMPACT_TAG: &str = "flag-retire";
const HELP_URI: &str = "https://docs.fallow.tools/cli/flags#retirement-report";
fn candidates(report: &FlagRetirementReport) -> impl Iterator<Item = &RetirementFlag> {
report.flags.iter().filter(|row| !row.reasons.is_empty())
}
fn reason_location(row: &RetirementFlag, reason: RetirementReason) -> Option<(&str, u32)> {
row.evidence
.iter()
.find(|evidence| evidence.reason == reason)
.map(|evidence| (evidence.path.as_str(), evidence.line))
}
fn row_location(row: &RetirementFlag) -> Option<(&str, u32, u32)> {
row.sites
.iter()
.find(|site| site.role == FlagSiteRole::Read)
.or_else(|| row.sites.first())
.map(|site| (site.path.as_str(), site.line, site.col))
.or_else(|| {
row.evidence
.first()
.map(|evidence| (evidence.path.as_str(), evidence.line, 0))
})
}
fn reason_codes(row: &RetirementFlag) -> Vec<&'static str> {
row.reasons.iter().map(|reason| reason.code()).collect()
}
const fn kind_code(kind: RetirementFlagKind) -> &'static str {
match kind {
RetirementFlagKind::EnvironmentVariable => "environment_variable",
RetirementFlagKind::SdkCall => "sdk_call",
RetirementFlagKind::ConfigObject => "config_object",
RetirementFlagKind::Constant => "constant",
RetirementFlagKind::VendorExport => "vendor_export",
}
}
#[must_use]
pub fn compact_lines(report: &FlagRetirementReport) -> Vec<String> {
let mut lines = Vec::new();
for row in candidates(report) {
for reason in &row.reasons {
let (path, line) = reason_location(row, *reason).unwrap_or(("", 0));
lines.push(format!(
"{COMPACT_TAG}:{}:{path}:{line}:{}",
reason.code(),
row.flag_name
));
}
}
lines
}
#[must_use]
pub fn sarif_rule() -> serde_json::Value {
serde_json::json!({
"id": SARIF_RULE_ID,
"shortDescription": { "text": "Feature flag is a retirement candidate" },
"fullDescription": {
"text": "The evidence shows that the flag can be retired. A person decides; Fallow does not remove code."
},
"helpUri": HELP_URI,
"defaultConfiguration": { "level": "note" },
})
}
#[must_use]
pub fn sarif_results(report: &FlagRetirementReport) -> Vec<serde_json::Value> {
candidates(report)
.filter_map(|row| {
let (path, line, col) = row_location(row)?;
let reasons = reason_codes(row);
let mut message = format!(
"Feature flag '{}' is a retirement candidate: {}",
row.flag_name,
reasons.join(", ")
);
if let Some(days) = row.age_days {
let _ = write!(message, " (age {days} days)");
}
Some(serde_json::json!({
"ruleId": SARIF_RULE_ID,
"level": "note",
"message": { "text": message },
"locations": [{
"physicalLocation": {
"artifactLocation": { "uri": crate::report::normalize_uri(path) },
"region": { "startLine": line.max(1), "startColumn": col + 1 },
}
}],
"properties": {
"flag_name": row.flag_name,
"kind": kind_code(row.kind),
"reasons": reasons,
"age_days": row.age_days,
},
}))
})
.collect()
}
#[must_use]
pub fn codeclimate_issues(report: &FlagRetirementReport) -> Vec<serde_json::Value> {
let mut issues = Vec::new();
for row in candidates(report) {
for reason in &row.reasons {
let Some((path, line)) = reason_location(row, *reason) else {
continue;
};
let detail = row
.evidence
.iter()
.find(|evidence| evidence.reason == *reason)
.map_or("", |evidence| evidence.detail.as_str());
let fingerprint = codeclimate_fingerprint_hash(&[
"flag-retirement",
kind_code(row.kind),
row.sdk_name.as_deref().unwrap_or(""),
row.workspace.as_deref().unwrap_or(""),
&row.flag_name,
reason.code(),
]);
issues.push(serde_json::json!({
"type": "issue",
"check_name": CODECLIMATE_CHECK_NAME,
"description": format!(
"Feature flag '{}' is a retirement candidate ({}): {detail}",
row.flag_name,
reason.code()
),
"categories": ["Clarity"],
"severity": "info",
"fingerprint": fingerprint,
"location": {
"path": crate::report::normalize_uri(path),
"lines": { "begin": line.max(1) },
}
}));
}
}
issues
}
#[must_use]
pub fn markdown_section(report: &FlagRetirementReport) -> String {
let rows: Vec<&RetirementFlag> = candidates(report).collect();
let mut out = format!(
"### Retirement candidates ({} of {} flags)\n\n",
rows.len(),
report.summary.listed_flags()
);
if rows.is_empty() {
out.push_str("No flag has a retirement reason.\n");
return out;
}
out.push_str("| Flag | Age | Sites | Reasons |\n|------|-----|-------|---------|\n");
for row in rows {
let age = row
.age_days
.map_or_else(|| "-".to_string(), |days| format!("{days} days"));
let _ = writeln!(
out,
"| {} | {age} | {} | {} |",
markdown_table_code_span(&row.flag_name),
row.read_sites,
reason_codes(row).join(", ")
);
}
out.push_str("\nFallow does not remove flags. A person decides what to retire.\n");
out
}
#[cfg(test)]
mod tests {
use super::*;
use fallow_types::flag_retirement::{
FlagAgeMode, RetirementEvidence, RetirementSite, RetirementSummary,
};
fn row(name: &str, reasons: &[RetirementReason], age: Option<u64>) -> RetirementFlag {
RetirementFlag {
flag_name: name.to_string(),
kind: RetirementFlagKind::SdkCall,
sdk_name: Some("LaunchDarkly".to_string()),
workspace: None,
sites: vec![
RetirementSite {
path: "src/def.ts".to_string(),
line: 2,
col: 0,
role: FlagSiteRole::Definition,
in_test: false,
},
RetirementSite {
path: "src/app.ts".to_string(),
line: 7,
col: 4,
role: FlagSiteRole::Read,
in_test: false,
},
],
read_sites: 1,
test_only: false,
first_seen: None,
oldest_surviving_site: None,
last_touched: None,
age_days: age,
reasons: reasons.to_vec(),
evidence: reasons
.iter()
.map(|reason| RetirementEvidence {
reason: *reason,
path: "src/app.ts".to_string(),
line: 7,
detail: format!("detail of {}", reason.code()),
})
.collect(),
actions: Vec::new(),
vendor: None,
}
}
fn report() -> FlagRetirementReport {
FlagRetirementReport {
generated_at_clock: None,
age_mode: FlagAgeMode::Blame,
vendor_state: None,
summary: RetirementSummary {
distinct_flags: 3,
..RetirementSummary::default()
},
regression: None,
max_flag_age: None,
flags: vec![
row(
"beta",
&[
RetirementReason::SingleReadSite,
RetirementReason::FullyRolledOut,
],
Some(120),
),
row("live", &[], Some(3)),
row("old", &[RetirementReason::ArchivedInVendor], None),
],
}
}
#[test]
fn compact_prints_one_line_per_reason_of_each_candidate() {
assert_eq!(
compact_lines(&report()),
vec![
"flag-retire:single-read-site:src/app.ts:7:beta",
"flag-retire:fully-rolled-out:src/app.ts:7:beta",
"flag-retire:archived-in-vendor:src/app.ts:7:old",
]
);
}
#[test]
fn sarif_gives_one_note_per_candidate_at_the_first_read_site() {
let results = sarif_results(&report());
assert_eq!(results.len(), 2);
assert_eq!(results[0]["ruleId"], SARIF_RULE_ID);
assert_eq!(results[0]["level"], "note");
let region = &results[0]["locations"][0]["physicalLocation"]["region"];
assert_eq!(region["startLine"], 7);
assert_eq!(region["startColumn"], 5);
assert_eq!(
results[0]["message"]["text"],
"Feature flag 'beta' is a retirement candidate: single-read-site, fully-rolled-out (age 120 days)"
);
assert_eq!(sarif_rule()["id"], SARIF_RULE_ID);
}
#[test]
fn codeclimate_fingerprints_do_not_follow_the_line() {
let issues = codeclimate_issues(&report());
assert_eq!(issues.len(), 3);
assert_eq!(issues[0]["check_name"], CODECLIMATE_CHECK_NAME);
assert_eq!(issues[0]["severity"], "info");
let mut moved = report();
moved.flags[0].evidence[0].line = 40;
let moved_issues = codeclimate_issues(&moved);
assert_eq!(issues[0]["fingerprint"], moved_issues[0]["fingerprint"]);
assert_ne!(issues[0]["fingerprint"], issues[1]["fingerprint"]);
}
#[test]
fn markdown_lists_the_candidates_in_one_table() {
let section = markdown_section(&report());
assert!(section.starts_with("### Retirement candidates (2 of 3 flags)"));
assert!(section.contains("| `beta` | 120 days | 1 | single-read-site, fully-rolled-out |"));
assert!(section.contains("| `old` | - | 1 | archived-in-vendor |"));
assert!(!section.contains("`live`"));
}
#[test]
fn markdown_keeps_a_flag_name_with_a_pipe_or_backtick_in_one_cell() {
let mut odd = report();
odd.flags[0].flag_name = "a|b`c".to_string();
let section = markdown_section(&odd);
assert!(section.contains("| ``a\\|b`c`` | 120 days |"), "{section}");
}
}