1use std::io::Read;
15use std::path::Path;
16
17use fallow_types::flag_retirement::{
18 FlagSiteRole, RetirementEvidence, RetirementFlag, RetirementFlagKind, RetirementReason,
19 RetirementVendor, RetirementVendorState, VendorFlagState,
20};
21use rustc_hash::{FxHashMap, FxHashSet};
22use serde::Deserialize;
23
24pub const FLAG_STATE_SCHEMA_VERSION: u32 = 1;
26
27pub const MAX_FLAG_STATE_BYTES: u64 = 16 * 1024 * 1024;
29
30pub const STALE_EXPORT_DAYS: u64 = 30;
32
33const SECS_PER_DAY: u64 = 86_400;
35
36const FLAG_STATE_HELP: &str = "See https://docs.fallow.tools/cli/flags#vendor-flag-state for the schema and a jq recipe for each vendor.";
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct FlagStateError {
42 pub message: String,
44 pub help: &'static str,
46}
47
48impl FlagStateError {
49 fn new(message: impl Into<String>) -> Self {
50 Self {
51 message: message.into(),
52 help: FLAG_STATE_HELP,
53 }
54 }
55}
56
57#[derive(Debug, Deserialize)]
58#[serde(deny_unknown_fields)]
59struct FlagStateFile {
60 schema_version: u32,
61 source: String,
62 exported_at: String,
63 flags: Vec<FlagStateEntry>,
64}
65
66#[derive(Debug, Deserialize)]
67#[serde(deny_unknown_fields)]
68struct FlagStateEntry {
69 key: String,
70 state: VendorFlagState,
71 #[serde(default)]
72 serves_single_variation: Option<bool>,
73 #[serde(default)]
74 created_at: Option<String>,
75 #[serde(default)]
76 last_evaluated_at: Option<String>,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct VendorFlag {
82 pub state: RetirementVendor,
84 pub line: u32,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct VendorExport {
91 pub source: String,
93 pub exported_at: String,
95 pub display_path: String,
98 pub flags: Vec<VendorFlag>,
100}
101
102pub fn load_flag_state(path: &Path, root: &Path) -> Result<VendorExport, FlagStateError> {
109 let file = std::fs::File::open(path).map_err(|error| {
110 FlagStateError::new(format!(
111 "cannot read the flag state file {}: {error}",
112 path.display()
113 ))
114 })?;
115 let mut bytes = Vec::new();
116 file.take(MAX_FLAG_STATE_BYTES + 1)
117 .read_to_end(&mut bytes)
118 .map_err(|error| {
119 FlagStateError::new(format!(
120 "cannot read the flag state file {}: {error}",
121 path.display()
122 ))
123 })?;
124 if bytes.len() as u64 > MAX_FLAG_STATE_BYTES {
125 return Err(FlagStateError::new(format!(
126 "the flag state file {} is larger than {} MiB",
127 path.display(),
128 MAX_FLAG_STATE_BYTES / (1024 * 1024)
129 )));
130 }
131 parse_flag_state(&bytes, display_path(path, root))
132}
133
134fn display_path(path: &Path, root: &Path) -> String {
140 let canonical_path = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
141 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
142 let shown = match canonical_path.strip_prefix(&canonical_root) {
143 Ok(relative) => relative.to_path_buf(),
144 Err(_) => canonical_path
145 .file_name()
146 .map_or_else(|| path.to_path_buf(), std::path::PathBuf::from),
147 };
148 shown.to_string_lossy().replace('\\', "/")
149}
150
151pub fn parse_flag_state(
159 bytes: &[u8],
160 display_path: String,
161) -> Result<VendorExport, FlagStateError> {
162 let text = std::str::from_utf8(bytes)
163 .map_err(|_| FlagStateError::new(format!("{display_path} is not UTF-8 text")))?;
164 let file: FlagStateFile = serde_json::from_str(text)
165 .map_err(|error| FlagStateError::new(format!("{display_path} is not valid: {error}")))?;
166 if file.schema_version != FLAG_STATE_SCHEMA_VERSION {
167 return Err(FlagStateError::new(format!(
168 "{display_path} has schema_version {}, but this build reads schema_version {FLAG_STATE_SCHEMA_VERSION}",
169 file.schema_version
170 )));
171 }
172 if file.source.trim().is_empty() {
173 return Err(FlagStateError::new(format!(
174 "{display_path} has an empty source"
175 )));
176 }
177 if date_epoch(&file.exported_at).is_none() {
178 return Err(FlagStateError::new(format!(
179 "{display_path} has exported_at {:?}, which does not start with a YYYY-MM-DD date",
180 file.exported_at
181 )));
182 }
183 let lines = key_lines(text);
184 let mut seen: FxHashSet<&str> = FxHashSet::default();
185 for entry in &file.flags {
186 if entry.key.is_empty() {
187 return Err(FlagStateError::new(format!(
188 "{display_path} has a flag with an empty key"
189 )));
190 }
191 if !seen.insert(entry.key.as_str()) {
192 return Err(FlagStateError::new(format!(
193 "{display_path} has the key {:?} more than one time",
194 entry.key
195 )));
196 }
197 }
198 let flags = file
199 .flags
200 .into_iter()
201 .map(|entry| VendorFlag {
202 line: lines.get(entry.key.as_str()).copied().unwrap_or(1),
203 state: RetirementVendor {
204 key: entry.key,
205 state: entry.state,
206 serves_single_variation: entry.serves_single_variation,
207 created_at: entry.created_at,
208 last_evaluated_at: entry.last_evaluated_at,
209 },
210 })
211 .collect();
212 Ok(VendorExport {
213 source: file.source,
214 exported_at: file.exported_at,
215 display_path,
216 flags,
217 })
218}
219
220fn key_lines(text: &str) -> FxHashMap<String, u32> {
222 const KEY_TOKEN: &str = "\"key\"";
223 let mut lines = FxHashMap::default();
224 let mut from = 0;
225 while let Some(offset) = text[from..].find(KEY_TOKEN) {
226 let start = from + offset;
227 from = start + KEY_TOKEN.len();
228 let rest = text[from..].trim_start();
229 let Some(value) = rest.strip_prefix(':') else {
230 continue;
231 };
232 let mut values =
233 serde_json::Deserializer::from_str(value.trim_start()).into_iter::<String>();
234 let Some(Ok(key)) = values.next() else {
235 continue;
236 };
237 let line = u32::try_from(text[..start].matches('\n').count() + 1).unwrap_or(u32::MAX);
238 lines.entry(key).or_insert(line);
239 }
240 lines
241}
242
243fn date_epoch(text: &str) -> Option<u64> {
245 crate::clock::utc_midnight_epoch(text.get(..10)?)
246}
247
248pub struct VendorMatch<'a> {
250 pub export: &'a VendorExport,
252 pub key_prefix: Option<&'a str>,
255 pub code_flag_names: &'a FxHashSet<String>,
257 pub project_sdk_labels: &'a FxHashSet<String>,
260 pub add_vendor_only: bool,
263 pub clock_epoch_secs: u64,
265}
266
267pub fn apply_vendor_state(
277 rows: &mut Vec<RetirementFlag>,
278 input: &VendorMatch<'_>,
279) -> RetirementVendorState {
280 let export = input.export;
281 let by_name: FxHashMap<&str, &VendorFlag> = export
282 .flags
283 .iter()
284 .map(|flag| (code_name(&flag.state.key, input.key_prefix), flag))
285 .collect();
286 let source = normalize_label(&export.source);
287 let source_is_project_sdk = input
288 .project_sdk_labels
289 .iter()
290 .any(|sdk| label_matches_source(sdk, &source));
291 for row in rows.iter_mut() {
292 if row.kind != RetirementFlagKind::SdkCall
293 || (source_is_project_sdk
294 && row.sdk_name.is_some()
295 && !sdk_matches_source(row, &source))
296 {
297 continue;
298 }
299 match by_name.get(row.flag_name.as_str()) {
300 Some(flag) => add_state_reasons(row, flag, export),
301 None => add_missing_reason(row, export),
302 }
303 }
304 if input.add_vendor_only {
305 for flag in &export.flags {
306 let name = code_name(&flag.state.key, input.key_prefix);
307 if !input.code_flag_names.contains(name) {
308 rows.push(vendor_only_row(name, flag, export));
309 }
310 }
311 }
312 RetirementVendorState {
313 source: export.source.clone(),
314 exported_at: export.exported_at.clone(),
315 export_age_days: date_epoch(&export.exported_at)
316 .map(|epoch| input.clock_epoch_secs.saturating_sub(epoch) / SECS_PER_DAY),
317 flags: export.flags.len(),
318 }
319}
320
321fn code_name<'k>(key: &'k str, prefix: Option<&str>) -> &'k str {
323 prefix
324 .filter(|prefix| !prefix.is_empty())
325 .and_then(|prefix| key.strip_prefix(prefix))
326 .filter(|name| !name.is_empty())
327 .unwrap_or(key)
328}
329
330fn normalize_label(label: &str) -> String {
333 label
334 .chars()
335 .filter(char::is_ascii_alphanumeric)
336 .map(|c| c.to_ascii_lowercase())
337 .collect()
338}
339
340fn sdk_matches_source(row: &RetirementFlag, source: &str) -> bool {
341 if row.kind != RetirementFlagKind::SdkCall || source.is_empty() {
342 return false;
343 }
344 row.sdk_name
345 .as_deref()
346 .is_some_and(|sdk| label_matches_source(sdk, source))
347}
348
349fn label_matches_source(sdk: &str, source: &str) -> bool {
352 if source.is_empty() {
353 return false;
354 }
355 let sdk = normalize_label(sdk);
356 !sdk.is_empty() && (sdk.starts_with(source) || source.starts_with(&sdk))
357}
358
359fn evidence_site(row: &RetirementFlag) -> Option<(String, u32)> {
362 row.sites
363 .iter()
364 .find(|site| site.role == FlagSiteRole::Read)
365 .or_else(|| row.sites.first())
366 .map(|site| (site.path.clone(), site.line))
367}
368
369fn add_state_reasons(row: &mut RetirementFlag, flag: &VendorFlag, export: &VendorExport) {
370 row.vendor = Some(flag.state.clone());
371 let Some((path, line)) = evidence_site(row) else {
372 return;
373 };
374 let state = &flag.state;
375 let single = state.serves_single_variation == Some(true);
376 if state.state == VendorFlagState::RolledOut || single {
377 let mut detail = format!("{} state {}", export.source, state_code(state.state));
378 if single {
379 detail.push_str(", serves one variation");
380 }
381 push_reason(row, RetirementReason::FullyRolledOut, &path, line, detail);
382 }
383 if state.state == VendorFlagState::Archived {
384 let detail = format!("{} state archived", export.source);
385 push_reason(row, RetirementReason::ArchivedInVendor, &path, line, detail);
386 }
387}
388
389fn add_missing_reason(row: &mut RetirementFlag, export: &VendorExport) {
390 let Some((path, line)) = evidence_site(row) else {
391 return;
392 };
393 let detail = format!(
394 "the key is not in the {} export ({})",
395 export.source, export.display_path
396 );
397 push_reason(row, RetirementReason::MissingInVendor, &path, line, detail);
398}
399
400fn push_reason(
401 row: &mut RetirementFlag,
402 reason: RetirementReason,
403 path: &str,
404 line: u32,
405 detail: String,
406) {
407 if !row.reasons.contains(&reason) {
408 row.reasons.push(reason);
409 }
410 row.evidence.push(RetirementEvidence {
411 reason,
412 path: path.to_string(),
413 line,
414 detail,
415 });
416}
417
418fn vendor_only_row(name: &str, flag: &VendorFlag, export: &VendorExport) -> RetirementFlag {
419 RetirementFlag {
420 flag_name: name.to_string(),
421 kind: RetirementFlagKind::VendorExport,
422 sdk_name: None,
423 workspace: None,
424 sites: Vec::new(),
425 read_sites: 0,
426 test_only: false,
427 first_seen: None,
428 oldest_surviving_site: None,
429 last_touched: None,
430 age_days: None,
431 reasons: vec![RetirementReason::VendorOnly],
432 evidence: vec![RetirementEvidence {
433 reason: RetirementReason::VendorOnly,
434 path: export.display_path.clone(),
435 line: flag.line,
436 detail: format!(
437 "the key is in the {} export, but no code reads it",
438 export.source
439 ),
440 }],
441 actions: Vec::new(),
442 vendor: Some(flag.state.clone()),
443 }
444}
445
446const fn state_code(state: VendorFlagState) -> &'static str {
447 match state {
448 VendorFlagState::On => "on",
449 VendorFlagState::Off => "off",
450 VendorFlagState::RolledOut => "rolled_out",
451 VendorFlagState::Archived => "archived",
452 VendorFlagState::Experiment => "experiment",
453 }
454}
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459 use fallow_types::flag_retirement::RetirementSite;
460
461 const EXPORT: &str = r#"{
462 "schema_version": 1,
463 "source": "launchdarkly",
464 "exported_at": "2026-09-20T00:00:00Z",
465 "flags": [
466 { "key": "web.rolled", "state": "rolled_out" },
467 { "key": "web.single", "state": "off", "serves_single_variation": true },
468 { "key": "web.archived", "state": "archived" },
469 { "key": "web.live", "state": "on", "serves_single_variation": false },
470 { "key": "web.orphan", "state": "on" }
471 ]
472}"#;
473
474 const CLOCK: u64 = 1_790_294_400;
476
477 fn export() -> VendorExport {
478 parse_flag_state(EXPORT.as_bytes(), "flag-state.json".to_string()).expect("valid export")
479 }
480
481 fn sdk_row(name: &str, sdk: Option<&str>) -> RetirementFlag {
482 RetirementFlag {
483 flag_name: name.to_string(),
484 kind: RetirementFlagKind::SdkCall,
485 sdk_name: sdk.map(str::to_string),
486 workspace: None,
487 sites: vec![RetirementSite {
488 path: "src/app.ts".to_string(),
489 line: 3,
490 col: 2,
491 role: FlagSiteRole::Read,
492 in_test: false,
493 }],
494 read_sites: 1,
495 test_only: false,
496 first_seen: None,
497 oldest_surviving_site: None,
498 last_touched: None,
499 age_days: None,
500 reasons: Vec::new(),
501 evidence: Vec::new(),
502 actions: Vec::new(),
503 vendor: None,
504 }
505 }
506
507 fn apply(rows: &mut Vec<RetirementFlag>, add_vendor_only: bool) -> RetirementVendorState {
508 let labels: FxHashSet<String> =
509 rows.iter().filter_map(|row| row.sdk_name.clone()).collect();
510 apply_with_labels(rows, add_vendor_only, &labels)
511 }
512
513 fn apply_with_labels(
514 rows: &mut Vec<RetirementFlag>,
515 add_vendor_only: bool,
516 labels: &FxHashSet<String>,
517 ) -> RetirementVendorState {
518 let export = export();
519 let names: FxHashSet<String> = rows.iter().map(|row| row.flag_name.clone()).collect();
520 apply_vendor_state(
521 rows,
522 &VendorMatch {
523 export: &export,
524 key_prefix: Some("web."),
525 code_flag_names: &names,
526 project_sdk_labels: labels,
527 add_vendor_only,
528 clock_epoch_secs: CLOCK,
529 },
530 )
531 }
532
533 fn reasons_of<'r>(rows: &'r [RetirementFlag], name: &str) -> &'r [RetirementReason] {
534 &rows
535 .iter()
536 .find(|row| row.flag_name == name)
537 .unwrap_or_else(|| panic!("no row {name}"))
538 .reasons
539 }
540
541 #[test]
542 fn vendor_states_give_the_vendor_reasons() {
543 let mut rows = vec![
544 sdk_row("rolled", Some("LaunchDarkly")),
545 sdk_row("single", Some("LaunchDarkly")),
546 sdk_row("archived", Some("LaunchDarkly")),
547 sdk_row("live", Some("LaunchDarkly")),
548 sdk_row("typo", Some("LaunchDarkly")),
549 ];
550 let state = apply(&mut rows, true);
551 assert_eq!(
552 reasons_of(&rows, "rolled"),
553 [RetirementReason::FullyRolledOut]
554 );
555 assert_eq!(
556 reasons_of(&rows, "single"),
557 [RetirementReason::FullyRolledOut]
558 );
559 assert_eq!(
560 reasons_of(&rows, "archived"),
561 [RetirementReason::ArchivedInVendor]
562 );
563 assert!(reasons_of(&rows, "live").is_empty());
564 assert_eq!(
565 reasons_of(&rows, "typo"),
566 [RetirementReason::MissingInVendor]
567 );
568 assert_eq!(reasons_of(&rows, "orphan"), [RetirementReason::VendorOnly]);
569
570 let single = rows
571 .iter()
572 .find(|row| row.flag_name == "single")
573 .expect("row");
574 assert_eq!(
575 single.evidence[0].detail,
576 "launchdarkly state off, serves one variation"
577 );
578 assert_eq!(
579 single.vendor.as_ref().map(|v| v.key.as_str()),
580 Some("web.single")
581 );
582
583 let orphan = rows
584 .iter()
585 .find(|row| row.flag_name == "orphan")
586 .expect("row");
587 assert_eq!(orphan.kind, RetirementFlagKind::VendorExport);
588 assert!(orphan.sites.is_empty());
589 assert_eq!(orphan.evidence[0].path, "flag-state.json");
590 assert_eq!(orphan.evidence[0].line, 10, "the line of the orphan key");
591
592 assert_eq!(state.source, "launchdarkly");
593 assert_eq!(state.flags, 5);
594 assert_eq!(state.export_age_days, Some(5));
595 }
596
597 #[test]
598 fn a_narrowed_run_adds_no_vendor_only_rows() {
599 let mut rows = vec![sdk_row("rolled", Some("LaunchDarkly"))];
600 apply(&mut rows, false);
601 assert_eq!(rows.len(), 1);
602 }
603
604 #[test]
605 fn a_key_that_the_code_reads_under_another_kind_is_not_vendor_only() {
606 let mut rows = vec![RetirementFlag {
607 kind: RetirementFlagKind::EnvironmentVariable,
608 ..sdk_row("orphan", None)
609 }];
610 apply(&mut rows, true);
611 assert_eq!(
612 rows.len(),
613 1 + 4,
614 "the env row and four keys that no code reads"
615 );
616 assert!(
617 reasons_of(&rows, "orphan").is_empty(),
618 "an env row never matches the export"
619 );
620 }
621
622 #[test]
623 fn an_export_of_one_vendor_leaves_other_sdks_alone() {
624 let mut rows = vec![
625 sdk_row("typo", Some("LaunchDarkly")),
626 sdk_row("other", Some("Statsig")),
627 sdk_row("custom", None),
628 ];
629 apply(&mut rows, false);
630 assert_eq!(
631 reasons_of(&rows, "typo"),
632 [RetirementReason::MissingInVendor]
633 );
634 assert!(reasons_of(&rows, "other").is_empty());
635 assert_eq!(
636 reasons_of(&rows, "custom"),
637 [RetirementReason::MissingInVendor],
638 "an SDK row without a label can belong to the vendor"
639 );
640 }
641
642 #[test]
643 fn a_narrowed_run_reads_the_sdk_labels_of_the_whole_project() {
644 let mut rows = vec![sdk_row("other", Some("Statsig"))];
647 let labels: FxHashSet<String> = ["Statsig", "LaunchDarkly"]
648 .into_iter()
649 .map(str::to_string)
650 .collect();
651 apply_with_labels(&mut rows, false, &labels);
652 assert!(
653 reasons_of(&rows, "other").is_empty(),
654 "{:?}",
655 rows[0].reasons
656 );
657 }
658
659 #[test]
660 fn an_export_of_an_unknown_vendor_matches_every_sdk_row() {
661 let mut rows = vec![sdk_row("typo", Some("Statsig"))];
662 let export = parse_flag_state(
663 br#"{"schema_version":1,"source":"in-house","exported_at":"2026-09-20","flags":[]}"#,
664 "state.json".to_string(),
665 )
666 .expect("valid");
667 apply_vendor_state(
668 &mut rows,
669 &VendorMatch {
670 export: &export,
671 key_prefix: None,
672 code_flag_names: &FxHashSet::default(),
673 project_sdk_labels: &std::iter::once("Statsig".to_string()).collect(),
674 add_vendor_only: true,
675 clock_epoch_secs: CLOCK,
676 },
677 );
678 assert_eq!(
679 reasons_of(&rows, "typo"),
680 [RetirementReason::MissingInVendor]
681 );
682 }
683
684 #[test]
685 fn invalid_exports_are_rejected() {
686 let cases: [(&str, &str); 6] = [
687 ("not json", "is not valid"),
688 (
689 r#"{"schema_version":2,"source":"x","exported_at":"2026-01-01","flags":[]}"#,
690 "schema_version 2",
691 ),
692 (
693 r#"{"schema_version":1,"source":"x","exported_at":"yesterday","flags":[]}"#,
694 "YYYY-MM-DD",
695 ),
696 (
697 r#"{"schema_version":1,"source":"x","exported_at":"2026-01-01","flags":[{"key":"a","state":"paused"}]}"#,
698 "unknown variant",
699 ),
700 (
701 r#"{"schema_version":1,"source":"x","exported_at":"2026-01-01","flags":[{"key":"a","state":"on"},{"key":"a","state":"off"}]}"#,
702 "more than one time",
703 ),
704 (
705 r#"{"schema_version":1,"source":"x","exported_at":"2026-01-01","flags":[{"key":"a","state":"on","enabled":true}]}"#,
706 "unknown field",
707 ),
708 ];
709 for (input, expected) in cases {
710 let error =
711 parse_flag_state(input.as_bytes(), "state.json".to_string()).expect_err(input);
712 assert!(
713 error.message.contains(expected),
714 "{input}: {}",
715 error.message
716 );
717 }
718 }
719
720 #[test]
721 fn an_oversized_export_is_rejected_before_parse() {
722 let dir = tempfile::tempdir().expect("temp dir");
723 let path = dir.path().join("state.json");
724 let file = std::fs::File::create(&path).expect("create");
725 file.set_len(MAX_FLAG_STATE_BYTES + 1).expect("grow");
726 let error = load_flag_state(&path, dir.path()).expect_err("too large");
727 assert!(
728 error.message.contains("larger than 16 MiB"),
729 "{}",
730 error.message
731 );
732 }
733
734 #[test]
735 fn the_display_path_is_relative_to_the_root_only_inside_it() {
736 let dir = tempfile::tempdir().expect("temp dir");
737 let inside = dir.path().join("state.json");
738 std::fs::write(&inside, "{}").expect("write");
739 assert_eq!(display_path(&inside, dir.path()), "state.json");
740 let other = tempfile::tempdir().expect("temp dir");
741 let outside = other.path().join("state.json");
742 std::fs::write(&outside, "{}").expect("write");
743 assert_eq!(
744 display_path(&outside, dir.path()),
745 "state.json",
746 "a file outside the root shows its file name only"
747 );
748 }
749
750 #[test]
751 fn a_prefix_that_is_the_whole_key_keeps_the_key() {
752 assert_eq!(code_name("web.", Some("web.")), "web.");
753 assert_eq!(code_name("web.a", Some("")), "web.a");
754 assert_eq!(code_name("app.a", Some("web.")), "app.a");
755 }
756}