Skip to main content

fallow_engine/
flag_vendor.rs

1//! Vendor flag state for `fallow flags --retirement --flag-state <FILE>`.
2//!
3//! The export is a local JSON file in one vendor-neutral schema. Fallow reads
4//! it offline: no credentials, no network calls and no vendor clients. The
5//! docs give `jq` recipes that turn the export of each vendor into this
6//! schema.
7//!
8//! The export adds four reasons to the report. `fully-rolled-out` and
9//! `archived-in-vendor` come from the vendor state of a flag in the code.
10//! `missing-in-vendor` marks a flag in the code whose key is not in the
11//! export. `vendor-only` adds a row for a key in the export that no code
12//! reads.
13
14use 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
24/// The schema version of the export that this build reads.
25pub const FLAG_STATE_SCHEMA_VERSION: u32 = 1;
26
27/// Largest export that Fallow reads, in bytes.
28pub const MAX_FLAG_STATE_BYTES: u64 = 16 * 1024 * 1024;
29
30/// An export older than this many days gets a warning.
31pub const STALE_EXPORT_DAYS: u64 = 30;
32
33/// Seconds in one day.
34const SECS_PER_DAY: u64 = 86_400;
35
36/// Hint for every invalid export.
37const 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/// Why the export cannot be used.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct FlagStateError {
42    /// What is wrong with the export.
43    pub message: String,
44    /// How to make a valid export.
45    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/// One flag of the export.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct VendorFlag {
82    /// The vendor state of the flag.
83    pub state: RetirementVendor,
84    /// 1-based line of the key in the export file.
85    pub line: u32,
86}
87
88/// A valid vendor export.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct VendorExport {
91    /// The vendor name, for example `launchdarkly`.
92    pub source: String,
93    /// When the export was made, as the file gives it.
94    pub exported_at: String,
95    /// The export path for evidence: relative to the root when the file is
96    /// inside it, else the file name only.
97    pub display_path: String,
98    /// The flags, in file order.
99    pub flags: Vec<VendorFlag>,
100}
101
102/// Read and check the export at `path`.
103///
104/// # Errors
105///
106/// Returns an error when the file cannot be read, is larger than
107/// [`MAX_FLAG_STATE_BYTES`], or does not match the schema.
108pub 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
134/// The export path relative to the root when the file is inside it, else
135/// the file name only. Output paths are root-relative, and an absolute path
136/// outside the root (for example a CI temp directory) is local to one
137/// machine. Both sides are canonical, so a relative `--flag-state` path and
138/// a symlinked root still match.
139fn 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
151/// Parse and check the bytes of an export.
152///
153/// # Errors
154///
155/// Returns an error when the bytes are not valid JSON in the schema, when
156/// `schema_version` is not [`FLAG_STATE_SCHEMA_VERSION`], when `exported_at`
157/// has no `YYYY-MM-DD` date, or when a key is empty or occurs two times.
158pub 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
220/// The line of the first `"key": "<value>"` pair for each value.
221fn 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
243/// Epoch of the UTC midnight of the `YYYY-MM-DD` date at the start of `text`.
244fn date_epoch(text: &str) -> Option<u64> {
245    crate::clock::utc_midnight_epoch(text.get(..10)?)
246}
247
248/// Inputs to match an export with the rows of the report.
249pub struct VendorMatch<'a> {
250    /// The export.
251    pub export: &'a VendorExport,
252    /// `flags.vendorKeyPrefix`: removed from each vendor key before the
253    /// match.
254    pub key_prefix: Option<&'a str>,
255    /// Every flag name in the project, also outside the scope of the run.
256    pub code_flag_names: &'a FxHashSet<String>,
257    /// The SDK label of every SDK site in the project, also outside the
258    /// scope of the run.
259    pub project_sdk_labels: &'a FxHashSet<String>,
260    /// Whether to add `vendor-only` rows. A run narrowed to part of the
261    /// project cannot tell that no code reads a key, so it adds none.
262    pub add_vendor_only: bool,
263    /// The analysis clock, in unix seconds.
264    pub clock_epoch_secs: u64,
265}
266
267/// Add the vendor reasons to `rows`, and add a row for each `vendor-only`
268/// key. Returns the export summary for the report.
269///
270/// Only SDK rows match the export. When an SDK label in the project matches
271/// the export `source` (for example `LaunchDarkly` and `launchdarkly`), only
272/// the rows of that SDK, and the SDK rows without a label, match. Thus an
273/// export of one vendor does not mark the flags of another SDK as
274/// `missing-in-vendor`. The label check reads every SDK site of the project,
275/// so a run narrowed to part of the project gives the same result for a row.
276pub 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
321/// The name that the code uses for a vendor key.
322fn 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
330/// Lowercase ASCII letters and digits only, so `LaunchDarkly`,
331/// `launch-darkly` and `launchdarkly` compare equal.
332fn 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
349/// Whether an SDK label names the vendor of the export. `source` is
350/// normalized.
351fn 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
359/// The code site that evidence of a vendor reason points at: the first read
360/// site, else the first site.
361fn 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    /// 2026-09-25T00:00:00Z.
475    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        // Only the Statsig row is in scope, but the project also has
645        // LaunchDarkly sites, so the LaunchDarkly export does not cover it.
646        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}