Skip to main content

ebman/
state.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    path::PathBuf,
4};
5
6use crate::util::{config_file, parse_bool, write_atomic};
7
8#[derive(Debug, Default, Clone)]
9pub struct PersistedState {
10    pub profile: Option<String>,
11    pub region: Option<String>,
12    pub filter: Option<String>,
13    pub sort: Option<String>, // e.g. "app:asc", "health:desc"
14    pub grouped: Option<bool>,
15    pub redact: Option<bool>,
16    pub events_visible: Option<bool>,
17    /// Event-timestamp display mode for the Events panel + Detail/Events
18    /// tab. `None` means "never set" — the app falls back to the
19    /// `EventTimeFormat` default (UTC). Stored as `"utc"|"local"|"age"`.
20    pub event_time_format: Option<crate::app::EventTimeFormat>,
21    pub selected_env: Option<String>,
22    pub pinned: BTreeSet<String>,
23    pub pinned_apps: BTreeSet<String>,
24    /// Cost Explorer column toggle. Defaults to `None` (off) so the
25    /// COST column doesn't render until the operator opts in via
26    /// `:cost on`. Persists across sessions because Cost Explorer
27    /// access is account-level and the operator's intent is durable.
28    pub cost_enabled: Option<bool>,
29    pub aliases: BTreeMap<String, String>,
30    pub saved_views: BTreeMap<String, String>,
31    /// Pre-deploy snapshots keyed by env name. Persists the
32    /// `previous_version_label` + `taken_at` captured by every `:deploy`
33    /// so a cross-session `:rollback` still has a target (without
34    /// falling back to the event-history scan, which has a 100-event
35    /// window cap). Stored as `"label|RFC3339-timestamp"` per env.
36    pub deploy_snapshots: BTreeMap<String, String>,
37    pub hidden_cols: BTreeSet<String>,
38    /// User-defined extra metric charts for the Metrics tab. Keyed by the
39    /// operator-chosen display label; value is `"namespace|name|stat"`.
40    pub custom_metrics: BTreeMap<String, CustomMetricSpec>,
41}
42
43/// Parsed shape of a user-defined Metrics-tab chart. Stored line-oriented
44/// in state.toml as `metric.LABEL = "namespace|name|stat[|dim=val;dim=val]"`.
45/// The fourth pipe-separated field is optional; when absent the app
46/// defaults to the env-scoped `EnvironmentName=<env>` dimension.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct CustomMetricSpec {
49    pub namespace: String,
50    pub name: String,
51    pub stat: String,
52    pub dimensions: Vec<(String, String)>,
53}
54
55impl CustomMetricSpec {
56    /// Parse the `"namespace|name|stat[|k=v;k=v]"` form. Returns None on
57    /// malformed input (wrong field count or empty mandatory parts) so the
58    /// loader silently drops bad lines instead of aborting startup. A
59    /// missing 4th field means "use the env-scoped default dimension at
60    /// fetch time".
61    pub fn parse(raw: &str) -> Option<Self> {
62        let parts: Vec<&str> = raw.split('|').collect();
63        if !matches!(parts.len(), 3 | 4) {
64            return None;
65        }
66        let ns = parts[0].trim();
67        let name = parts[1].trim();
68        let stat = parts[2].trim();
69        if ns.is_empty() || name.is_empty() || stat.is_empty() {
70            return None;
71        }
72        let dimensions = if parts.len() == 4 {
73            parts[3]
74                .split(';')
75                .filter_map(|kv| {
76                    let (k, v) = kv.split_once('=')?;
77                    let k = k.trim();
78                    let v = v.trim();
79                    if k.is_empty() || v.is_empty() {
80                        return None;
81                    }
82                    Some((k.to_string(), v.to_string()))
83                })
84                .collect()
85        } else {
86            Vec::new()
87        };
88        Some(Self {
89            namespace: ns.into(),
90            name: name.into(),
91            stat: stat.into(),
92            dimensions,
93        })
94    }
95
96    pub fn serialize(&self) -> String {
97        if self.dimensions.is_empty() {
98            return format!("{}|{}|{}", self.namespace, self.name, self.stat);
99        }
100        let dims = self
101            .dimensions
102            .iter()
103            .map(|(k, v)| format!("{k}={v}"))
104            .collect::<Vec<_>>()
105            .join(";");
106        format!("{}|{}|{}|{dims}", self.namespace, self.name, self.stat)
107    }
108}
109
110pub fn load() -> PersistedState {
111    let path = state_path();
112    let Ok(text) = std::fs::read_to_string(&path) else {
113        return PersistedState::default();
114    };
115    parse(&text)
116}
117
118/// True when no `state.toml` exists on disk yet. Used by the
119/// first-run nudge to decide whether to surface the "press ? for
120/// help" hint at boot. Distinct from "state.toml exists but is
121/// empty" — the latter means the operator has run ebman before
122/// (we wrote the file) but everything got cleared.
123pub fn file_exists() -> bool {
124    state_path().exists()
125}
126
127pub fn parse(text: &str) -> PersistedState {
128    let mut state = PersistedState::default();
129    for line in text.lines() {
130        let line = line.trim();
131        if line.is_empty() || line.starts_with('#') {
132            continue;
133        }
134        let Some((key, raw_val)) = line.split_once('=') else {
135            continue;
136        };
137        let value = raw_val.trim().trim_matches('"').to_string();
138        if value.is_empty() {
139            continue;
140        }
141        let k = key.trim();
142        match k {
143            "profile" => state.profile = Some(value),
144            "region" => state.region = Some(value),
145            "filter" => state.filter = Some(value),
146            "sort" => state.sort = Some(value),
147            "grouped" => state.grouped = parse_bool(&value),
148            "redact" => state.redact = parse_bool(&value),
149            "events_visible" => state.events_visible = parse_bool(&value),
150            "event_time_format" => {
151                state.event_time_format = crate::app::EventTimeFormat::parse(&value)
152            }
153            "selected_env" => state.selected_env = Some(value),
154            _ if k.starts_with("filter.") => {
155                // Legacy named-filter entries from ebman ≤ 0.11. Promote
156                // them into the unified `saved_views` store using the
157                // filter-only encoding so the operator's existing
158                // shortcuts keep working — `]` / `[` cycle picks them
159                // up alongside any full views. If the same name also
160                // exists as `view.NAME`, the explicit `view.*` wins
161                // (preserves operator intent on the off chance both
162                // are present). First serialize-after-load drops the
163                // `filter.*` lines and writes only `view.*` going
164                // forward.
165                let name = k.trim_start_matches("filter.").trim().to_string();
166                if !name.is_empty() && !state.saved_views.contains_key(&name) {
167                    state
168                        .saved_views
169                        .insert(name, crate::app::encode_filter_only_view(&value));
170                }
171            }
172            "pinned" => {
173                state.pinned = value
174                    .split(',')
175                    .map(|s| s.trim().to_string())
176                    .filter(|s| !s.is_empty())
177                    .collect();
178            }
179            "pinned_apps" => {
180                state.pinned_apps = value
181                    .split(',')
182                    .map(|s| s.trim().to_string())
183                    .filter(|s| !s.is_empty())
184                    .collect();
185            }
186            "cost_enabled" => state.cost_enabled = parse_bool(&value),
187            _ if k.starts_with("alias.") => {
188                let name = k.trim_start_matches("alias.").trim().to_string();
189                if !name.is_empty() {
190                    state.aliases.insert(name, value);
191                }
192            }
193            _ if k.starts_with("view.") => {
194                let name = k.trim_start_matches("view.").trim().to_string();
195                if !name.is_empty() {
196                    state.saved_views.insert(name, value);
197                }
198            }
199            _ if k.starts_with("deploy_snapshot.") => {
200                let name = k.trim_start_matches("deploy_snapshot.").trim().to_string();
201                if !name.is_empty() {
202                    state.deploy_snapshots.insert(name, value);
203                }
204            }
205            _ if k.starts_with("metric.") => {
206                let label = k.trim_start_matches("metric.").trim().to_string();
207                if label.is_empty() {
208                    continue;
209                }
210                if let Some(spec) = CustomMetricSpec::parse(&value) {
211                    state.custom_metrics.insert(label, spec);
212                }
213            }
214            "hidden_cols" => {
215                state.hidden_cols = value
216                    .split(',')
217                    .map(|s| s.trim().to_uppercase())
218                    .filter(|s| !s.is_empty())
219                    .collect();
220            }
221            _ => {}
222        }
223    }
224    state
225}
226
227pub fn save(state: &PersistedState) {
228    let path = state_path();
229    // Parent-dir creation is handled by `write_atomic`. We just build
230    // the body here and hand it off.
231    let mut out = String::new();
232    out.push_str("# ebman persisted state — managed by the app, edits will be overwritten\n");
233    if let Some(p) = &state.profile {
234        out.push_str(&format!("profile = \"{p}\"\n"));
235    }
236    if let Some(r) = &state.region {
237        out.push_str(&format!("region = \"{r}\"\n"));
238    }
239    if let Some(f) = &state.filter {
240        if !f.is_empty() {
241            out.push_str(&format!("filter = \"{f}\"\n"));
242        }
243    }
244    if let Some(s) = &state.sort {
245        out.push_str(&format!("sort = \"{s}\"\n"));
246    }
247    if let Some(g) = state.grouped {
248        out.push_str(&format!("grouped = {g}\n"));
249    }
250    if let Some(r) = state.redact {
251        out.push_str(&format!("redact = {r}\n"));
252    }
253    if let Some(e) = state.events_visible {
254        out.push_str(&format!("events_visible = {e}\n"));
255    }
256    if let Some(f) = state.event_time_format {
257        out.push_str(&format!("event_time_format = \"{}\"\n", f.label()));
258    }
259    if let Some(s) = &state.selected_env {
260        out.push_str(&format!("selected_env = \"{s}\"\n"));
261    }
262    if !state.pinned.is_empty() {
263        let joined: Vec<&str> = state.pinned.iter().map(String::as_str).collect();
264        out.push_str(&format!("pinned = \"{}\"\n", joined.join(",")));
265    }
266    if !state.pinned_apps.is_empty() {
267        let joined: Vec<&str> = state.pinned_apps.iter().map(String::as_str).collect();
268        out.push_str(&format!("pinned_apps = \"{}\"\n", joined.join(",")));
269    }
270    if let Some(b) = state.cost_enabled {
271        out.push_str(&format!("cost_enabled = {b}\n"));
272    }
273    for (name, value) in &state.aliases {
274        out.push_str(&format!("alias.{name} = \"{value}\"\n"));
275    }
276    for (name, value) in &state.saved_views {
277        out.push_str(&format!("view.{name} = \"{value}\"\n"));
278    }
279    for (env, snap) in &state.deploy_snapshots {
280        out.push_str(&format!("deploy_snapshot.{env} = \"{snap}\"\n"));
281    }
282    for (label, spec) in &state.custom_metrics {
283        out.push_str(&format!("metric.{label} = \"{}\"\n", spec.serialize()));
284    }
285    if !state.hidden_cols.is_empty() {
286        let joined: Vec<&str> = state.hidden_cols.iter().map(String::as_str).collect();
287        out.push_str(&format!("hidden_cols = \"{}\"\n", joined.join(",")));
288    }
289    if let Err(e) = write_atomic(&path, &out) {
290        tracing::warn!(error = %e, path = %path.display(), "failed to write state");
291    }
292}
293
294fn state_path() -> PathBuf {
295    config_file("state.toml")
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn parse_basic_fields() {
304        let text = r#"
305# comment
306profile = "prod"
307region = us-east-1
308filter = "foo"
309sort = "app:desc"
310grouped = true
311redact = off
312events_visible = 1
313selected_env = "my-env"
314"#;
315        let s = parse(text);
316        assert_eq!(s.profile, Some("prod".into()));
317        assert_eq!(s.region, Some("us-east-1".into()));
318        assert_eq!(s.filter, Some("foo".into()));
319        assert_eq!(s.sort, Some("app:desc".into()));
320        assert_eq!(s.grouped, Some(true));
321        assert_eq!(s.redact, Some(false));
322        assert_eq!(s.events_visible, Some(true));
323        assert_eq!(s.selected_env, Some("my-env".into()));
324    }
325
326    #[test]
327    fn event_time_format_parses_each_value() {
328        use crate::app::EventTimeFormat;
329        assert_eq!(
330            parse("event_time_format = \"utc\"\n").event_time_format,
331            Some(EventTimeFormat::Utc)
332        );
333        assert_eq!(
334            parse("event_time_format = \"local\"\n").event_time_format,
335            Some(EventTimeFormat::Local)
336        );
337        assert_eq!(
338            parse("event_time_format = \"age\"\n").event_time_format,
339            Some(EventTimeFormat::Age)
340        );
341        // Absent key → None (app falls back to the EventTimeFormat default).
342        assert_eq!(parse("region = \"x\"\n").event_time_format, None);
343        // Garbage value → None, not a panic.
344        assert_eq!(
345            parse("event_time_format = \"bogus\"\n").event_time_format,
346            None
347        );
348    }
349
350    #[test]
351    fn parse_legacy_filter_lines_promote_into_saved_views() {
352        // Backward-compat: ebman ≤ 0.11 wrote `filter.NAME = "..."`
353        // for saved filters; 0.12+ stores them as `view.NAME =
354        // "filter=..."`. The parser promotes the legacy form into
355        // `saved_views` using the filter-only encoding so existing
356        // state.toml files keep working.
357        let text = r#"
358filter.dev = "production"
359filter.prod = "live"
360"#;
361        let s = parse(text);
362        assert_eq!(
363            s.saved_views.get("dev").map(String::as_str),
364            Some("filter=production")
365        );
366        assert_eq!(
367            s.saved_views.get("prod").map(String::as_str),
368            Some("filter=live")
369        );
370    }
371
372    #[test]
373    fn parse_explicit_view_wins_over_legacy_filter_for_same_name() {
374        // If both `view.NAME = "..."` (new) and `filter.NAME = "..."`
375        // (legacy) exist for the same NAME, the explicit `view.*`
376        // form wins regardless of line order. This guards against
377        // a mid-migration state.toml where the operator's full view
378        // got overwritten by a legacy filter line.
379        let text = r#"
380filter.prod = "legacy-string"
381view.prod = "filter=new-string;sort=app:asc"
382"#;
383        let s = parse(text);
384        assert_eq!(
385            s.saved_views.get("prod").map(String::as_str),
386            Some("filter=new-string;sort=app:asc")
387        );
388        // And the reverse order — view.* first, filter.* second.
389        let text = r#"
390view.prod = "filter=new-string;sort=app:asc"
391filter.prod = "legacy-string"
392"#;
393        let s = parse(text);
394        assert_eq!(
395            s.saved_views.get("prod").map(String::as_str),
396            Some("filter=new-string;sort=app:asc")
397        );
398    }
399
400    #[test]
401    fn parse_collections() {
402        let text = r#"
403pinned = "prod-api,prod-worker"
404pinned_apps = "billing,checkout"
405alias.awseb-e-abc = "production"
406alias.awseb-e-xyz = "staging"
407view.dev = "filter=dev;sort=app:asc;grouped=false;scope=envs"
408hidden_cols = "TREND,PLATFORM"
409"#;
410        let s = parse(text);
411        assert!(s.pinned.contains("prod-api"));
412        assert!(s.pinned.contains("prod-worker"));
413        assert!(s.pinned_apps.contains("billing"));
414        assert!(s.pinned_apps.contains("checkout"));
415        assert_eq!(
416            s.aliases.get("awseb-e-abc").map(String::as_str),
417            Some("production")
418        );
419        assert!(s.saved_views.contains_key("dev"));
420        assert!(s.hidden_cols.contains("TREND"));
421        assert!(s.hidden_cols.contains("PLATFORM"));
422    }
423
424    #[test]
425    fn parse_deploy_snapshots() {
426        // `:deploy` captures these as a pre-rollback safety net;
427        // persistence lets cross-session `:rollback` find them.
428        let text = r#"
429deploy_snapshot.prod-api = "build-823|2026-05-25T14:30:00+00:00"
430deploy_snapshot.staging-api = "build-825|2026-05-25T15:00:00+00:00"
431"#;
432        let s = parse(text);
433        assert_eq!(
434            s.deploy_snapshots.get("prod-api").map(String::as_str),
435            Some("build-823|2026-05-25T14:30:00+00:00")
436        );
437        assert_eq!(
438            s.deploy_snapshots.get("staging-api").map(String::as_str),
439            Some("build-825|2026-05-25T15:00:00+00:00")
440        );
441    }
442
443    #[test]
444    fn serialize_deploy_snapshots_round_trips() {
445        // save() should emit deploy_snapshot.ENV lines that parse()
446        // recognises. The intermediate file content isn't asserted
447        // directly (avoids brittle string matching); instead we
448        // round-trip via parse-after-save semantics.
449        let mut state = PersistedState::default();
450        state.deploy_snapshots.insert(
451            "prod-api".into(),
452            "build-823|2026-05-25T14:30:00+00:00".into(),
453        );
454        // Hand-construct the line save() would write so we can verify
455        // it parses back without needing filesystem access.
456        let line = format!(
457            "deploy_snapshot.prod-api = \"{}\"\n",
458            state.deploy_snapshots["prod-api"]
459        );
460        let reparsed = parse(&line);
461        assert_eq!(
462            reparsed.deploy_snapshots.get("prod-api"),
463            state.deploy_snapshots.get("prod-api")
464        );
465    }
466
467    #[test]
468    fn parse_custom_metrics() {
469        let text = r#"
470metric.cpu = "AWS/EC2|CPUUtilization|Average"
471metric.disk = "AWS/EC2|DiskReadOps|Sum"
472"#;
473        let s = parse(text);
474        let cpu = s.custom_metrics.get("cpu").expect("cpu metric");
475        assert_eq!(cpu.namespace, "AWS/EC2");
476        assert_eq!(cpu.name, "CPUUtilization");
477        assert_eq!(cpu.stat, "Average");
478        assert!(s.custom_metrics.contains_key("disk"));
479    }
480
481    #[test]
482    fn parse_custom_metric_drops_malformed_value() {
483        // Wrong field count: silently dropped, no panic.
484        let text = "metric.bad = \"only|two\"\n";
485        let s = parse(text);
486        assert!(s.custom_metrics.is_empty());
487        // Empty field: also dropped.
488        let text = "metric.bad = \"AWS/EC2||Average\"\n";
489        let s = parse(text);
490        assert!(s.custom_metrics.is_empty());
491    }
492
493    #[test]
494    fn custom_metric_spec_round_trips() {
495        let spec = CustomMetricSpec {
496            namespace: "AWS/ApplicationELB".into(),
497            name: "RequestCount".into(),
498            stat: "Sum".into(),
499            dimensions: Vec::new(),
500        };
501        assert_eq!(
502            CustomMetricSpec::parse(&spec.serialize()).as_ref(),
503            Some(&spec)
504        );
505    }
506
507    #[test]
508    fn custom_metric_spec_round_trips_with_dimensions() {
509        let spec = CustomMetricSpec {
510            namespace: "AWS/EC2".into(),
511            name: "CPUUtilization".into(),
512            stat: "Average".into(),
513            dimensions: vec![("InstanceId".into(), "i-abc".into())],
514        };
515        let s = spec.serialize();
516        assert!(s.contains("|InstanceId=i-abc"));
517        assert_eq!(CustomMetricSpec::parse(&s).as_ref(), Some(&spec));
518    }
519
520    #[test]
521    fn custom_metric_spec_parse_drops_malformed_dimension_pairs() {
522        // The 'badkv' fragment is missing '='; the parser drops it but
523        // keeps the well-formed pair.
524        let s = "AWS/EC2|CPUUtilization|Average|InstanceId=i-abc;badkv";
525        let spec = CustomMetricSpec::parse(s).expect("parse");
526        assert_eq!(spec.dimensions, vec![("InstanceId".into(), "i-abc".into())]);
527    }
528
529    #[test]
530    fn parse_skips_empty_and_unknown_keys() {
531        let s = parse("");
532        assert!(s.profile.is_none());
533        let s = parse("# only comment\n  \nnonsense\n");
534        assert!(s.profile.is_none());
535        let s = parse("unknown = value\n");
536        assert!(s.profile.is_none());
537    }
538}