1pub mod statusio;
2pub mod statuspage;
3pub mod text;
4
5use std::collections::{
6 HashMap,
7 HashSet,
8};
9
10use chrono::{
11 DateTime,
12 Duration,
13 Utc,
14};
15use pipedash_plugin_api::{
16 ComponentFilter,
17 ComponentHealth,
18 FeedFormat,
19 HealthLevel,
20};
21use reqwest::Url;
22use serde::{
23 de::DeserializeOwned,
24 Deserialize,
25 Deserializer,
26};
27use thiserror::Error;
28
29pub use self::statusio::parse_statusio;
30pub use self::statuspage::parse_statuspage;
31use crate::domain::health::{
32 Incident,
33 Maintenance,
34};
35
36pub const MAX_FEED_BYTES: usize = 2 * 1024 * 1024;
37pub const MAX_INCIDENTS: usize = 20;
38pub const MAX_COMPONENTS: usize = 50;
39pub const MAX_MAINTENANCES: usize = 10;
40
41const MAINTENANCE_HORIZON_HOURS: i64 = 24;
42
43#[derive(Debug, Error, PartialEq, Eq)]
44pub enum FeedError {
45 #[error("status feed is {0} bytes, over the 2 MiB limit")]
46 TooLarge(usize),
47 #[error("invalid status feed: {0}")]
48 Invalid(String),
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct FeedComponent {
53 pub id: String,
54 pub name: String,
55 pub level: HealthLevel,
56 pub detail: Option<&'static str>,
57 pub children: Vec<String>,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct FeedIncident {
62 pub id: String,
63 pub name: String,
64 pub level: HealthLevel,
65 pub phase: &'static str,
66 pub latest_update: Option<String>,
67 pub shortlink: Option<String>,
68 pub started_at: Option<DateTime<Utc>>,
69 pub updated_at: Option<DateTime<Utc>>,
70 pub components: Vec<String>,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct FeedMaintenance {
75 pub id: String,
76 pub name: String,
77 pub in_progress: bool,
78 pub shortlink: Option<String>,
79 pub starts_at: Option<DateTime<Utc>>,
80 pub ends_at: Option<DateTime<Utc>>,
81 pub components: Vec<String>,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct ParsedFeed {
86 pub format: FeedFormat,
87 pub components: Vec<FeedComponent>,
88 pub incidents: Vec<FeedIncident>,
89 pub maintenances: Vec<FeedMaintenance>,
90 pub unknown: Vec<&'static str>,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct FeedSignal {
95 pub level: HealthLevel,
96 pub components: Vec<ComponentHealth>,
97 pub incidents: Vec<Incident>,
98 pub maintenances: Vec<Maintenance>,
99 pub incident_ids: Vec<String>,
100 pub filter_fallback: bool,
101 pub watched: Vec<ComponentHealth>,
102 pub other_incidents: Vec<Incident>,
103}
104
105pub fn evaluate(
106 feed: &ParsedFeed, filter: &ComponentFilter, page_url: &Url, now: DateTime<Utc>,
107) -> FeedSignal {
108 let (watched, filter_fallback) = watched_ids(feed, filter);
109 let is_watched = |component: &FeedComponent| match &watched {
110 Some(ids) => ids.contains(component.id.as_str()),
111 None => component.children.is_empty(),
112 };
113 let applies = |ids: &[String]| match &watched {
114 Some(watched) => ids.is_empty() || ids.iter().any(|id| watched.contains(id.as_str())),
115 None => true,
116 };
117 let affects = |ids: &[String]| match &watched {
118 Some(watched) => ids.iter().any(|id| watched.contains(id.as_str())),
119 None => true,
120 };
121 let base = with_trailing_slash(page_url);
122 let link = |id: &str, shortlink: Option<&str>| {
123 let candidate = match feed.format {
124 FeedFormat::Statuspage => join_item(&base, id).or_else(|| shortlink.map(str::to_owned)),
125 _ => None,
126 };
127 https_link(candidate.as_deref(), page_url)
128 };
129
130 let to_health = |c: &FeedComponent| ComponentHealth {
131 name: c.name.clone(),
132 level: c.level,
133 detail: c.detail.map(str::to_owned),
134 };
135 let to_incident = |i: &FeedIncident, updated_at: DateTime<Utc>| Incident {
136 id: i.id.clone(),
137 name: i.name.clone(),
138 level: i.level,
139 phase: i.phase.to_owned(),
140 latest_update: i.latest_update.clone(),
141 url: link(&i.id, i.shortlink.as_deref()),
142 started_at: i.started_at,
143 updated_at,
144 };
145 let watched_components: Vec<ComponentHealth> = feed
146 .components
147 .iter()
148 .filter(|c| is_watched(c) && (watched.is_some() || c.level > HealthLevel::Operational))
149 .take(MAX_COMPONENTS)
150 .map(to_health)
151 .collect();
152
153 let mut components: Vec<&FeedComponent> = feed
154 .components
155 .iter()
156 .filter(|c| c.level > HealthLevel::Operational && is_watched(c))
157 .collect();
158 let mut level = components
159 .iter()
160 .map(|c| c.level)
161 .max()
162 .unwrap_or(HealthLevel::Operational);
163 components.sort_by_key(|c| std::cmp::Reverse(c.level));
164 components.truncate(MAX_COMPONENTS);
165
166 let mut incidents: Vec<(&FeedIncident, DateTime<Utc>)> = feed
167 .incidents
168 .iter()
169 .filter(|i| affects(&i.components))
170 .map(|i| (i, i.updated_at.unwrap_or(now)))
171 .collect();
172 level = level.max(
173 incidents
174 .iter()
175 .map(|(i, _)| i.level)
176 .max()
177 .unwrap_or(HealthLevel::Operational),
178 );
179 incidents.sort_by(|(a, a_at), (b, b_at)| {
180 b.level
181 .cmp(&a.level)
182 .then_with(|| b_at.cmp(a_at))
183 .then_with(|| a.id.cmp(&b.id))
184 });
185 let mut incident_ids: Vec<String> = incidents.iter().map(|(i, _)| i.id.clone()).collect();
186 incident_ids.sort();
187 incident_ids.dedup();
188 incidents.truncate(MAX_INCIDENTS);
189 let mut other_incidents: Vec<Incident> = feed
190 .incidents
191 .iter()
192 .filter(|i| !affects(&i.components))
193 .map(|i| to_incident(i, i.updated_at.unwrap_or(now)))
194 .collect();
195 other_incidents.sort_by(|a, b| {
196 b.updated_at
197 .cmp(&a.updated_at)
198 .then_with(|| a.id.cmp(&b.id))
199 });
200 other_incidents.truncate(MAX_INCIDENTS);
201
202 let horizon = now + Duration::hours(MAINTENANCE_HORIZON_HOURS);
203 let mut maintenances: Vec<&FeedMaintenance> = feed
204 .maintenances
205 .iter()
206 .filter(|m| {
207 applies(&m.components)
208 && (m.in_progress
209 || (m.starts_at.is_some_and(|start| start <= horizon)
210 && m.ends_at.is_none_or(|end| end >= now)))
211 })
212 .collect();
213 if maintenances.iter().any(|m| m.in_progress) {
214 level = level.max(HealthLevel::Maintenance);
215 }
216 maintenances.sort_by(|a, b| {
217 b.in_progress
218 .cmp(&a.in_progress)
219 .then_with(|| a.starts_at.cmp(&b.starts_at))
220 .then_with(|| a.id.cmp(&b.id))
221 });
222 maintenances.truncate(MAX_MAINTENANCES);
223
224 FeedSignal {
225 level,
226 components: components.into_iter().map(to_health).collect(),
227 incidents: incidents
228 .into_iter()
229 .map(|(i, updated_at)| to_incident(i, updated_at))
230 .collect(),
231 maintenances: maintenances
232 .into_iter()
233 .map(|m| Maintenance {
234 id: m.id.clone(),
235 name: m.name.clone(),
236 in_progress: m.in_progress,
237 starts_at: m.starts_at,
238 ends_at: m.ends_at,
239 url: link(&m.id, m.shortlink.as_deref()),
240 })
241 .collect(),
242 incident_ids,
243 filter_fallback,
244 watched: watched_components,
245 other_incidents,
246 }
247}
248
249fn watched_ids<'a>(
250 feed: &'a ParsedFeed, filter: &ComponentFilter,
251) -> (Option<HashSet<&'a str>>, bool) {
252 let ComponentFilter::Ids(ids) = filter else {
253 return (None, false);
254 };
255 let by_id: HashMap<&str, &FeedComponent> =
256 feed.components.iter().map(|c| (c.id.as_str(), c)).collect();
257 let found: Vec<&FeedComponent> = ids
258 .iter()
259 .filter_map(|id| by_id.get(id.as_str()).copied())
260 .collect();
261 if found.is_empty() {
262 return (None, true);
263 }
264
265 let mut watched = HashSet::new();
266 for component in found {
267 if component.children.is_empty() {
268 watched.insert(component.id.as_str());
269 } else {
270 watched.extend(component.children.iter().map(String::as_str));
271 }
272 }
273 (Some(watched), false)
274}
275
276fn with_trailing_slash(url: &Url) -> Url {
277 let mut base = url.clone();
278 if !base.path().ends_with('/') {
279 let path = format!("{}/", base.path());
280 base.set_path(&path);
281 }
282 base
283}
284
285fn join_item(base: &Url, id: &str) -> Option<String> {
286 let plain = !id.is_empty()
287 && id
288 .chars()
289 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'));
290 if !plain {
291 return None;
292 }
293 base.join(&format!("incidents/{id}")).ok().map(String::from)
294}
295
296fn https_link(candidate: Option<&str>, page_url: &Url) -> Option<String> {
297 candidate
298 .and_then(|link| Url::parse(link).ok())
299 .filter(|link| link.scheme() == "https")
300 .or_else(|| (page_url.scheme() == "https").then(|| page_url.clone()))
301 .map(String::from)
302}
303
304pub(super) fn decode<T: DeserializeOwned>(body: &[u8]) -> Result<T, FeedError> {
305 if body.len() > MAX_FEED_BYTES {
306 return Err(FeedError::TooLarge(body.len()));
307 }
308 serde_json::from_slice(body).map_err(|e| FeedError::Invalid(e.to_string()))
309}
310
311pub(super) fn lenient_date<'de, D: Deserializer<'de>>(
312 deserializer: D,
313) -> Result<Option<DateTime<Utc>>, D::Error> {
314 Ok(Option::<String>::deserialize(deserializer)?
315 .and_then(|text| DateTime::parse_from_rfc3339(&text).ok())
316 .map(|date| date.with_timezone(&Utc)))
317}
318
319pub(super) fn note_unknown(unknown: &mut Vec<&'static str>, field: &'static str) {
320 if !unknown.contains(&field) {
321 unknown.push(field);
322 }
323}
324
325#[cfg(test)]
326mod tests {
327 use chrono::TimeZone;
328 use serde_json::{
329 json,
330 Value,
331 };
332
333 use super::*;
334
335 macro_rules! fixture {
336 ($name:literal) => {
337 include_bytes!(concat!(
338 env!("CARGO_MANIFEST_DIR"),
339 "/tests/fixtures/status_feeds/",
340 $name
341 ))
342 };
343 }
344
345 fn now() -> DateTime<Utc> {
346 Utc.with_ymd_and_hms(2026, 10, 22, 23, 50, 0).unwrap()
347 }
348
349 fn url(text: &str) -> Url {
350 Url::parse(text).unwrap()
351 }
352
353 fn page() -> Url {
354 url("https://status.example.com")
355 }
356
357 fn ids(ids: &[&str]) -> ComponentFilter {
358 ComponentFilter::Ids(ids.iter().map(|id| id.to_string()).collect())
359 }
360
361 fn mutate(bytes: &[u8], edit: impl FnOnce(&mut Value)) -> Vec<u8> {
362 let mut value: Value = serde_json::from_slice(bytes).unwrap();
363 edit(&mut value);
364 serde_json::to_vec(&value).unwrap()
365 }
366
367 fn set_status(value: &mut Value, id: &str, status: &str) {
368 let component = value["components"]
369 .as_array_mut()
370 .unwrap()
371 .iter_mut()
372 .find(|c| c["id"] == id)
373 .unwrap();
374 component["status"] = json!(status);
375 }
376
377 fn sp(components: Value, incidents: Value, maintenances: Value) -> ParsedFeed {
378 let body = json!({
379 "components": components,
380 "incidents": incidents,
381 "scheduled_maintenances": maintenances,
382 });
383 parse_statuspage(body.to_string().as_bytes()).unwrap()
384 }
385
386 fn component(id: &str, status: &str) -> Value {
387 json!({"id": id, "name": format!("Component {id}"), "status": status})
388 }
389
390 fn incident(id: &str, impact: &str, components: &[&str]) -> Value {
391 json!({
392 "id": id,
393 "name": format!("Incident {id}"),
394 "status": "investigating",
395 "impact": impact,
396 "updated_at": "2026-10-22T22:00:00.000Z",
397 "components": components.iter().map(|c| json!({"id": c})).collect::<Vec<_>>(),
398 })
399 }
400
401 fn maintenance(id: &str, status: &str, start_hours: i64, components: &[&str]) -> Value {
402 let start = now() + Duration::hours(start_hours);
403 json!({
404 "id": id,
405 "name": format!("Maintenance {id}"),
406 "status": status,
407 "scheduled_for": start.to_rfc3339(),
408 "scheduled_until": (start + Duration::hours(2)).to_rfc3339(),
409 "components": components.iter().map(|c| json!({"id": c})).collect::<Vec<_>>(),
410 })
411 }
412
413 const GITHUB_WATCHED: [&str; 4] = [
414 "br0l2tvcx85d",
415 "brv1bkgrwx7q",
416 "8l4ygp009s5s",
417 "4230lsnqdsld",
418 ];
419
420 #[test]
421 fn github_incident_on_operational_components_makes_the_feed_degraded() {
422 let feed = parse_statuspage(fixture!("github.json")).unwrap();
423
424 let signal = evaluate(
425 &feed,
426 &ids(&GITHUB_WATCHED),
427 &url("https://www.githubstatus.com"),
428 now(),
429 );
430
431 assert!(feed
432 .components
433 .iter()
434 .all(|c| c.level == HealthLevel::Operational));
435 assert_eq!(signal.level, HealthLevel::Degraded);
436 assert!(signal.components.is_empty());
437 assert_eq!(signal.incident_ids, ["wq8305lppdc2"]);
438 let incident = &signal.incidents[0];
439 assert_eq!(incident.name, "Project updates delayed");
440 assert_eq!(incident.level, HealthLevel::Degraded);
441 assert_eq!(incident.phase, "Investigating");
442 assert_eq!(
443 incident.url.as_deref(),
444 Some("https://www.githubstatus.com/incidents/wq8305lppdc2")
445 );
446 let update = incident.latest_update.as_deref().unwrap();
447 assert!(update.starts_with("GitHub Projects update processing has improved"));
448 assert!(update.contains("\nWe're continuing"));
449 assert!(!update.contains('<'));
450 assert!(feed.unknown.is_empty());
451 }
452
453 #[test]
454 fn github_incident_on_unwatched_components_is_not_counted() {
455 let feed = parse_statuspage(fixture!("github.json")).unwrap();
456
457 let signal = evaluate(
458 &feed,
459 &ids(&["4230lsnqdsld"]),
460 &url("https://www.githubstatus.com"),
461 now(),
462 );
463
464 assert_eq!(signal.level, HealthLevel::Operational);
465 assert!(signal.incidents.is_empty());
466 assert!(signal.incident_ids.is_empty());
467 assert!(!signal.filter_fallback);
468 }
469
470 #[test]
471 fn bitbucket_fixture_is_operational() {
472 let feed = parse_statuspage(fixture!("bitbucket.json")).unwrap();
473
474 let signal = evaluate(
475 &feed,
476 &ids(&[
477 "k0x2yw1435v7",
478 "rzh2n85q7vtt",
479 "rfzky0v13fbp",
480 "c1qmcrcbc5zy",
481 ]),
482 &url("https://bitbucket.status.atlassian.com"),
483 now(),
484 );
485
486 assert_eq!(signal.level, HealthLevel::Operational);
487 assert!(!signal.filter_fallback);
488 assert!(signal.components.is_empty() && signal.incidents.is_empty());
489 }
490
491 #[test]
492 fn buildkite_group_expands_to_children_and_ids_beat_duplicate_names() {
493 let body = mutate(fixture!("buildkite.json"), |v| {
494 set_status(v, "sd4q7mk8n853", "partial_outage");
495 set_status(v, "msyttmm8jpns", "major_outage");
496 });
497 let feed = parse_statuspage(&body).unwrap();
498 let buildkite = url("https://www.buildkitestatus.com");
499
500 let hosted = evaluate(&feed, &ids(&["56ph4s45xl9s"]), &buildkite, now());
501 let rest_api = evaluate(&feed, &ids(&["r21z46hqn47c"]), &buildkite, now());
502 let other_rest_api = evaluate(&feed, &ids(&["msyttmm8jpns"]), &buildkite, now());
503 let everything = evaluate(&feed, &ComponentFilter::All, &buildkite, now());
504
505 assert_eq!(hosted.level, HealthLevel::PartialOutage);
506 assert_eq!(hosted.components.len(), 1);
507 assert_eq!(hosted.components[0].name, "Linux (ARM64)");
508 assert_eq!(rest_api.level, HealthLevel::Operational);
509 assert_eq!(other_rest_api.level, HealthLevel::MajorOutage);
510 assert_eq!(other_rest_api.components[0].name, "REST API");
511 assert_eq!(everything.level, HealthLevel::MajorOutage);
512 let names: Vec<_> = everything
513 .components
514 .iter()
515 .map(|c| (c.name.as_str(), c.level))
516 .collect();
517 assert_eq!(
518 names,
519 [
520 ("REST API", HealthLevel::MajorOutage),
521 ("Linux (ARM64)", HealthLevel::PartialOutage)
522 ]
523 );
524 }
525
526 #[test]
527 fn buildkite_fixture_is_operational_with_all_watched_ids() {
528 let feed = parse_statuspage(fixture!("buildkite.json")).unwrap();
529
530 let signal = evaluate(
531 &feed,
532 &ids(&[
533 "r21z46hqn47c",
534 "1mpkbbd1kh0h",
535 "3n7fzwsysx86",
536 "56ph4s45xl9s",
537 ]),
538 &url("https://www.buildkitestatus.com"),
539 now(),
540 );
541
542 assert_eq!(signal.level, HealthLevel::Operational);
543 assert!(!signal.filter_fallback);
544 }
545
546 #[test]
547 fn gitlab_fixture_is_operational_and_container_outage_propagates() {
548 let watched = ids(&[
549 "5b36e05f02d06804c0834a09",
550 "5d2f74932676bc45e4927ead",
551 "67ea346990c050053ab18e89",
552 "5b36e06c633e9004b3d624ad",
553 "5e29c1403f4deb04c0d7f035",
554 "650c87fbadb04b052efa2bc9",
555 ]);
556 let gitlab = url("https://status.gitlab.com");
557 let feed = parse_statusio(fixture!("gitlab.json")).unwrap();
558 let broken = mutate(fixture!("gitlab.json"), |v| {
559 let status = v["result"]["status"].as_array_mut().unwrap();
560 let macos = status
561 .iter_mut()
562 .find(|c| c["id"] == "650c87fbadb04b052efa2bc9")
563 .unwrap();
564 macos["containers"][1]["status_code"] = json!(500);
565 });
566
567 let operational = evaluate(&feed, &watched, &gitlab, now());
568 let outage = evaluate(&parse_statusio(&broken).unwrap(), &watched, &gitlab, now());
569
570 assert_eq!(operational.level, HealthLevel::Operational);
571 assert!(!operational.filter_fallback);
572 assert!(operational.components.is_empty() && operational.incidents.is_empty());
573 assert_eq!(outage.level, HealthLevel::MajorOutage);
574 assert_eq!(outage.components[0].name, "CI/CD - Hosted runners on macOS");
575 }
576
577 #[test]
578 fn component_outages_use_the_worst_watched_level_worst_first() {
579 let feed = sp(
580 json!([
581 component("a", "degraded_performance"),
582 component("b", "major_outage"),
583 component("c", "partial_outage"),
584 component("d", "major_outage"),
585 ]),
586 json!([]),
587 json!([]),
588 );
589
590 let signal = evaluate(&feed, &ids(&["a", "b", "c"]), &page(), now());
591
592 assert_eq!(signal.level, HealthLevel::MajorOutage);
593 let levels: Vec<_> = signal.components.iter().map(|c| c.level).collect();
594 assert_eq!(
595 levels,
596 [
597 HealthLevel::MajorOutage,
598 HealthLevel::PartialOutage,
599 HealthLevel::Degraded
600 ]
601 );
602 assert_eq!(
603 evaluate(&feed, &ids(&["a"]), &page(), now()).level,
604 HealthLevel::Degraded
605 );
606 }
607
608 #[test]
609 fn incident_impact_raises_the_level_and_none_is_only_listed() {
610 let components = json!([component("a", "operational")]);
611 let level_of = |impact: &str| {
612 let feed = sp(
613 components.clone(),
614 json!([incident("i", impact, &["a"])]),
615 json!([]),
616 );
617 let signal = evaluate(&feed, &ids(&["a"]), &page(), now());
618 assert_eq!(signal.incidents.len(), 1);
619 signal.level
620 };
621
622 assert_eq!(level_of("none"), HealthLevel::Operational);
623 assert_eq!(level_of("minor"), HealthLevel::Degraded);
624 assert_eq!(level_of("major"), HealthLevel::PartialOutage);
625 assert_eq!(level_of("critical"), HealthLevel::MajorOutage);
626 assert_eq!(level_of("maintenance"), HealthLevel::Maintenance);
627 }
628
629 #[test]
630 fn resolved_postmortem_and_monitoring_incidents() {
631 let mut resolved = incident("done", "critical", &["a"]);
632 resolved["status"] = json!("resolved");
633 let mut postmortem = incident("post", "critical", &["a"]);
634 postmortem["status"] = json!("postmortem");
635 let mut monitoring = incident("watch", "major", &["a"]);
636 monitoring["status"] = json!("monitoring");
637 let feed = sp(
638 json!([component("a", "operational")]),
639 json!([resolved, postmortem, monitoring]),
640 json!([]),
641 );
642
643 let signal = evaluate(&feed, &ids(&["a"]), &page(), now());
644
645 assert_eq!(signal.incident_ids, ["watch"]);
646 assert_eq!(signal.level, HealthLevel::PartialOutage);
647 assert_eq!(signal.incidents[0].phase, "Monitoring a fix");
648 }
649
650 #[test]
651 fn untagged_incidents_count_only_when_the_whole_page_is_watched() {
652 let feed = sp(
653 json!([
654 component("a", "operational"),
655 component("b", "partial_outage")
656 ]),
657 json!([
658 incident("wide", "major", &[]),
659 incident("mine", "minor", &["a"]),
660 incident("theirs", "critical", &["b"]),
661 ]),
662 json!([]),
663 );
664
665 let signal = evaluate(&feed, &ids(&["a"]), &page(), now());
666 let all = evaluate(&feed, &ComponentFilter::All, &page(), now());
667
668 assert_eq!(signal.incident_ids, ["mine"]);
669 assert_eq!(signal.level, HealthLevel::Degraded);
670 let others: Vec<_> = signal
671 .other_incidents
672 .iter()
673 .map(|i| i.id.as_str())
674 .collect();
675 assert_eq!(others.len(), 2);
676 assert!(others.contains(&"wide") && others.contains(&"theirs"));
677 let watched: Vec<_> = signal
678 .watched
679 .iter()
680 .map(|c| (c.name.as_str(), c.level))
681 .collect();
682 assert_eq!(watched, [("Component a", HealthLevel::Operational)]);
683 assert_eq!(all.incident_ids, ["mine", "theirs", "wide"]);
684 assert_eq!(all.level, HealthLevel::MajorOutage);
685 assert!(all.other_incidents.is_empty());
686 let all_watched: Vec<_> = all.watched.iter().map(|c| c.name.as_str()).collect();
687 assert_eq!(all_watched, ["Component b"]);
688 }
689
690 #[test]
691 fn incidents_on_a_watched_group_child_count_for_the_group_filter() {
692 let feed = sp(
693 json!([
694 {"id": "g", "name": "Group", "status": "operational", "group": true, "components": ["a", "b"]},
695 component("a", "operational"),
696 component("b", "operational"),
697 component("c", "operational"),
698 ]),
699 json!([
700 incident("child", "minor", &["b"]),
701 incident("other", "major", &["c"])
702 ]),
703 json!([]),
704 );
705
706 let signal = evaluate(&feed, &ids(&["g"]), &page(), now());
707
708 assert_eq!(signal.incident_ids, ["child"]);
709 }
710
711 #[test]
712 fn filter_with_no_known_ids_falls_back_to_all_with_a_flag() {
713 let feed = sp(
714 json!([
715 component("a", "major_outage"),
716 component("b", "operational")
717 ]),
718 json!([]),
719 json!([]),
720 );
721
722 let fallback = evaluate(&feed, &ids(&["ghost", "phantom"]), &page(), now());
723 let partial = evaluate(&feed, &ids(&["b", "ghost"]), &page(), now());
724 let empty = evaluate(&feed, &ids(&[]), &page(), now());
725
726 assert!(fallback.filter_fallback);
727 assert_eq!(fallback.level, HealthLevel::MajorOutage);
728 assert!(!partial.filter_fallback);
729 assert_eq!(partial.level, HealthLevel::Operational);
730 assert!(empty.filter_fallback);
731 }
732
733 #[test]
734 fn maintenance_in_progress_raises_to_maintenance_level() {
735 let feed = sp(
736 json!([component("a", "operational")]),
737 json!([]),
738 json!([maintenance("m", "in_progress", -1, &["a"])]),
739 );
740
741 let signal = evaluate(&feed, &ids(&["a"]), &page(), now());
742
743 assert_eq!(signal.level, HealthLevel::Maintenance);
744 assert!(signal.maintenances[0].in_progress);
745 }
746
747 #[test]
748 fn maintenance_within_24_hours_is_listed_without_raising_the_level() {
749 let feed = sp(
750 json!([component("a", "operational")]),
751 json!([]),
752 json!([
753 maintenance("soon", "scheduled", 23, &["a"]),
754 maintenance("later", "scheduled", 25, &["a"]),
755 maintenance("elsewhere", "scheduled", 2, &["zzz"]),
756 maintenance("page", "scheduled", 3, &[]),
757 ]),
758 );
759
760 let signal = evaluate(&feed, &ids(&["a"]), &page(), now());
761
762 assert_eq!(signal.level, HealthLevel::Operational);
763 let listed: Vec<_> = signal
764 .maintenances
765 .iter()
766 .map(|m| (m.id.as_str(), m.in_progress))
767 .collect();
768 assert_eq!(listed, [("page", false), ("soon", false)]);
769 }
770
771 #[test]
772 fn scheduled_maintenance_that_already_ended_is_dropped() {
773 let feed = sp(
774 json!([component("a", "operational")]),
775 json!([]),
776 json!([maintenance("stale", "scheduled", -5, &["a"])]),
777 );
778
779 assert!(evaluate(&feed, &ids(&["a"]), &page(), now())
780 .maintenances
781 .is_empty());
782 }
783
784 #[test]
785 fn status_io_maintenance_active_and_upcoming() {
786 let at = |hours: i64| (now() + Duration::hours(hours)).to_rfc3339();
787 let body = json!({"result": {
788 "status": [{"id": "a", "name": "A", "status_code": 100, "containers": []}],
789 "maintenance": {
790 "active": [{"_id": "now", "name": "Now", "datetime_planned_start": at(-1), "datetime_planned_end": at(1)}],
791 "upcoming": [
792 {"_id": "soon", "name": "Soon", "datetime_planned_start": at(5), "datetime_planned_end": at(6)},
793 {"_id": "far", "name": "Far", "datetime_planned_start": at(72), "datetime_planned_end": at(73)},
794 ],
795 },
796 }});
797 let feed = parse_statusio(body.to_string().as_bytes()).unwrap();
798
799 let signal = evaluate(
800 &feed,
801 &ids(&["a"]),
802 &url("https://status.example.com"),
803 now(),
804 );
805
806 assert_eq!(signal.level, HealthLevel::Maintenance);
807 let listed: Vec<_> = signal
808 .maintenances
809 .iter()
810 .map(|m| (m.id.as_str(), m.in_progress))
811 .collect();
812 assert_eq!(listed, [("now", true), ("soon", false)]);
813 }
814
815 #[test]
816 fn unknown_enum_strings_are_reported_not_fatal() {
817 let feed = sp(
818 json!([component("a", "levitating")]),
819 json!([incident("i", "galactic", &["a"])]),
820 json!([]),
821 );
822
823 let signal = evaluate(&feed, &ids(&["a"]), &page(), now());
824
825 assert_eq!(feed.unknown, ["component status", "incident impact"]);
826 assert_eq!(signal.level, HealthLevel::Operational);
827 }
828
829 #[test]
830 fn statuspage_links_point_to_the_incidents_path_of_the_page() {
831 let feed = sp(
832 json!([component("a", "operational")]),
833 json!([incident("inc1", "minor", &["a"])]),
834 json!([maintenance("mnt1", "scheduled", 2, &["a"])]),
835 );
836
837 let plain = evaluate(
838 &feed,
839 &ids(&["a"]),
840 &url("https://status.example.com"),
841 now(),
842 );
843 let nested = evaluate(
844 &feed,
845 &ids(&["a"]),
846 &url("https://example.com/status"),
847 now(),
848 );
849
850 assert_eq!(
851 plain.incidents[0].url.as_deref(),
852 Some("https://status.example.com/incidents/inc1")
853 );
854 assert_eq!(
855 plain.maintenances[0].url.as_deref(),
856 Some("https://status.example.com/incidents/mnt1")
857 );
858 assert_eq!(
859 nested.incidents[0].url.as_deref(),
860 Some("https://example.com/status/incidents/inc1")
861 );
862 }
863
864 #[test]
865 fn status_io_items_link_to_the_page_url() {
866 let body = json!({"result": {
867 "status": [{"id": "a", "name": "A", "status_code": 100, "containers": []}],
868 "incidents": [{
869 "_id": "inc", "name": "Down",
870 "messages": [{"state": 100, "status": 500, "datetime": "2026-10-22T10:00:00.000Z", "details": "x"}],
871 }],
872 "maintenance": {"active": [{"_id": "mnt", "name": "M"}]},
873 }});
874 let feed = parse_statusio(body.to_string().as_bytes()).unwrap();
875
876 let signal = evaluate(
877 &feed,
878 &ComponentFilter::All,
879 &url("https://status.gitlab.com"),
880 now(),
881 );
882
883 assert_eq!(
884 signal.incidents[0].url.as_deref(),
885 Some("https://status.gitlab.com/")
886 );
887 assert_eq!(
888 signal.maintenances[0].url.as_deref(),
889 Some("https://status.gitlab.com/")
890 );
891 }
892
893 #[test]
894 fn non_https_page_url_gives_no_link() {
895 let feed = sp(
896 json!([component("a", "operational")]),
897 json!([incident("inc1", "minor", &["a"])]),
898 json!([maintenance("mnt1", "in_progress", -1, &["a"])]),
899 );
900
901 let signal = evaluate(&feed, &ids(&["a"]), &url("http://127.0.0.1:8080"), now());
902
903 assert_eq!(signal.incidents[0].url, None);
904 assert_eq!(signal.maintenances[0].url, None);
905 }
906
907 #[test]
908 fn shortlink_is_used_only_when_the_join_fails_and_only_if_https() {
909 let page = url("https://status.example.com");
910
911 assert_eq!(
912 https_link(
913 join_item(&page, "bad id/../x")
914 .or(Some("https://stspg.io/abc".to_string()))
915 .as_deref(),
916 &page
917 )
918 .as_deref(),
919 Some("https://stspg.io/abc")
920 );
921 assert_eq!(
922 https_link(Some("http://stspg.io/abc"), &page).as_deref(),
923 Some("https://status.example.com/")
924 );
925 assert_eq!(
926 https_link(Some("http://stspg.io/abc"), &url("http://127.0.0.1")),
927 None
928 );
929 assert_eq!(
930 https_link(Some("not a url"), &url("http://127.0.0.1")),
931 None
932 );
933 }
934
935 #[test]
936 fn shortlink_in_a_feed_replaces_an_unjoinable_id() {
937 let mut odd = incident("a/b", "minor", &[]);
938 odd["shortlink"] = json!("http://stspg.io/abc");
939 let mut good = incident("c d", "minor", &[]);
940 good["shortlink"] = json!("https://stspg.io/xyz");
941 let feed = sp(
942 json!([component("a", "operational")]),
943 json!([odd, good]),
944 json!([]),
945 );
946
947 let signal = evaluate(&feed, &ComponentFilter::All, &page(), now());
948
949 let urls: Vec<_> = signal
950 .incidents
951 .iter()
952 .map(|i| (i.id.as_str(), i.url.as_deref()))
953 .collect();
954 assert_eq!(
955 urls,
956 [
957 ("a/b", Some("https://status.example.com/")),
958 ("c d", Some("https://stspg.io/xyz"))
959 ]
960 );
961 }
962
963 #[test]
964 fn caps_limit_components_incidents_and_maintenances_but_not_alert_ids() {
965 let components: Vec<_> = (0..60)
966 .map(|n| component(&format!("c{n:02}"), "partial_outage"))
967 .collect();
968 let incidents: Vec<_> = (0..25)
969 .map(|n| incident(&format!("i{n:02}"), "minor", &[]))
970 .collect();
971 let maintenances: Vec<_> = (0..15)
972 .map(|n| maintenance(&format!("m{n:02}"), "in_progress", -1, &[]))
973 .collect();
974 let feed = sp(json!(components), json!(incidents), json!(maintenances));
975
976 let signal = evaluate(&feed, &ComponentFilter::All, &page(), now());
977
978 assert_eq!(signal.components.len(), MAX_COMPONENTS);
979 assert_eq!(signal.incidents.len(), MAX_INCIDENTS);
980 assert_eq!(signal.maintenances.len(), MAX_MAINTENANCES);
981 assert_eq!(signal.incident_ids.len(), 25);
982 assert_eq!(signal.level, HealthLevel::PartialOutage);
983 }
984
985 #[test]
986 fn incidents_sort_by_level_then_recency() {
987 let mut older = incident("older", "major", &[]);
988 older["updated_at"] = json!("2026-10-22T08:00:00.000Z");
989 let feed = sp(
990 json!([component("a", "operational")]),
991 json!([
992 incident("minor", "minor", &[]),
993 older,
994 incident("newer", "major", &[])
995 ]),
996 json!([]),
997 );
998
999 let signal = evaluate(&feed, &ComponentFilter::All, &page(), now());
1000
1001 let order: Vec<_> = signal.incidents.iter().map(|i| i.id.as_str()).collect();
1002 assert_eq!(order, ["newer", "older", "minor"]);
1003 }
1004
1005 #[test]
1006 fn bodies_over_the_limit_are_rejected() {
1007 let body = vec![b' '; MAX_FEED_BYTES + 1];
1008
1009 assert_eq!(
1010 parse_statuspage(&body),
1011 Err(FeedError::TooLarge(MAX_FEED_BYTES + 1))
1012 );
1013 assert_eq!(
1014 parse_statusio(&body),
1015 Err(FeedError::TooLarge(MAX_FEED_BYTES + 1))
1016 );
1017 }
1018
1019 #[test]
1020 fn a_body_at_the_limit_is_still_parsed() {
1021 let mut body = br#"{"components":[]}"#.to_vec();
1022 body.resize(MAX_FEED_BYTES, b' ');
1023
1024 assert!(parse_statuspage(&body).is_ok());
1025 }
1026}