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>, pub grouped: Option<bool>,
15 pub redact: Option<bool>,
16 pub events_visible: Option<bool>,
17 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 pub cost_enabled: Option<bool>,
29 pub aliases: BTreeMap<String, String>,
30 pub saved_views: BTreeMap<String, String>,
31 pub deploy_snapshots: BTreeMap<String, String>,
37 pub hidden_cols: BTreeSet<String>,
38 pub custom_metrics: BTreeMap<String, CustomMetricSpec>,
41}
42
43#[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 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
118pub 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 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 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 assert_eq!(parse("region = \"x\"\n").event_time_format, None);
343 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 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 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 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 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 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 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 let text = "metric.bad = \"only|two\"\n";
485 let s = parse(text);
486 assert!(s.custom_metrics.is_empty());
487 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 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}