Skip to main content

pipedash_core/infrastructure/status_feeds/
mod.rs

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}