Skip to main content

omni_dev/datadog/
types.rs

1//! Shared response types for the Datadog API.
2//!
3//! Populated in subsequent slices as endpoint families land. Kept as a
4//! module now so `src/datadog.rs` declares a stable set of children.
5
6use std::io::Write;
7
8use anyhow::Result;
9use serde::{Deserialize, Serialize};
10
11use crate::cli::datadog::format::{write_items_jsonl, write_scalar_jsonl, JsonlSerialize};
12
13/// A single `(timestamp_ms, value)` sample returned by Datadog.
14///
15/// Datadog returns `pointlist` as a JSON array of two-element arrays where
16/// the timestamp is milliseconds since the Unix epoch and the value may be
17/// `null` for gaps in the series.
18pub type MetricPoint = (f64, Option<f64>);
19
20/// A single series within a Datadog metrics query response.
21///
22/// Only the fields used by the CLI renderer are retained; additional
23/// fields Datadog may emit (e.g. `length`, `start`, `end`, `aggr`,
24/// `unit`, `attributes`, `query_index`) are ignored by the deserializer.
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
26pub struct MetricSeries {
27    /// Metric identifier as returned by Datadog (e.g. `avg:system.cpu.user{*}`).
28    pub metric: String,
29
30    /// Human-friendly name suitable as a column header; when Datadog omits
31    /// it we fall back to the `expression` or `metric` field.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub display_name: Option<String>,
34
35    /// Scope that the points apply to (e.g. `host:*`).
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub scope: Option<String>,
38
39    /// Original query expression for this series.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub expression: Option<String>,
42
43    /// Sample points as `(timestamp_ms, value)` pairs.
44    #[serde(default)]
45    pub pointlist: Vec<MetricPoint>,
46}
47
48impl MetricSeries {
49    /// Returns the best available column label for this series.
50    ///
51    /// Prefers `display_name`, then `expression`, then `metric`.
52    #[must_use]
53    pub fn label(&self) -> &str {
54        self.display_name
55            .as_deref()
56            .or(self.expression.as_deref())
57            .unwrap_or(&self.metric)
58    }
59}
60
61/// Response from `GET /api/v1/query`.
62///
63/// `from_date` / `to_date` are in milliseconds since the Unix epoch — the
64/// native unit Datadog emits.
65#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
66pub struct MetricQueryResponse {
67    /// Query status (`ok` or `error`).
68    pub status: String,
69
70    /// Start of the returned window in epoch milliseconds.
71    pub from_date: i64,
72
73    /// End of the returned window in epoch milliseconds.
74    pub to_date: i64,
75
76    /// One entry per series returned by Datadog.
77    #[serde(default)]
78    pub series: Vec<MetricSeries>,
79}
80
81impl JsonlSerialize for MetricQueryResponse {
82    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
83        write_scalar_jsonl(self, out)
84    }
85}
86
87/// A Datadog monitor as returned by `GET /api/v1/monitor` and
88/// `GET /api/v1/monitor/{id}`.
89///
90/// Only the fields exposed by the CLI are retained; additional fields
91/// Datadog may emit (e.g. `creator`, `options`) are surfaced through
92/// `serde_json::Value` so JSON / YAML output preserves them while the
93/// table renderer ignores them.
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
95pub struct Monitor {
96    /// Datadog monitor identifier.
97    pub id: i64,
98
99    /// Human-readable monitor name.
100    pub name: String,
101
102    /// Monitor type (e.g. `metric alert`, `service check`, `log alert`).
103    #[serde(rename = "type")]
104    pub monitor_type: String,
105
106    /// The monitor query expression.
107    pub query: String,
108
109    /// Optional notification message body.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub message: Option<String>,
112
113    /// Tags applied to the monitor.
114    #[serde(default)]
115    pub tags: Vec<String>,
116
117    /// Aggregated state across all groups (e.g. `OK`, `Alert`, `No Data`).
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub overall_state: Option<String>,
120
121    /// ISO 8601 creation timestamp.
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub created: Option<String>,
124
125    /// ISO 8601 last-modified timestamp.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub modified: Option<String>,
128
129    /// Optional priority (1 highest – 5 lowest).
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub priority: Option<i64>,
132
133    /// Whether the monitor evaluates as multi-alert across groups.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub multi: Option<bool>,
136
137    /// Creator of the monitor (raw object).
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub creator: Option<serde_json::Value>,
140
141    /// Monitor configuration options (raw object).
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub options: Option<serde_json::Value>,
144}
145
146impl Monitor {
147    /// Status string suitable for table output. Falls back to `-` when
148    /// Datadog omits `overall_state`.
149    #[must_use]
150    pub fn status(&self) -> &str {
151        self.overall_state.as_deref().unwrap_or("-")
152    }
153}
154
155impl JsonlSerialize for Monitor {
156    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
157        write_scalar_jsonl(self, out)
158    }
159}
160
161/// Pagination metadata returned by `GET /api/v1/monitor/search`.
162#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
163pub struct MonitorSearchMetadata {
164    /// Zero-indexed page number returned.
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub page: Option<i64>,
167
168    /// Number of items per page (Datadog calls this `per_page`).
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub per_page: Option<i64>,
171
172    /// Total number of pages available for the query.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub page_count: Option<i64>,
175
176    /// Total number of monitors matching the query.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub total_count: Option<i64>,
179}
180
181/// A single hit in `GET /api/v1/monitor/search`.
182///
183/// Schema differs from a full [`Monitor`] (notably `status` is uppercase
184/// like `ALERT` rather than the mixed-case `overall_state`); the search
185/// envelope is intentionally a separate type.
186#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
187pub struct MonitorSearchItem {
188    /// Datadog monitor identifier.
189    pub id: i64,
190
191    /// Human-readable monitor name.
192    pub name: String,
193
194    /// Aggregated state (e.g. `OK`, `ALERT`, `NO DATA`).
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub status: Option<String>,
197
198    /// Tags applied to the monitor.
199    #[serde(default)]
200    pub tags: Vec<String>,
201
202    /// Monitor type.
203    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
204    pub monitor_type: Option<String>,
205
206    /// Monitor query expression.
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub query: Option<String>,
209
210    /// Most recent trigger time, in epoch milliseconds.
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub last_triggered_ts: Option<i64>,
213
214    /// Creator of the monitor (raw object).
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub creator: Option<serde_json::Value>,
217}
218
219impl MonitorSearchItem {
220    /// Status string suitable for table output. Falls back to `-` when
221    /// Datadog omits `status`.
222    #[must_use]
223    pub fn status_label(&self) -> &str {
224        self.status.as_deref().unwrap_or("-")
225    }
226}
227
228/// Response from `GET /api/v1/monitor/search`.
229///
230/// `counts` is opaque: Datadog returns nested faceted counters whose
231/// shape varies by query, so it's preserved as `serde_json::Value`.
232#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
233pub struct MonitorSearchResult {
234    /// Monitors matching the search query.
235    #[serde(default)]
236    pub monitors: Vec<MonitorSearchItem>,
237
238    /// Faceted counters returned by Datadog (raw object, optional).
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub counts: Option<serde_json::Value>,
241
242    /// Pagination metadata.
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub metadata: Option<MonitorSearchMetadata>,
245}
246
247impl JsonlSerialize for MonitorSearchResult {
248    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
249        write_items_jsonl(self.monitors.iter(), out)
250    }
251}
252
253/// One row of `GET /api/v1/dashboard`'s `dashboards` array.
254///
255/// Datadog identifies dashboards by an opaque string (e.g. `abc-def-ghi`),
256/// not a numeric id. Optional fields are preserved as `Option<_>` so
257/// JSON / YAML output never invents a value the API didn't return.
258#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
259pub struct DashboardSummary {
260    /// Datadog dashboard identifier.
261    pub id: String,
262
263    /// Human-readable title.
264    pub title: String,
265
266    /// Login of the dashboard's author.
267    #[serde(default, skip_serializing_if = "Option::is_none")]
268    pub author_handle: Option<String>,
269
270    /// Web UI URL relative to the Datadog site (e.g. `/dashboard/abc-def-ghi`).
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    pub url: Option<String>,
273
274    /// ISO 8601 last-modified timestamp.
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub modified_at: Option<String>,
277
278    /// ISO 8601 creation timestamp.
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub created_at: Option<String>,
281
282    /// Optional dashboard description.
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub description: Option<String>,
285
286    /// Whether the dashboard is shared with the wider organisation.
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub is_shared: Option<bool>,
289
290    /// Whether the dashboard cannot be edited via the UI.
291    #[serde(default, skip_serializing_if = "Option::is_none")]
292    pub is_read_only: Option<bool>,
293
294    /// Layout type as reported by Datadog (`ordered` or `free`).
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub layout_type: Option<String>,
297}
298
299impl DashboardSummary {
300    /// Author handle for table output. Falls back to `-` when Datadog
301    /// omits the field.
302    #[must_use]
303    pub fn author_label(&self) -> &str {
304        self.author_handle.as_deref().unwrap_or("-")
305    }
306
307    /// URL string for table output. Falls back to `-` when Datadog
308    /// omits the field.
309    #[must_use]
310    pub fn url_label(&self) -> &str {
311        self.url.as_deref().unwrap_or("-")
312    }
313}
314
315impl JsonlSerialize for DashboardSummary {
316    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
317        write_scalar_jsonl(self, out)
318    }
319}
320
321/// Envelope returned by `GET /api/v1/dashboard`.
322///
323/// Datadog returns *all* dashboards in this single response — no
324/// server-side pagination — so the wrapper is a thin newtype kept only
325/// to mirror the on-the-wire shape.
326#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
327pub struct DashboardListResponse {
328    /// Dashboards returned by the API.
329    #[serde(default)]
330    pub dashboards: Vec<DashboardSummary>,
331}
332
333/// A Datadog dashboard returned by `GET /api/v1/dashboard/{id}`.
334///
335/// `widgets` is preserved as a raw `serde_json::Value` because the per-
336/// widget schemas are deeply heterogeneous (timeseries, query value,
337/// note, group, log stream, …) — modelling each variant would explode
338/// the type surface for no CLI gain. This mirrors how the Atlassian
339/// integration treats ADF documents.
340#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
341pub struct Dashboard {
342    /// Datadog dashboard identifier.
343    pub id: String,
344
345    /// Human-readable title.
346    pub title: String,
347
348    /// Optional dashboard description.
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub description: Option<String>,
351
352    /// Web UI URL relative to the Datadog site.
353    #[serde(default, skip_serializing_if = "Option::is_none")]
354    pub url: Option<String>,
355
356    /// Login of the dashboard's author.
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub author_handle: Option<String>,
359
360    /// ISO 8601 creation timestamp.
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    pub created_at: Option<String>,
363
364    /// ISO 8601 last-modified timestamp.
365    #[serde(default, skip_serializing_if = "Option::is_none")]
366    pub modified_at: Option<String>,
367
368    /// Layout type as reported by Datadog (`ordered` or `free`).
369    #[serde(default, skip_serializing_if = "Option::is_none")]
370    pub layout_type: Option<String>,
371
372    /// Whether the dashboard cannot be edited via the UI.
373    #[serde(default, skip_serializing_if = "Option::is_none")]
374    pub is_read_only: Option<bool>,
375
376    /// Reflow type for `ordered` dashboards (`auto` or `fixed`).
377    #[serde(default, skip_serializing_if = "Option::is_none")]
378    pub reflow_type: Option<String>,
379
380    /// Notification list for the dashboard (raw value).
381    #[serde(default, skip_serializing_if = "Option::is_none")]
382    pub notify_list: Option<serde_json::Value>,
383
384    /// Template variables (raw value — schemas vary by variable type).
385    #[serde(default, skip_serializing_if = "Option::is_none")]
386    pub template_variables: Option<serde_json::Value>,
387
388    /// Widget definitions. Preserved as raw JSON; see type docs.
389    #[serde(default, skip_serializing_if = "Option::is_none")]
390    pub widgets: Option<serde_json::Value>,
391}
392
393impl Dashboard {
394    /// Author handle for table output. Falls back to `-` when Datadog
395    /// omits the field.
396    #[must_use]
397    pub fn author_label(&self) -> &str {
398        self.author_handle.as_deref().unwrap_or("-")
399    }
400
401    /// URL string for table output. Falls back to `-` when Datadog
402    /// omits the field.
403    #[must_use]
404    pub fn url_label(&self) -> &str {
405        self.url.as_deref().unwrap_or("-")
406    }
407}
408
409impl JsonlSerialize for Dashboard {
410    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
411        write_scalar_jsonl(self, out)
412    }
413}
414
415/// Sort order for `POST /api/v2/logs/events/search`.
416///
417/// Datadog encodes the order on the wire as the field name optionally
418/// prefixed with `-` for descending. The CLI exposes the friendlier
419/// `timestamp-asc` / `timestamp-desc` value names.
420#[derive(Debug, Clone, Copy, PartialEq, Eq)]
421pub enum SortOrder {
422    /// Oldest first — wire form `timestamp`.
423    TimestampAsc,
424    /// Newest first — wire form `-timestamp`.
425    TimestampDesc,
426}
427
428impl SortOrder {
429    /// Returns the wire representation used by the v2 logs API.
430    #[must_use]
431    pub fn as_api_str(self) -> &'static str {
432        match self {
433            Self::TimestampAsc => "timestamp",
434            Self::TimestampDesc => "-timestamp",
435        }
436    }
437}
438
439impl Serialize for SortOrder {
440    fn serialize<S: serde::Serializer>(
441        &self,
442        serializer: S,
443    ) -> std::result::Result<S::Ok, S::Error> {
444        serializer.serialize_str(self.as_api_str())
445    }
446}
447
448impl<'de> Deserialize<'de> for SortOrder {
449    fn deserialize<D: serde::Deserializer<'de>>(
450        deserializer: D,
451    ) -> std::result::Result<Self, D::Error> {
452        let s = String::deserialize(deserializer)?;
453        match s.as_str() {
454            "timestamp" => Ok(Self::TimestampAsc),
455            "-timestamp" => Ok(Self::TimestampDesc),
456            other => Err(serde::de::Error::custom(format!(
457                "unknown sort order: {other}"
458            ))),
459        }
460    }
461}
462
463/// Attributes payload of a log event returned by `POST /api/v2/logs/events/search`.
464///
465/// Datadog wraps each event in a `{ id, type, attributes }` envelope. Only
466/// the fields needed by the table renderer are surfaced as named fields;
467/// callers that need the full event attribute map can re-fetch through
468/// `-o json`.
469#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
470pub struct LogEventAttributes {
471    /// Event timestamp as Datadog returns it (typically RFC 3339).
472    #[serde(default, skip_serializing_if = "Option::is_none")]
473    pub timestamp: Option<String>,
474
475    /// Service name reported by the log producer.
476    #[serde(default, skip_serializing_if = "Option::is_none")]
477    pub service: Option<String>,
478
479    /// Log status (e.g. `info`, `warn`, `error`).
480    #[serde(default, skip_serializing_if = "Option::is_none")]
481    pub status: Option<String>,
482
483    /// Originating host.
484    #[serde(default, skip_serializing_if = "Option::is_none")]
485    pub host: Option<String>,
486
487    /// Free-form log message.
488    #[serde(default, skip_serializing_if = "Option::is_none")]
489    pub message: Option<String>,
490
491    /// Tags applied to the event.
492    #[serde(default)]
493    pub tags: Vec<String>,
494}
495
496/// A single log event hit returned by `POST /api/v2/logs/events/search`.
497#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
498pub struct LogEvent {
499    /// Datadog event identifier.
500    pub id: String,
501
502    /// Resource type marker — Datadog returns the literal string `"log"`.
503    #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
504    pub event_type: Option<String>,
505
506    /// Event payload.
507    #[serde(default)]
508    pub attributes: LogEventAttributes,
509}
510
511impl LogEvent {
512    /// Timestamp string suitable for table output. Falls back to `-`
513    /// when Datadog omits the field.
514    #[must_use]
515    pub fn timestamp_label(&self) -> &str {
516        self.attributes.timestamp.as_deref().unwrap_or("-")
517    }
518
519    /// Service string suitable for table output. Falls back to `-`
520    /// when Datadog omits the field.
521    #[must_use]
522    pub fn service_label(&self) -> &str {
523        self.attributes.service.as_deref().unwrap_or("-")
524    }
525
526    /// Status string suitable for table output. Falls back to `-`
527    /// when Datadog omits the field.
528    #[must_use]
529    pub fn status_label(&self) -> &str {
530        self.attributes.status.as_deref().unwrap_or("-")
531    }
532
533    /// Message string suitable for table output. Falls back to an
534    /// empty string when Datadog omits the field.
535    #[must_use]
536    pub fn message_label(&self) -> &str {
537        self.attributes.message.as_deref().unwrap_or("")
538    }
539}
540
541impl JsonlSerialize for LogEvent {
542    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
543        write_scalar_jsonl(self, out)
544    }
545}
546
547/// Cursor pagination block returned by `POST /api/v2/logs/events/search`.
548#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
549pub struct LogSearchPage {
550    /// Cursor token for the next page; absent when no further pages exist.
551    #[serde(default, skip_serializing_if = "Option::is_none")]
552    pub after: Option<String>,
553}
554
555/// Search-level metadata returned by `POST /api/v2/logs/events/search`.
556///
557/// Datadog returns additional fields (`elapsed`, `request_id`, `warnings`,
558/// `status`) whose shapes vary; they're preserved as raw `serde_json::Value`
559/// so JSON / YAML output round-trips unchanged.
560#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
561pub struct LogSearchMeta {
562    /// Cursor pagination block (absent when no further pages exist).
563    #[serde(default, skip_serializing_if = "Option::is_none")]
564    pub page: Option<LogSearchPage>,
565
566    /// Search status reported by Datadog (e.g. `done`).
567    #[serde(default, skip_serializing_if = "Option::is_none")]
568    pub status: Option<String>,
569
570    /// Elapsed query time as reported by Datadog, in milliseconds.
571    #[serde(default, skip_serializing_if = "Option::is_none")]
572    pub elapsed: Option<i64>,
573
574    /// Datadog request id; useful for support escalations.
575    #[serde(default, skip_serializing_if = "Option::is_none")]
576    pub request_id: Option<String>,
577
578    /// Optional non-fatal warnings emitted by the search.
579    #[serde(default, skip_serializing_if = "Option::is_none")]
580    pub warnings: Option<serde_json::Value>,
581}
582
583/// Response from `POST /api/v2/logs/events/search`.
584///
585/// Phase 1 ships single-page only; the cursor token is preserved on
586/// `meta.page.after` so a future Phase 2 follow-up can iterate without
587/// changing the wire types.
588#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
589pub struct LogSearchResult {
590    /// Events returned by the API.
591    #[serde(default)]
592    pub data: Vec<LogEvent>,
593
594    /// Pagination + status metadata.
595    #[serde(default, skip_serializing_if = "Option::is_none")]
596    pub meta: Option<LogSearchMeta>,
597}
598
599impl JsonlSerialize for LogSearchResult {
600    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
601        write_items_jsonl(self.data.iter(), out)
602    }
603}
604
605// ── Phase 2: events ────────────────────────────────────────────────
606
607/// Attributes payload of an event returned by `GET /api/v2/events`.
608///
609/// Datadog wraps each event in a `{ id, type, attributes }` envelope. The
610/// `attributes` block in turn contains a nested `attributes` map plus
611/// flat fields like `tags`, `timestamp`, and `service`. Only the fields
612/// the CLI uses for table rendering are surfaced as named fields; the
613/// rest round-trips through `extra` so JSON / YAML output stays lossless.
614#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
615pub struct EventAttributes {
616    /// Event timestamp as Datadog returns it (RFC 3339 string).
617    #[serde(default, skip_serializing_if = "Option::is_none")]
618    pub timestamp: Option<String>,
619
620    /// Event title (often the headline shown in the Datadog UI).
621    #[serde(default, skip_serializing_if = "Option::is_none")]
622    pub title: Option<String>,
623
624    /// Free-form event body / description.
625    #[serde(default, skip_serializing_if = "Option::is_none")]
626    pub text: Option<String>,
627
628    /// Source name reported by the event producer (e.g. `aws`, `kubernetes`).
629    #[serde(default, skip_serializing_if = "Option::is_none")]
630    pub source: Option<String>,
631
632    /// Service emitting the event (when applicable).
633    #[serde(default, skip_serializing_if = "Option::is_none")]
634    pub service: Option<String>,
635
636    /// Originating host.
637    #[serde(default, skip_serializing_if = "Option::is_none")]
638    pub host: Option<String>,
639
640    /// Event status (`info`, `warning`, `error`, `success`, …).
641    #[serde(default, skip_serializing_if = "Option::is_none")]
642    pub status: Option<String>,
643
644    /// Aggregation key — events sharing one collapse in the UI.
645    #[serde(default, skip_serializing_if = "Option::is_none")]
646    pub aggregation_key: Option<String>,
647
648    /// Tags applied to the event.
649    #[serde(default)]
650    pub tags: Vec<String>,
651
652    /// Nested per-source attributes Datadog returns under `attributes.attributes`.
653    /// Preserved as raw JSON because the schema varies by event type.
654    #[serde(default, skip_serializing_if = "Option::is_none")]
655    pub attributes: Option<serde_json::Value>,
656}
657
658/// A single event hit returned by `GET /api/v2/events`.
659#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
660pub struct Event {
661    /// Datadog event identifier.
662    pub id: String,
663
664    /// Resource type marker — Datadog returns the literal string `"event"`.
665    #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
666    pub event_type: Option<String>,
667
668    /// Event payload.
669    #[serde(default)]
670    pub attributes: EventAttributes,
671}
672
673impl Event {
674    /// Timestamp string for table output. Falls back to `-` when unset.
675    #[must_use]
676    pub fn timestamp_label(&self) -> &str {
677        self.attributes.timestamp.as_deref().unwrap_or("-")
678    }
679
680    /// Title string for table output. Falls back to `-` when unset.
681    #[must_use]
682    pub fn title_label(&self) -> &str {
683        self.attributes.title.as_deref().unwrap_or("-")
684    }
685
686    /// Source string for table output. Falls back to `-` when unset.
687    #[must_use]
688    pub fn source_label(&self) -> &str {
689        self.attributes.source.as_deref().unwrap_or("-")
690    }
691
692    /// Host string for table output. Falls back to `-` when unset.
693    #[must_use]
694    pub fn host_label(&self) -> &str {
695        self.attributes.host.as_deref().unwrap_or("-")
696    }
697}
698
699impl JsonlSerialize for Event {
700    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
701        write_scalar_jsonl(self, out)
702    }
703}
704
705/// Cursor pagination block returned by `GET /api/v2/events`.
706#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
707pub struct EventsPage {
708    /// Cursor token for the next page; absent when no further pages exist.
709    #[serde(default, skip_serializing_if = "Option::is_none")]
710    pub after: Option<String>,
711}
712
713/// Search-level metadata returned by `GET /api/v2/events`.
714#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
715pub struct EventsMeta {
716    /// Cursor pagination block.
717    #[serde(default, skip_serializing_if = "Option::is_none")]
718    pub page: Option<EventsPage>,
719
720    /// Search status reported by Datadog.
721    #[serde(default, skip_serializing_if = "Option::is_none")]
722    pub status: Option<String>,
723
724    /// Datadog request id.
725    #[serde(default, skip_serializing_if = "Option::is_none")]
726    pub request_id: Option<String>,
727
728    /// Elapsed query time as reported by Datadog, in milliseconds.
729    #[serde(default, skip_serializing_if = "Option::is_none")]
730    pub elapsed: Option<i64>,
731
732    /// Optional non-fatal warnings emitted by the search.
733    #[serde(default, skip_serializing_if = "Option::is_none")]
734    pub warnings: Option<serde_json::Value>,
735}
736
737/// Response from `GET /api/v2/events`.
738///
739/// Phase 2 ships single-page only; the cursor token is preserved on
740/// `meta.page.after` for callers that need to iterate manually.
741#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
742pub struct EventsResponse {
743    /// Events returned by the API.
744    #[serde(default)]
745    pub data: Vec<Event>,
746
747    /// Pagination + status metadata.
748    #[serde(default, skip_serializing_if = "Option::is_none")]
749    pub meta: Option<EventsMeta>,
750
751    /// Cursor / self link block (preserved as raw JSON for round-trip).
752    #[serde(default, skip_serializing_if = "Option::is_none")]
753    pub links: Option<serde_json::Value>,
754}
755
756impl JsonlSerialize for EventsResponse {
757    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
758        write_items_jsonl(self.data.iter(), out)
759    }
760}
761
762// ── Phase 2: SLOs ──────────────────────────────────────────────────
763
764/// A Datadog Service Level Objective as returned by `GET /api/v1/slo`
765/// and `GET /api/v1/slo/{id}`.
766///
767/// The `query` and `thresholds` shapes vary by SLO type (metric / monitor
768/// / time-slice), so they're preserved as raw `serde_json::Value` to keep
769/// JSON / YAML output lossless without pulling Datadog's variant schemas
770/// into the type surface.
771#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
772pub struct Slo {
773    /// Datadog SLO identifier.
774    pub id: String,
775
776    /// Human-readable name.
777    pub name: String,
778
779    /// SLO type as reported by Datadog (`metric`, `monitor`, `time_slice`).
780    #[serde(rename = "type")]
781    pub slo_type: String,
782
783    /// Query specification (raw — schema differs per SLO type).
784    #[serde(default, skip_serializing_if = "Option::is_none")]
785    pub query: Option<serde_json::Value>,
786
787    /// Target threshold definitions (raw — list of objects).
788    #[serde(default, skip_serializing_if = "Option::is_none")]
789    pub thresholds: Option<serde_json::Value>,
790
791    /// Tags applied to the SLO.
792    #[serde(default)]
793    pub tags: Vec<String>,
794
795    /// Underlying monitor ids (for monitor SLOs).
796    #[serde(default)]
797    pub monitor_ids: Vec<i64>,
798
799    /// Tags propagated from underlying monitors.
800    #[serde(default, skip_serializing_if = "Option::is_none")]
801    pub monitor_tags: Option<Vec<String>>,
802
803    /// Optional description.
804    #[serde(default, skip_serializing_if = "Option::is_none")]
805    pub description: Option<String>,
806
807    /// Creation timestamp (Datadog returns Unix epoch seconds).
808    #[serde(default, skip_serializing_if = "Option::is_none")]
809    pub created_at: Option<i64>,
810
811    /// Last-modified timestamp (Datadog returns Unix epoch seconds).
812    #[serde(default, skip_serializing_if = "Option::is_none")]
813    pub modified_at: Option<i64>,
814
815    /// Creator (raw object).
816    #[serde(default, skip_serializing_if = "Option::is_none")]
817    pub creator: Option<serde_json::Value>,
818
819    /// Optional grouping facets (raw — present for multi-group SLOs).
820    #[serde(default, skip_serializing_if = "Option::is_none")]
821    pub groups: Option<serde_json::Value>,
822
823    /// Configured alert ids (raw — list of integers).
824    #[serde(default, skip_serializing_if = "Option::is_none")]
825    pub configured_alert_ids: Option<serde_json::Value>,
826}
827
828impl JsonlSerialize for Slo {
829    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
830        write_scalar_jsonl(self, out)
831    }
832}
833
834/// Response envelope for `GET /api/v1/slo`.
835///
836/// `errors` is populated when Datadog rejects part of the request; the
837/// list façade surfaces those as a hard failure.
838#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
839pub struct SloListResponse {
840    /// SLOs returned by the API.
841    #[serde(default)]
842    pub data: Vec<Slo>,
843
844    /// Optional non-fatal error list (raw — populated under partial failure).
845    #[serde(default, skip_serializing_if = "Option::is_none")]
846    pub error: Option<serde_json::Value>,
847
848    /// Optional non-fatal error list (per Datadog's plural variant).
849    #[serde(default, skip_serializing_if = "Option::is_none")]
850    pub errors: Option<Vec<String>>,
851}
852
853/// Response envelope for `GET /api/v1/slo/{id}`.
854#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
855pub struct SloGetResponse {
856    /// The SLO returned by the API.
857    pub data: Slo,
858
859    /// Optional non-fatal error block (raw).
860    #[serde(default, skip_serializing_if = "Option::is_none")]
861    pub error: Option<serde_json::Value>,
862
863    /// Optional non-fatal error list.
864    #[serde(default, skip_serializing_if = "Option::is_none")]
865    pub errors: Option<Vec<String>>,
866}
867
868// ── Phase 2: hosts ─────────────────────────────────────────────────
869
870/// A reporting host as returned by `GET /api/v1/hosts`.
871///
872/// Datadog returns dozens of fields per host; only the ones surfaced by
873/// the table renderer are typed. `meta`, `metrics`, and `tags_by_source`
874/// are preserved as raw JSON for `-o json/yaml` output.
875#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
876pub struct Host {
877    /// Hostname as reported by the agent.
878    pub name: String,
879
880    /// Alternate names (e.g. EC2 instance id, FQDN).
881    #[serde(default)]
882    pub aliases: Vec<String>,
883
884    /// Apps (integrations) reporting on this host.
885    #[serde(default)]
886    pub apps: Vec<String>,
887
888    /// Tag map keyed by source (raw — schema is `{ source: [tag, ...] }`).
889    #[serde(default, skip_serializing_if = "Option::is_none")]
890    pub tags_by_source: Option<serde_json::Value>,
891
892    /// Whether the host is currently reporting.
893    #[serde(default, skip_serializing_if = "Option::is_none")]
894    pub up: Option<bool>,
895
896    /// Last time the host reported, in Unix epoch seconds.
897    #[serde(default, skip_serializing_if = "Option::is_none")]
898    pub last_reported_time: Option<i64>,
899
900    /// Sources Datadog has data from (e.g. `agent`, `aws`).
901    #[serde(default)]
902    pub sources: Vec<String>,
903
904    /// Whether the host is currently muted.
905    #[serde(default, skip_serializing_if = "Option::is_none")]
906    pub is_muted: Option<bool>,
907
908    /// Optional mute timeout (epoch seconds).
909    #[serde(default, skip_serializing_if = "Option::is_none")]
910    pub mute_timeout: Option<i64>,
911
912    /// Datadog-internal numeric host id.
913    #[serde(default, skip_serializing_if = "Option::is_none")]
914    pub id: Option<i64>,
915
916    /// Reporting hostname (occasionally distinct from `name`).
917    #[serde(default, skip_serializing_if = "Option::is_none")]
918    pub host_name: Option<String>,
919
920    /// Per-source meta block (raw).
921    #[serde(default, skip_serializing_if = "Option::is_none")]
922    pub meta: Option<serde_json::Value>,
923
924    /// Per-host metrics block (raw).
925    #[serde(default, skip_serializing_if = "Option::is_none")]
926    pub metrics: Option<serde_json::Value>,
927}
928
929impl Host {
930    /// `up` rendered as `yes` / `no` / `-` for the bespoke table view.
931    #[must_use]
932    pub fn up_label(&self) -> &'static str {
933        match self.up {
934            Some(true) => "yes",
935            Some(false) => "no",
936            None => "-",
937        }
938    }
939}
940
941impl JsonlSerialize for Host {
942    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
943        write_scalar_jsonl(self, out)
944    }
945}
946
947/// Response envelope for `GET /api/v1/hosts`.
948#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
949pub struct HostsResponse {
950    /// Hosts returned in the current page.
951    #[serde(default)]
952    pub host_list: Vec<Host>,
953
954    /// Number of hosts in the current response.
955    #[serde(default, skip_serializing_if = "Option::is_none")]
956    pub total_returned: Option<i64>,
957
958    /// Total number of hosts matching the query across all pages.
959    #[serde(default, skip_serializing_if = "Option::is_none")]
960    pub total_matching: Option<i64>,
961}
962
963impl JsonlSerialize for HostsResponse {
964    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
965        write_items_jsonl(self.host_list.iter(), out)
966    }
967}
968
969// ── Phase 2: downtimes ─────────────────────────────────────────────
970
971/// A scheduled downtime as returned by `GET /api/v1/downtime`.
972///
973/// `recurrence` is preserved as raw JSON because Datadog encodes it as
974/// either `null`, an object, or an array of objects depending on the
975/// downtime kind.
976#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
977pub struct Downtime {
978    /// Datadog downtime identifier.
979    pub id: i64,
980
981    /// Scope tags the downtime applies to (e.g. `["env:prod"]`).
982    #[serde(default)]
983    pub scope: Vec<String>,
984
985    /// Optional monitor tags filter.
986    #[serde(default)]
987    pub monitor_tags: Vec<String>,
988
989    /// Start time (epoch seconds).
990    #[serde(default, skip_serializing_if = "Option::is_none")]
991    pub start: Option<i64>,
992
993    /// End time (epoch seconds). Absent for indefinite downtimes.
994    #[serde(default, skip_serializing_if = "Option::is_none")]
995    pub end: Option<i64>,
996
997    /// Notification message body.
998    #[serde(default, skip_serializing_if = "Option::is_none")]
999    pub message: Option<String>,
1000
1001    /// Whether the downtime is currently active.
1002    #[serde(default, skip_serializing_if = "Option::is_none")]
1003    pub active: Option<bool>,
1004
1005    /// Whether the downtime has been disabled.
1006    #[serde(default, skip_serializing_if = "Option::is_none")]
1007    pub disabled: Option<bool>,
1008
1009    /// Underlying monitor id (for single-monitor downtimes).
1010    #[serde(default, skip_serializing_if = "Option::is_none")]
1011    pub monitor_id: Option<i64>,
1012
1013    /// Recurrence rule (raw — null / object / array).
1014    #[serde(default, skip_serializing_if = "Option::is_none")]
1015    pub recurrence: Option<serde_json::Value>,
1016
1017    /// Creation timestamp (epoch seconds).
1018    #[serde(default, skip_serializing_if = "Option::is_none")]
1019    pub created: Option<i64>,
1020
1021    /// Last-modified timestamp (epoch seconds).
1022    #[serde(default, skip_serializing_if = "Option::is_none")]
1023    pub modified: Option<i64>,
1024
1025    /// Creator user id.
1026    #[serde(default, skip_serializing_if = "Option::is_none")]
1027    pub creator_id: Option<i64>,
1028
1029    /// Parent downtime id (for child downtimes generated from a recurrence).
1030    #[serde(default, skip_serializing_if = "Option::is_none")]
1031    pub parent_id: Option<i64>,
1032
1033    /// Timezone for the downtime schedule.
1034    #[serde(default, skip_serializing_if = "Option::is_none")]
1035    pub timezone: Option<String>,
1036}
1037
1038impl Downtime {
1039    /// Joins `scope` tags with commas, falling back to `*` for empty
1040    /// scope (the convention Datadog's UI uses for "all").
1041    #[must_use]
1042    pub fn scope_label(&self) -> String {
1043        if self.scope.is_empty() {
1044            "*".to_string()
1045        } else {
1046            self.scope.join(",")
1047        }
1048    }
1049
1050    /// Message for table output (single-line, falling back to `-`).
1051    #[must_use]
1052    pub fn message_label(&self) -> &str {
1053        self.message.as_deref().unwrap_or("-")
1054    }
1055
1056    /// Monitor id for table output (formatted, fallback `-`).
1057    #[must_use]
1058    pub fn monitor_label(&self) -> String {
1059        self.monitor_id
1060            .map_or_else(|| "-".to_string(), |id| id.to_string())
1061    }
1062}
1063
1064impl JsonlSerialize for Downtime {
1065    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
1066        write_scalar_jsonl(self, out)
1067    }
1068}
1069
1070// ── Phase 2: metric catalog ────────────────────────────────────────
1071
1072/// Response from `GET /api/v1/metrics`.
1073///
1074/// Datadog returns a flat array of metric names. The optional `from`
1075/// echoes back the requested time anchor.
1076#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
1077pub struct MetricCatalogResponse {
1078    /// Echo of the requested `from` (Unix epoch seconds).
1079    #[serde(default, skip_serializing_if = "Option::is_none")]
1080    pub from: Option<i64>,
1081
1082    /// Metric names returned by the API.
1083    #[serde(default)]
1084    pub metrics: Vec<String>,
1085}
1086
1087impl JsonlSerialize for MetricCatalogResponse {
1088    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
1089        for m in &self.metrics {
1090            write_scalar_jsonl(m, out)?;
1091        }
1092        Ok(())
1093    }
1094}
1095
1096#[cfg(test)]
1097#[allow(clippy::unwrap_used)]
1098mod tests {
1099    use super::*;
1100
1101    fn sample_response_json() -> serde_json::Value {
1102        serde_json::json!({
1103            "status": "ok",
1104            "from_date": 1_700_000_000_000_i64,
1105            "to_date":   1_700_000_030_000_i64,
1106            "series": [
1107                {
1108                    "metric": "avg:system.cpu.user{*}",
1109                    "display_name": "avg:system.cpu.user{*}",
1110                    "scope": "host:*",
1111                    "expression": "avg:system.cpu.user{*}",
1112                    "pointlist": [
1113                        [1_700_000_000_000_i64, 0.5_f64],
1114                        [1_700_000_015_000_i64, null],
1115                        [1_700_000_030_000_i64, 0.6_f64]
1116                    ],
1117                    "length": 3,
1118                    "unit": [],
1119                    "attributes": {}
1120                }
1121            ]
1122        })
1123    }
1124
1125    #[test]
1126    fn deserialize_strips_unknown_fields() {
1127        let resp: MetricQueryResponse = serde_json::from_value(sample_response_json()).unwrap();
1128        assert_eq!(resp.status, "ok");
1129        assert_eq!(resp.series.len(), 1);
1130        let series = &resp.series[0];
1131        assert_eq!(series.metric, "avg:system.cpu.user{*}");
1132        assert_eq!(series.pointlist.len(), 3);
1133        assert_eq!(series.pointlist[1].1, None);
1134        assert_eq!(series.pointlist[2].1, Some(0.6));
1135    }
1136
1137    #[test]
1138    fn series_defaults_are_applied() {
1139        let value = serde_json::json!({
1140            "status": "ok",
1141            "from_date": 0_i64,
1142            "to_date":   0_i64,
1143            "series": [{"metric": "m"}]
1144        });
1145        let resp: MetricQueryResponse = serde_json::from_value(value).unwrap();
1146        assert_eq!(resp.series[0].metric, "m");
1147        assert!(resp.series[0].pointlist.is_empty());
1148        assert_eq!(resp.series[0].display_name, None);
1149    }
1150
1151    #[test]
1152    fn series_label_prefers_display_name() {
1153        let s = MetricSeries {
1154            metric: "m".into(),
1155            display_name: Some("d".into()),
1156            scope: None,
1157            expression: Some("e".into()),
1158            pointlist: vec![],
1159        };
1160        assert_eq!(s.label(), "d");
1161    }
1162
1163    #[test]
1164    fn series_label_falls_back_to_expression_then_metric() {
1165        let s = MetricSeries {
1166            metric: "m".into(),
1167            display_name: None,
1168            scope: None,
1169            expression: Some("e".into()),
1170            pointlist: vec![],
1171        };
1172        assert_eq!(s.label(), "e");
1173
1174        let s = MetricSeries {
1175            metric: "m".into(),
1176            display_name: None,
1177            scope: None,
1178            expression: None,
1179            pointlist: vec![],
1180        };
1181        assert_eq!(s.label(), "m");
1182    }
1183
1184    #[test]
1185    fn metric_query_response_jsonl_emits_one_object_per_call() {
1186        let resp: MetricQueryResponse = serde_json::from_value(sample_response_json()).unwrap();
1187        let mut buf = Vec::new();
1188        resp.write_jsonl(&mut buf).unwrap();
1189        let out = String::from_utf8(buf).unwrap();
1190        assert!(out.ends_with('\n'));
1191        assert_eq!(out.matches('\n').count(), 1);
1192        let value: serde_json::Value = serde_json::from_str(out.trim_end()).unwrap();
1193        assert_eq!(value["status"], "ok");
1194    }
1195
1196    #[test]
1197    fn metric_query_response_roundtrips_through_json() {
1198        let resp: MetricQueryResponse = serde_json::from_value(sample_response_json()).unwrap();
1199        let json = serde_json::to_string(&resp).unwrap();
1200        let roundtripped: MetricQueryResponse = serde_json::from_str(&json).unwrap();
1201        assert_eq!(resp, roundtripped);
1202    }
1203
1204    // ── Monitor ────────────────────────────────────────────────────
1205
1206    fn sample_monitor_json() -> serde_json::Value {
1207        serde_json::json!({
1208            "id": 12345_i64,
1209            "name": "CPU high",
1210            "type": "metric alert",
1211            "query": "avg(last_5m):avg:system.cpu.user{*} > 90",
1212            "message": "Notify @ops",
1213            "tags": ["team:sre", "env:prod"],
1214            "overall_state": "OK",
1215            "created": "2024-01-01T00:00:00.000Z",
1216            "modified": "2024-02-01T00:00:00.000Z",
1217            "priority": 2_i64,
1218            "multi": true,
1219            "creator": {"name": "Alice", "email": "alice@example.com"},
1220            "options": {"notify_no_data": true, "no_data_timeframe": 10},
1221            "deleted": null,
1222            "matching_downtimes": []
1223        })
1224    }
1225
1226    #[test]
1227    fn monitor_deserialize_strips_unknown_fields_and_renames_type() {
1228        let m: Monitor = serde_json::from_value(sample_monitor_json()).unwrap();
1229        assert_eq!(m.id, 12345);
1230        assert_eq!(m.name, "CPU high");
1231        assert_eq!(m.monitor_type, "metric alert");
1232        assert_eq!(m.tags, vec!["team:sre", "env:prod"]);
1233        assert_eq!(m.overall_state.as_deref(), Some("OK"));
1234        assert_eq!(m.priority, Some(2));
1235        assert_eq!(m.multi, Some(true));
1236        assert!(m.creator.is_some());
1237        assert!(m.options.is_some());
1238    }
1239
1240    #[test]
1241    fn monitor_defaults_when_optional_fields_missing() {
1242        let value = serde_json::json!({
1243            "id": 1_i64,
1244            "name": "n",
1245            "type": "metric alert",
1246            "query": "q"
1247        });
1248        let m: Monitor = serde_json::from_value(value).unwrap();
1249        assert!(m.tags.is_empty());
1250        assert_eq!(m.overall_state, None);
1251        assert_eq!(m.message, None);
1252        assert_eq!(m.priority, None);
1253        assert_eq!(m.multi, None);
1254        assert!(m.creator.is_none());
1255        assert!(m.options.is_none());
1256    }
1257
1258    #[test]
1259    fn monitor_status_falls_back_to_dash() {
1260        let m = Monitor {
1261            id: 1,
1262            name: "n".into(),
1263            monitor_type: "metric alert".into(),
1264            query: "q".into(),
1265            message: None,
1266            tags: vec![],
1267            overall_state: None,
1268            created: None,
1269            modified: None,
1270            priority: None,
1271            multi: None,
1272            creator: None,
1273            options: None,
1274        };
1275        assert_eq!(m.status(), "-");
1276    }
1277
1278    #[test]
1279    fn monitor_status_returns_overall_state_when_present() {
1280        let m = Monitor {
1281            id: 1,
1282            name: "n".into(),
1283            monitor_type: "metric alert".into(),
1284            query: "q".into(),
1285            message: None,
1286            tags: vec![],
1287            overall_state: Some("Alert".into()),
1288            created: None,
1289            modified: None,
1290            priority: None,
1291            multi: None,
1292            creator: None,
1293            options: None,
1294        };
1295        assert_eq!(m.status(), "Alert");
1296    }
1297
1298    #[test]
1299    fn monitor_jsonl_emits_one_line_per_call() {
1300        let m: Monitor = serde_json::from_value(sample_monitor_json()).unwrap();
1301        let mut buf = Vec::new();
1302        m.write_jsonl(&mut buf).unwrap();
1303        let out = String::from_utf8(buf).unwrap();
1304        assert_eq!(out.matches('\n').count(), 1);
1305        let v: serde_json::Value = serde_json::from_str(out.trim_end()).unwrap();
1306        assert_eq!(v["id"], 12345);
1307        assert_eq!(v["type"], "metric alert");
1308    }
1309
1310    #[test]
1311    fn monitor_roundtrips_through_json() {
1312        let m: Monitor = serde_json::from_value(sample_monitor_json()).unwrap();
1313        let json = serde_json::to_string(&m).unwrap();
1314        let m2: Monitor = serde_json::from_str(&json).unwrap();
1315        assert_eq!(m, m2);
1316    }
1317
1318    // ── MonitorSearchResult / Item ─────────────────────────────────
1319
1320    fn sample_search_json() -> serde_json::Value {
1321        serde_json::json!({
1322            "monitors": [
1323                {
1324                    "id": 1_i64,
1325                    "name": "Disk full",
1326                    "status": "ALERT",
1327                    "tags": ["team:sre"],
1328                    "type": "metric alert",
1329                    "query": "avg(last_1h):avg:system.disk.in_use{*} > 0.9",
1330                    "last_triggered_ts": 1_700_000_000_000_i64,
1331                    "creator": {"name": "Alice"}
1332                },
1333                {
1334                    "id": 2_i64,
1335                    "name": "Latency",
1336                    "tags": []
1337                }
1338            ],
1339            "counts": {"status": [{"name": "ALERT", "count": 1}]},
1340            "metadata": {
1341                "page": 0,
1342                "per_page": 30,
1343                "page_count": 1,
1344                "total_count": 2
1345            }
1346        })
1347    }
1348
1349    #[test]
1350    fn monitor_search_result_deserializes_full_envelope() {
1351        let r: MonitorSearchResult = serde_json::from_value(sample_search_json()).unwrap();
1352        assert_eq!(r.monitors.len(), 2);
1353        assert_eq!(r.monitors[0].id, 1);
1354        assert_eq!(r.monitors[0].status.as_deref(), Some("ALERT"));
1355        assert_eq!(r.monitors[0].monitor_type.as_deref(), Some("metric alert"));
1356        assert_eq!(r.monitors[0].last_triggered_ts, Some(1_700_000_000_000));
1357        assert_eq!(r.monitors[1].status, None);
1358        assert!(r.monitors[1].tags.is_empty());
1359        assert!(r.counts.is_some());
1360        let meta = r.metadata.as_ref().unwrap();
1361        assert_eq!(meta.total_count, Some(2));
1362        assert_eq!(meta.page, Some(0));
1363    }
1364
1365    #[test]
1366    fn monitor_search_result_defaults_when_optional_fields_missing() {
1367        let r: MonitorSearchResult = serde_json::from_value(serde_json::json!({})).unwrap();
1368        assert!(r.monitors.is_empty());
1369        assert!(r.counts.is_none());
1370        assert!(r.metadata.is_none());
1371    }
1372
1373    #[test]
1374    fn monitor_search_item_status_label_falls_back_to_dash() {
1375        let item = MonitorSearchItem {
1376            id: 1,
1377            name: "n".into(),
1378            status: None,
1379            tags: vec![],
1380            monitor_type: None,
1381            query: None,
1382            last_triggered_ts: None,
1383            creator: None,
1384        };
1385        assert_eq!(item.status_label(), "-");
1386    }
1387
1388    #[test]
1389    fn monitor_search_item_status_label_returns_status_when_present() {
1390        let item = MonitorSearchItem {
1391            id: 1,
1392            name: "n".into(),
1393            status: Some("OK".into()),
1394            tags: vec![],
1395            monitor_type: None,
1396            query: None,
1397            last_triggered_ts: None,
1398            creator: None,
1399        };
1400        assert_eq!(item.status_label(), "OK");
1401    }
1402
1403    #[test]
1404    fn monitor_search_result_jsonl_emits_one_line_per_monitor() {
1405        let r: MonitorSearchResult = serde_json::from_value(sample_search_json()).unwrap();
1406        let mut buf = Vec::new();
1407        r.write_jsonl(&mut buf).unwrap();
1408        let out = String::from_utf8(buf).unwrap();
1409        assert_eq!(out.matches('\n').count(), 2);
1410        let lines: Vec<&str> = out.lines().collect();
1411        let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
1412        assert_eq!(first["id"], 1);
1413        assert_eq!(first["status"], "ALERT");
1414        let second: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
1415        assert_eq!(second["id"], 2);
1416    }
1417
1418    #[test]
1419    fn monitor_search_result_jsonl_empty_monitors_emits_nothing() {
1420        let r = MonitorSearchResult::default();
1421        let mut buf = Vec::new();
1422        r.write_jsonl(&mut buf).unwrap();
1423        assert!(buf.is_empty());
1424    }
1425
1426    #[test]
1427    fn monitor_search_result_roundtrips_through_json() {
1428        let r: MonitorSearchResult = serde_json::from_value(sample_search_json()).unwrap();
1429        let json = serde_json::to_string(&r).unwrap();
1430        let r2: MonitorSearchResult = serde_json::from_str(&json).unwrap();
1431        assert_eq!(r, r2);
1432    }
1433
1434    // ── Dashboard / DashboardSummary ───────────────────────────────
1435
1436    fn sample_dashboard_summary_json() -> serde_json::Value {
1437        serde_json::json!({
1438            "id": "abc-def-ghi",
1439            "title": "Service Overview",
1440            "author_handle": "alice@example.com",
1441            "url": "/dashboard/abc-def-ghi/service-overview",
1442            "modified_at": "2024-02-01T00:00:00.000Z",
1443            "created_at": "2024-01-01T00:00:00.000Z",
1444            "description": "Top-level service health.",
1445            "is_shared": true,
1446            "is_read_only": false,
1447            "layout_type": "ordered",
1448            "deleted": null
1449        })
1450    }
1451
1452    fn sample_dashboard_json() -> serde_json::Value {
1453        serde_json::json!({
1454            "id": "abc-def-ghi",
1455            "title": "Service Overview",
1456            "description": "Top-level service health.",
1457            "url": "/dashboard/abc-def-ghi",
1458            "author_handle": "alice@example.com",
1459            "created_at": "2024-01-01T00:00:00.000Z",
1460            "modified_at": "2024-02-01T00:00:00.000Z",
1461            "layout_type": "ordered",
1462            "is_read_only": false,
1463            "reflow_type": "auto",
1464            "notify_list": [],
1465            "template_variables": [
1466                {"name": "env", "default": "prod"}
1467            ],
1468            "widgets": [
1469                {"id": 1, "definition": {"type": "note", "content": "hello"}}
1470            ],
1471            "extra_unknown": "ignored"
1472        })
1473    }
1474
1475    #[test]
1476    fn dashboard_summary_deserializes_full_payload() {
1477        let s: DashboardSummary = serde_json::from_value(sample_dashboard_summary_json()).unwrap();
1478        assert_eq!(s.id, "abc-def-ghi");
1479        assert_eq!(s.title, "Service Overview");
1480        assert_eq!(s.author_handle.as_deref(), Some("alice@example.com"));
1481        assert_eq!(
1482            s.url.as_deref(),
1483            Some("/dashboard/abc-def-ghi/service-overview")
1484        );
1485        assert_eq!(s.is_shared, Some(true));
1486        assert_eq!(s.is_read_only, Some(false));
1487        assert_eq!(s.layout_type.as_deref(), Some("ordered"));
1488    }
1489
1490    #[test]
1491    fn dashboard_summary_defaults_when_optional_fields_missing() {
1492        let s: DashboardSummary = serde_json::from_value(serde_json::json!({
1493            "id": "x",
1494            "title": "y"
1495        }))
1496        .unwrap();
1497        assert_eq!(s.author_handle, None);
1498        assert_eq!(s.url, None);
1499        assert_eq!(s.is_shared, None);
1500        assert_eq!(s.author_label(), "-");
1501        assert_eq!(s.url_label(), "-");
1502    }
1503
1504    #[test]
1505    fn dashboard_summary_labels_use_present_fields() {
1506        let s = DashboardSummary {
1507            id: "x".into(),
1508            title: "y".into(),
1509            author_handle: Some("alice".into()),
1510            url: Some("/u".into()),
1511            modified_at: None,
1512            created_at: None,
1513            description: None,
1514            is_shared: None,
1515            is_read_only: None,
1516            layout_type: None,
1517        };
1518        assert_eq!(s.author_label(), "alice");
1519        assert_eq!(s.url_label(), "/u");
1520    }
1521
1522    #[test]
1523    fn dashboard_summary_jsonl_emits_one_line_per_call() {
1524        let s: DashboardSummary = serde_json::from_value(sample_dashboard_summary_json()).unwrap();
1525        let mut buf = Vec::new();
1526        s.write_jsonl(&mut buf).unwrap();
1527        let out = String::from_utf8(buf).unwrap();
1528        assert_eq!(out.matches('\n').count(), 1);
1529        let v: serde_json::Value = serde_json::from_str(out.trim_end()).unwrap();
1530        assert_eq!(v["id"], "abc-def-ghi");
1531    }
1532
1533    #[test]
1534    fn dashboard_summary_roundtrips_through_json() {
1535        let s: DashboardSummary = serde_json::from_value(sample_dashboard_summary_json()).unwrap();
1536        let json = serde_json::to_string(&s).unwrap();
1537        let s2: DashboardSummary = serde_json::from_str(&json).unwrap();
1538        assert_eq!(s, s2);
1539    }
1540
1541    #[test]
1542    fn dashboard_list_response_deserializes_envelope() {
1543        let r: DashboardListResponse = serde_json::from_value(serde_json::json!({
1544            "dashboards": [
1545                sample_dashboard_summary_json(),
1546                {"id": "zzz", "title": "Other"}
1547            ]
1548        }))
1549        .unwrap();
1550        assert_eq!(r.dashboards.len(), 2);
1551        assert_eq!(r.dashboards[1].id, "zzz");
1552    }
1553
1554    #[test]
1555    fn dashboard_list_response_defaults_to_empty() {
1556        let r: DashboardListResponse = serde_json::from_value(serde_json::json!({})).unwrap();
1557        assert!(r.dashboards.is_empty());
1558    }
1559
1560    #[test]
1561    fn dashboard_deserializes_full_payload_and_strips_unknowns() {
1562        let d: Dashboard = serde_json::from_value(sample_dashboard_json()).unwrap();
1563        assert_eq!(d.id, "abc-def-ghi");
1564        assert_eq!(d.title, "Service Overview");
1565        assert_eq!(d.layout_type.as_deref(), Some("ordered"));
1566        assert_eq!(d.reflow_type.as_deref(), Some("auto"));
1567        assert!(d.widgets.is_some());
1568        assert!(d.template_variables.is_some());
1569        assert!(d.notify_list.is_some());
1570    }
1571
1572    #[test]
1573    fn dashboard_defaults_when_optional_fields_missing() {
1574        let d: Dashboard = serde_json::from_value(serde_json::json!({
1575            "id": "x",
1576            "title": "y"
1577        }))
1578        .unwrap();
1579        assert!(d.widgets.is_none());
1580        assert!(d.notify_list.is_none());
1581        assert!(d.template_variables.is_none());
1582        assert_eq!(d.author_label(), "-");
1583        assert_eq!(d.url_label(), "-");
1584    }
1585
1586    #[test]
1587    fn dashboard_labels_use_present_fields() {
1588        let d = Dashboard {
1589            id: "x".into(),
1590            title: "y".into(),
1591            description: None,
1592            url: Some("/u".into()),
1593            author_handle: Some("alice".into()),
1594            created_at: None,
1595            modified_at: None,
1596            layout_type: None,
1597            is_read_only: None,
1598            reflow_type: None,
1599            notify_list: None,
1600            template_variables: None,
1601            widgets: None,
1602        };
1603        assert_eq!(d.author_label(), "alice");
1604        assert_eq!(d.url_label(), "/u");
1605    }
1606
1607    #[test]
1608    fn dashboard_jsonl_emits_one_line_per_call() {
1609        let d: Dashboard = serde_json::from_value(sample_dashboard_json()).unwrap();
1610        let mut buf = Vec::new();
1611        d.write_jsonl(&mut buf).unwrap();
1612        let out = String::from_utf8(buf).unwrap();
1613        assert_eq!(out.matches('\n').count(), 1);
1614        let v: serde_json::Value = serde_json::from_str(out.trim_end()).unwrap();
1615        assert_eq!(v["id"], "abc-def-ghi");
1616    }
1617
1618    #[test]
1619    fn dashboard_roundtrips_through_json() {
1620        let d: Dashboard = serde_json::from_value(sample_dashboard_json()).unwrap();
1621        let json = serde_json::to_string(&d).unwrap();
1622        let d2: Dashboard = serde_json::from_str(&json).unwrap();
1623        assert_eq!(d, d2);
1624    }
1625
1626    // ── SortOrder ──────────────────────────────────────────────────
1627
1628    #[test]
1629    fn sort_order_as_api_str_uses_minus_for_desc() {
1630        assert_eq!(SortOrder::TimestampAsc.as_api_str(), "timestamp");
1631        assert_eq!(SortOrder::TimestampDesc.as_api_str(), "-timestamp");
1632    }
1633
1634    #[test]
1635    fn sort_order_serializes_to_api_string() {
1636        assert_eq!(
1637            serde_json::to_value(SortOrder::TimestampAsc).unwrap(),
1638            serde_json::Value::String("timestamp".into())
1639        );
1640        assert_eq!(
1641            serde_json::to_value(SortOrder::TimestampDesc).unwrap(),
1642            serde_json::Value::String("-timestamp".into())
1643        );
1644    }
1645
1646    #[test]
1647    fn sort_order_deserializes_known_values() {
1648        let asc: SortOrder = serde_json::from_value(serde_json::json!("timestamp")).unwrap();
1649        assert_eq!(asc, SortOrder::TimestampAsc);
1650        let desc: SortOrder = serde_json::from_value(serde_json::json!("-timestamp")).unwrap();
1651        assert_eq!(desc, SortOrder::TimestampDesc);
1652    }
1653
1654    #[test]
1655    fn sort_order_rejects_unknown_value() {
1656        let err = serde_json::from_value::<SortOrder>(serde_json::json!("nope")).unwrap_err();
1657        assert!(err.to_string().contains("unknown sort order"));
1658    }
1659
1660    #[test]
1661    fn sort_order_rejects_non_string_value() {
1662        // Exercises the `String::deserialize(...)?` propagation: a JSON
1663        // number can't be deserialised as a `String`, so the error short-
1664        // circuits before the match-on-content arm runs.
1665        let err = serde_json::from_value::<SortOrder>(serde_json::json!(42)).unwrap_err();
1666        // serde_json's error for "expected string, got number" mentions
1667        // the type name; we don't pin the exact wording.
1668        assert!(err.to_string().to_lowercase().contains("string"));
1669    }
1670
1671    // ── LogEvent / LogSearchResult ─────────────────────────────────
1672
1673    fn sample_log_search_json() -> serde_json::Value {
1674        serde_json::json!({
1675            "data": [
1676                {
1677                    "id": "AAAAAA",
1678                    "type": "log",
1679                    "attributes": {
1680                        "timestamp": "2026-04-22T10:00:00.000Z",
1681                        "service": "api",
1682                        "status": "info",
1683                        "host": "web-01",
1684                        "message": "request handled",
1685                        "tags": ["env:prod"]
1686                    }
1687                },
1688                {
1689                    "id": "BBBBBB",
1690                    "type": "log",
1691                    "attributes": {}
1692                }
1693            ],
1694            "meta": {
1695                "page": { "after": "next-cursor" },
1696                "status": "done",
1697                "elapsed": 23,
1698                "request_id": "req-1",
1699                "warnings": []
1700            }
1701        })
1702    }
1703
1704    #[test]
1705    fn log_search_result_deserializes_full_envelope() {
1706        let r: LogSearchResult = serde_json::from_value(sample_log_search_json()).unwrap();
1707        assert_eq!(r.data.len(), 2);
1708        assert_eq!(r.data[0].id, "AAAAAA");
1709        assert_eq!(r.data[0].event_type.as_deref(), Some("log"));
1710        assert_eq!(
1711            r.data[0].attributes.timestamp.as_deref(),
1712            Some("2026-04-22T10:00:00.000Z")
1713        );
1714        assert_eq!(r.data[0].attributes.tags, vec!["env:prod"]);
1715        assert!(r.data[1].attributes.tags.is_empty());
1716        let meta = r.meta.as_ref().unwrap();
1717        assert_eq!(
1718            meta.page.as_ref().and_then(|p| p.after.as_deref()),
1719            Some("next-cursor")
1720        );
1721        assert_eq!(meta.status.as_deref(), Some("done"));
1722        assert_eq!(meta.elapsed, Some(23));
1723        assert_eq!(meta.request_id.as_deref(), Some("req-1"));
1724    }
1725
1726    #[test]
1727    fn log_search_result_defaults_when_optional_fields_missing() {
1728        let r: LogSearchResult = serde_json::from_value(serde_json::json!({})).unwrap();
1729        assert!(r.data.is_empty());
1730        assert!(r.meta.is_none());
1731    }
1732
1733    #[test]
1734    fn log_event_labels_fall_back_to_dash_or_empty() {
1735        let e = LogEvent {
1736            id: "x".into(),
1737            event_type: None,
1738            attributes: LogEventAttributes::default(),
1739        };
1740        assert_eq!(e.timestamp_label(), "-");
1741        assert_eq!(e.service_label(), "-");
1742        assert_eq!(e.status_label(), "-");
1743        assert_eq!(e.message_label(), "");
1744    }
1745
1746    #[test]
1747    fn log_event_labels_use_present_fields() {
1748        let e = LogEvent {
1749            id: "x".into(),
1750            event_type: Some("log".into()),
1751            attributes: LogEventAttributes {
1752                timestamp: Some("t".into()),
1753                service: Some("s".into()),
1754                status: Some("info".into()),
1755                host: None,
1756                message: Some("hello".into()),
1757                tags: vec![],
1758            },
1759        };
1760        assert_eq!(e.timestamp_label(), "t");
1761        assert_eq!(e.service_label(), "s");
1762        assert_eq!(e.status_label(), "info");
1763        assert_eq!(e.message_label(), "hello");
1764    }
1765
1766    #[test]
1767    fn log_search_result_jsonl_emits_one_line_per_event() {
1768        let r: LogSearchResult = serde_json::from_value(sample_log_search_json()).unwrap();
1769        let mut buf = Vec::new();
1770        r.write_jsonl(&mut buf).unwrap();
1771        let out = String::from_utf8(buf).unwrap();
1772        assert_eq!(out.matches('\n').count(), 2);
1773        let lines: Vec<&str> = out.lines().collect();
1774        let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
1775        assert_eq!(first["id"], "AAAAAA");
1776    }
1777
1778    #[test]
1779    fn log_search_result_jsonl_empty_data_emits_nothing() {
1780        let r = LogSearchResult::default();
1781        let mut buf = Vec::new();
1782        r.write_jsonl(&mut buf).unwrap();
1783        assert!(buf.is_empty());
1784    }
1785
1786    #[test]
1787    fn log_event_jsonl_emits_one_line_per_call() {
1788        let r: LogSearchResult = serde_json::from_value(sample_log_search_json()).unwrap();
1789        let mut buf = Vec::new();
1790        r.data[0].write_jsonl(&mut buf).unwrap();
1791        let out = String::from_utf8(buf).unwrap();
1792        assert_eq!(out.matches('\n').count(), 1);
1793        let v: serde_json::Value = serde_json::from_str(out.trim_end()).unwrap();
1794        assert_eq!(v["id"], "AAAAAA");
1795    }
1796
1797    #[test]
1798    fn log_search_result_roundtrips_through_json() {
1799        let r: LogSearchResult = serde_json::from_value(sample_log_search_json()).unwrap();
1800        let json = serde_json::to_string(&r).unwrap();
1801        let r2: LogSearchResult = serde_json::from_str(&json).unwrap();
1802        assert_eq!(r, r2);
1803    }
1804
1805    // ── Phase 2: Event / EventsResponse ────────────────────────────
1806
1807    fn sample_events_json() -> serde_json::Value {
1808        serde_json::json!({
1809            "data": [
1810                {
1811                    "id": "EV1",
1812                    "type": "event",
1813                    "attributes": {
1814                        "timestamp": "2026-04-22T10:00:00.000Z",
1815                        "title": "Deploy",
1816                        "text": "shipped v1.2.3",
1817                        "source": "github",
1818                        "service": "api",
1819                        "host": "web-01",
1820                        "status": "success",
1821                        "aggregation_key": "deploy-1",
1822                        "tags": ["env:prod"],
1823                        "attributes": {"sha": "abc123"}
1824                    }
1825                },
1826                {
1827                    "id": "EV2",
1828                    "type": "event",
1829                    "attributes": {}
1830                }
1831            ],
1832            "meta": {
1833                "page": {"after": "next-cursor"},
1834                "status": "done",
1835                "request_id": "r-1",
1836                "elapsed": 7,
1837                "warnings": []
1838            },
1839            "links": {"self": "/api/v2/events"}
1840        })
1841    }
1842
1843    #[test]
1844    fn events_response_deserializes_full_envelope() {
1845        let r: EventsResponse = serde_json::from_value(sample_events_json()).unwrap();
1846        assert_eq!(r.data.len(), 2);
1847        assert_eq!(r.data[0].id, "EV1");
1848        assert_eq!(r.data[0].event_type.as_deref(), Some("event"));
1849        assert_eq!(r.data[0].attributes.title.as_deref(), Some("Deploy"));
1850        assert_eq!(r.data[0].attributes.tags, vec!["env:prod"]);
1851        assert!(r.data[0].attributes.attributes.is_some());
1852        let meta = r.meta.as_ref().unwrap();
1853        assert_eq!(
1854            meta.page.as_ref().and_then(|p| p.after.as_deref()),
1855            Some("next-cursor")
1856        );
1857        assert_eq!(meta.elapsed, Some(7));
1858        assert_eq!(meta.request_id.as_deref(), Some("r-1"));
1859        assert!(r.links.is_some());
1860    }
1861
1862    #[test]
1863    fn events_response_defaults_when_optional_fields_missing() {
1864        let r: EventsResponse = serde_json::from_value(serde_json::json!({})).unwrap();
1865        assert!(r.data.is_empty());
1866        assert!(r.meta.is_none());
1867        assert!(r.links.is_none());
1868    }
1869
1870    #[test]
1871    fn event_labels_fall_back_to_dash() {
1872        let e = Event {
1873            id: "x".into(),
1874            event_type: None,
1875            attributes: EventAttributes::default(),
1876        };
1877        assert_eq!(e.timestamp_label(), "-");
1878        assert_eq!(e.title_label(), "-");
1879        assert_eq!(e.source_label(), "-");
1880        assert_eq!(e.host_label(), "-");
1881    }
1882
1883    #[test]
1884    fn event_labels_use_present_fields() {
1885        let r: EventsResponse = serde_json::from_value(sample_events_json()).unwrap();
1886        let e = &r.data[0];
1887        assert_eq!(e.timestamp_label(), "2026-04-22T10:00:00.000Z");
1888        assert_eq!(e.title_label(), "Deploy");
1889        assert_eq!(e.source_label(), "github");
1890        assert_eq!(e.host_label(), "web-01");
1891    }
1892
1893    #[test]
1894    fn events_response_jsonl_emits_one_line_per_event() {
1895        let r: EventsResponse = serde_json::from_value(sample_events_json()).unwrap();
1896        let mut buf = Vec::new();
1897        r.write_jsonl(&mut buf).unwrap();
1898        let out = String::from_utf8(buf).unwrap();
1899        assert_eq!(out.matches('\n').count(), 2);
1900        let first: serde_json::Value = serde_json::from_str(out.lines().next().unwrap()).unwrap();
1901        assert_eq!(first["id"], "EV1");
1902    }
1903
1904    #[test]
1905    fn event_jsonl_emits_one_line_per_call() {
1906        let r: EventsResponse = serde_json::from_value(sample_events_json()).unwrap();
1907        let mut buf = Vec::new();
1908        r.data[0].write_jsonl(&mut buf).unwrap();
1909        let out = String::from_utf8(buf).unwrap();
1910        assert_eq!(out.matches('\n').count(), 1);
1911    }
1912
1913    #[test]
1914    fn events_response_roundtrips_through_json() {
1915        let r: EventsResponse = serde_json::from_value(sample_events_json()).unwrap();
1916        let json = serde_json::to_string(&r).unwrap();
1917        let r2: EventsResponse = serde_json::from_str(&json).unwrap();
1918        assert_eq!(r, r2);
1919    }
1920
1921    // ── Phase 2: Slo / Slo*Response ────────────────────────────────
1922
1923    fn sample_slo_json() -> serde_json::Value {
1924        serde_json::json!({
1925            "id": "abc-def",
1926            "name": "API latency p95",
1927            "type": "metric",
1928            "query": {"numerator": "sum:requests.success{*}.as_count()", "denominator": "sum:requests{*}.as_count()"},
1929            "thresholds": [{"timeframe": "30d", "target": 99.9}],
1930            "tags": ["team:sre"],
1931            "monitor_ids": [1_i64, 2_i64],
1932            "monitor_tags": ["severity:high"],
1933            "description": "Latency under 200ms",
1934            "created_at": 1_700_000_000_i64,
1935            "modified_at": 1_700_000_500_i64,
1936            "creator": {"name": "Alice"},
1937            "configured_alert_ids": [10_i64],
1938            "groups": ["env:prod"],
1939            "extra_unknown": "ignored"
1940        })
1941    }
1942
1943    #[test]
1944    fn slo_deserializes_full_payload_and_strips_unknowns() {
1945        let s: Slo = serde_json::from_value(sample_slo_json()).unwrap();
1946        assert_eq!(s.id, "abc-def");
1947        assert_eq!(s.name, "API latency p95");
1948        assert_eq!(s.slo_type, "metric");
1949        assert_eq!(s.tags, vec!["team:sre"]);
1950        assert_eq!(s.monitor_ids, vec![1, 2]);
1951        assert_eq!(
1952            s.monitor_tags.as_deref(),
1953            Some(&["severity:high".to_string()][..])
1954        );
1955        assert!(s.query.is_some());
1956        assert!(s.thresholds.is_some());
1957        assert!(s.creator.is_some());
1958        assert!(s.configured_alert_ids.is_some());
1959        assert!(s.groups.is_some());
1960    }
1961
1962    #[test]
1963    fn slo_defaults_when_optional_fields_missing() {
1964        let s: Slo = serde_json::from_value(serde_json::json!({
1965            "id": "x", "name": "y", "type": "monitor"
1966        }))
1967        .unwrap();
1968        assert!(s.tags.is_empty());
1969        assert!(s.monitor_ids.is_empty());
1970        assert!(s.query.is_none());
1971        assert!(s.thresholds.is_none());
1972        assert!(s.monitor_tags.is_none());
1973    }
1974
1975    #[test]
1976    fn slo_jsonl_emits_one_line_per_call() {
1977        let s: Slo = serde_json::from_value(sample_slo_json()).unwrap();
1978        let mut buf = Vec::new();
1979        s.write_jsonl(&mut buf).unwrap();
1980        let out = String::from_utf8(buf).unwrap();
1981        assert_eq!(out.matches('\n').count(), 1);
1982        let v: serde_json::Value = serde_json::from_str(out.trim_end()).unwrap();
1983        assert_eq!(v["id"], "abc-def");
1984        assert_eq!(v["type"], "metric");
1985    }
1986
1987    #[test]
1988    fn slo_list_response_deserializes_envelope() {
1989        let r: SloListResponse = serde_json::from_value(serde_json::json!({
1990            "data": [sample_slo_json()],
1991            "errors": ["soft warning"]
1992        }))
1993        .unwrap();
1994        assert_eq!(r.data.len(), 1);
1995        assert_eq!(r.errors.as_deref(), Some(&["soft warning".to_string()][..]));
1996    }
1997
1998    #[test]
1999    fn slo_list_response_defaults_to_empty() {
2000        let r: SloListResponse = serde_json::from_value(serde_json::json!({})).unwrap();
2001        assert!(r.data.is_empty());
2002        assert!(r.errors.is_none());
2003        assert!(r.error.is_none());
2004    }
2005
2006    #[test]
2007    fn slo_get_response_deserializes_envelope() {
2008        let r: SloGetResponse = serde_json::from_value(serde_json::json!({
2009            "data": sample_slo_json()
2010        }))
2011        .unwrap();
2012        assert_eq!(r.data.id, "abc-def");
2013        assert!(r.errors.is_none());
2014    }
2015
2016    #[test]
2017    fn slo_roundtrips_through_json() {
2018        let s: Slo = serde_json::from_value(sample_slo_json()).unwrap();
2019        let json = serde_json::to_string(&s).unwrap();
2020        let s2: Slo = serde_json::from_str(&json).unwrap();
2021        assert_eq!(s, s2);
2022    }
2023
2024    // ── Phase 2: Host / HostsResponse ──────────────────────────────
2025
2026    fn sample_host_json() -> serde_json::Value {
2027        serde_json::json!({
2028            "name": "web-01",
2029            "aliases": ["i-1234abcd", "web-01.example"],
2030            "apps": ["nginx", "ntp"],
2031            "tags_by_source": {"Datadog": ["env:prod"]},
2032            "up": true,
2033            "last_reported_time": 1_700_000_000_i64,
2034            "sources": ["agent", "aws"],
2035            "is_muted": false,
2036            "mute_timeout": null,
2037            "id": 99_i64,
2038            "host_name": "web-01.example",
2039            "meta": {"platform": "linux"},
2040            "metrics": {"load": 0.5}
2041        })
2042    }
2043
2044    #[test]
2045    fn host_deserializes_full_payload() {
2046        let h: Host = serde_json::from_value(sample_host_json()).unwrap();
2047        assert_eq!(h.name, "web-01");
2048        assert_eq!(h.aliases.len(), 2);
2049        assert_eq!(h.apps, vec!["nginx", "ntp"]);
2050        assert_eq!(h.up, Some(true));
2051        assert_eq!(h.last_reported_time, Some(1_700_000_000));
2052        assert_eq!(h.sources, vec!["agent", "aws"]);
2053        assert_eq!(h.is_muted, Some(false));
2054        assert_eq!(h.id, Some(99));
2055        assert!(h.meta.is_some());
2056    }
2057
2058    #[test]
2059    fn host_up_label_renders_yes_no_dash() {
2060        let mut h: Host = serde_json::from_value(sample_host_json()).unwrap();
2061        assert_eq!(h.up_label(), "yes");
2062        h.up = Some(false);
2063        assert_eq!(h.up_label(), "no");
2064        h.up = None;
2065        assert_eq!(h.up_label(), "-");
2066    }
2067
2068    #[test]
2069    fn host_defaults_when_optional_fields_missing() {
2070        let h: Host = serde_json::from_value(serde_json::json!({"name": "x"})).unwrap();
2071        assert!(h.aliases.is_empty());
2072        assert!(h.apps.is_empty());
2073        assert!(h.up.is_none());
2074        assert_eq!(h.up_label(), "-");
2075    }
2076
2077    #[test]
2078    fn host_jsonl_emits_one_line_per_call() {
2079        let h: Host = serde_json::from_value(sample_host_json()).unwrap();
2080        let mut buf = Vec::new();
2081        h.write_jsonl(&mut buf).unwrap();
2082        assert_eq!(String::from_utf8(buf).unwrap().matches('\n').count(), 1);
2083    }
2084
2085    #[test]
2086    fn hosts_response_deserializes_envelope() {
2087        let r: HostsResponse = serde_json::from_value(serde_json::json!({
2088            "host_list": [sample_host_json()],
2089            "total_returned": 1_i64,
2090            "total_matching": 1_i64
2091        }))
2092        .unwrap();
2093        assert_eq!(r.host_list.len(), 1);
2094        assert_eq!(r.total_returned, Some(1));
2095        assert_eq!(r.total_matching, Some(1));
2096    }
2097
2098    #[test]
2099    fn hosts_response_defaults_to_empty() {
2100        let r: HostsResponse = serde_json::from_value(serde_json::json!({})).unwrap();
2101        assert!(r.host_list.is_empty());
2102        assert!(r.total_returned.is_none());
2103    }
2104
2105    #[test]
2106    fn hosts_response_jsonl_emits_one_line_per_host() {
2107        let r: HostsResponse = serde_json::from_value(serde_json::json!({
2108            "host_list": [sample_host_json(), sample_host_json()],
2109            "total_returned": 2_i64,
2110            "total_matching": 2_i64
2111        }))
2112        .unwrap();
2113        let mut buf = Vec::new();
2114        r.write_jsonl(&mut buf).unwrap();
2115        assert_eq!(String::from_utf8(buf).unwrap().matches('\n').count(), 2);
2116    }
2117
2118    #[test]
2119    fn hosts_response_jsonl_empty_emits_nothing() {
2120        let r = HostsResponse::default();
2121        let mut buf = Vec::new();
2122        r.write_jsonl(&mut buf).unwrap();
2123        assert!(buf.is_empty());
2124    }
2125
2126    // ── Phase 2: Downtime ──────────────────────────────────────────
2127
2128    fn sample_downtime_json() -> serde_json::Value {
2129        serde_json::json!({
2130            "id": 12345_i64,
2131            "scope": ["env:prod", "team:sre"],
2132            "monitor_tags": ["severity:high"],
2133            "start": 1_700_000_000_i64,
2134            "end": 1_700_000_300_i64,
2135            "message": "Maintenance window",
2136            "active": true,
2137            "disabled": false,
2138            "monitor_id": 6789_i64,
2139            "recurrence": {"type": "weeks", "period": 1},
2140            "created": 1_699_999_000_i64,
2141            "modified": 1_699_999_500_i64,
2142            "creator_id": 42_i64,
2143            "parent_id": null,
2144            "timezone": "UTC"
2145        })
2146    }
2147
2148    #[test]
2149    fn downtime_deserializes_full_payload() {
2150        let d: Downtime = serde_json::from_value(sample_downtime_json()).unwrap();
2151        assert_eq!(d.id, 12345);
2152        assert_eq!(d.scope, vec!["env:prod", "team:sre"]);
2153        assert_eq!(d.monitor_tags, vec!["severity:high"]);
2154        assert_eq!(d.message.as_deref(), Some("Maintenance window"));
2155        assert_eq!(d.active, Some(true));
2156        assert_eq!(d.monitor_id, Some(6789));
2157        assert!(d.recurrence.is_some());
2158        assert_eq!(d.timezone.as_deref(), Some("UTC"));
2159    }
2160
2161    #[test]
2162    fn downtime_defaults_when_optional_fields_missing() {
2163        let d: Downtime = serde_json::from_value(serde_json::json!({
2164            "id": 1_i64
2165        }))
2166        .unwrap();
2167        assert!(d.scope.is_empty());
2168        assert!(d.monitor_tags.is_empty());
2169        assert!(d.message.is_none());
2170        assert!(d.recurrence.is_none());
2171    }
2172
2173    #[test]
2174    fn downtime_scope_label_joins_or_falls_back_to_star() {
2175        let d1: Downtime = serde_json::from_value(sample_downtime_json()).unwrap();
2176        assert_eq!(d1.scope_label(), "env:prod,team:sre");
2177        let d2: Downtime = serde_json::from_value(serde_json::json!({"id": 1_i64})).unwrap();
2178        assert_eq!(d2.scope_label(), "*");
2179    }
2180
2181    #[test]
2182    fn downtime_message_label_falls_back_to_dash() {
2183        let d: Downtime = serde_json::from_value(serde_json::json!({"id": 1_i64})).unwrap();
2184        assert_eq!(d.message_label(), "-");
2185        let d_with: Downtime =
2186            serde_json::from_value(serde_json::json!({"id": 1_i64, "message": "m"})).unwrap();
2187        assert_eq!(d_with.message_label(), "m");
2188    }
2189
2190    #[test]
2191    fn downtime_monitor_label_renders_id_or_dash() {
2192        let d_with: Downtime =
2193            serde_json::from_value(serde_json::json!({"id": 1_i64, "monitor_id": 99_i64})).unwrap();
2194        assert_eq!(d_with.monitor_label(), "99");
2195        let d_without: Downtime = serde_json::from_value(serde_json::json!({"id": 1_i64})).unwrap();
2196        assert_eq!(d_without.monitor_label(), "-");
2197    }
2198
2199    #[test]
2200    fn downtime_jsonl_emits_one_line_per_call() {
2201        let d: Downtime = serde_json::from_value(sample_downtime_json()).unwrap();
2202        let mut buf = Vec::new();
2203        d.write_jsonl(&mut buf).unwrap();
2204        assert_eq!(String::from_utf8(buf).unwrap().matches('\n').count(), 1);
2205    }
2206
2207    #[test]
2208    fn downtime_roundtrips_through_json() {
2209        let d: Downtime = serde_json::from_value(sample_downtime_json()).unwrap();
2210        let json = serde_json::to_string(&d).unwrap();
2211        let d2: Downtime = serde_json::from_str(&json).unwrap();
2212        assert_eq!(d, d2);
2213    }
2214
2215    // ── Phase 2: MetricCatalogResponse ─────────────────────────────
2216
2217    #[test]
2218    fn metric_catalog_response_deserializes_full_payload() {
2219        let r: MetricCatalogResponse = serde_json::from_value(serde_json::json!({
2220            "from": 1_700_000_000_i64,
2221            "metrics": ["system.cpu.user", "system.cpu.idle"]
2222        }))
2223        .unwrap();
2224        assert_eq!(r.from, Some(1_700_000_000));
2225        assert_eq!(r.metrics, vec!["system.cpu.user", "system.cpu.idle"]);
2226    }
2227
2228    #[test]
2229    fn metric_catalog_response_defaults_to_empty() {
2230        let r: MetricCatalogResponse = serde_json::from_value(serde_json::json!({})).unwrap();
2231        assert!(r.from.is_none());
2232        assert!(r.metrics.is_empty());
2233    }
2234
2235    #[test]
2236    fn metric_catalog_response_jsonl_emits_one_line_per_metric() {
2237        let r = MetricCatalogResponse {
2238            from: Some(0),
2239            metrics: vec!["a".into(), "b".into(), "c".into()],
2240        };
2241        let mut buf = Vec::new();
2242        r.write_jsonl(&mut buf).unwrap();
2243        assert_eq!(String::from_utf8(buf).unwrap(), "\"a\"\n\"b\"\n\"c\"\n");
2244    }
2245
2246    #[test]
2247    fn metric_catalog_response_jsonl_empty_emits_nothing() {
2248        let r = MetricCatalogResponse::default();
2249        let mut buf = Vec::new();
2250        r.write_jsonl(&mut buf).unwrap();
2251        assert!(buf.is_empty());
2252    }
2253}