Skip to main content

ic_query/
report.rs

1//! Shared collection provenance and report classifications.
2
3use serde::{Deserialize, Serialize};
4use std::fmt;
5#[cfg(any(feature = "nns-host", feature = "sns-host"))]
6use std::path::Path;
7
8///
9/// CollectionSourceRequest
10///
11/// Explicit network, endpoint and collection provenance shared by canister report sources.
12/// Source families retain their own network admission and transport policy.
13///
14
15#[derive(Clone, Debug, Eq, PartialEq)]
16pub struct CollectionSourceRequest {
17    /// Requested IC network identity.
18    pub network: String,
19    /// Explicit replica endpoint used for collection.
20    pub endpoint: String,
21    /// UTC collection timestamp recorded in the report.
22    pub fetched_at: String,
23    /// Collector identity recorded in the report.
24    pub fetched_by: String,
25}
26
27impl CollectionSourceRequest {
28    /// Construct source settings with explicit network and provenance.
29    #[must_use]
30    pub fn new(
31        network: impl Into<String>,
32        endpoint: impl Into<String>,
33        fetched_at: impl Into<String>,
34        fetched_by: impl Into<String>,
35    ) -> Self {
36        Self {
37            network: network.into(),
38            endpoint: endpoint.into(),
39            fetched_at: fetched_at.into(),
40            fetched_by: fetched_by.into(),
41        }
42    }
43
44    /// Construct source settings from a caller-supplied Unix collection timestamp.
45    #[must_use]
46    pub fn from_unix_secs(
47        network: impl Into<String>,
48        endpoint: impl Into<String>,
49        fetched_at_unix_secs: u64,
50        fetched_by: impl Into<String>,
51    ) -> Self {
52        Self::new(
53            network,
54            endpoint,
55            ic_host_text::timestamp::format_utc_timestamp_secs(fetched_at_unix_secs),
56            fetched_by,
57        )
58    }
59}
60
61///
62/// SortDirection
63///
64/// Direction shared by local report sorts; each sort key owns its default.
65///
66
67#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
68pub enum SortDirection {
69    /// Order values from lowest to highest.
70    Asc,
71    /// Order values from highest to lowest.
72    #[default]
73    Desc,
74}
75
76impl SortDirection {
77    /// Return the stable report label.
78    #[must_use]
79    pub const fn as_str(self) -> &'static str {
80        match self {
81            Self::Asc => "asc",
82            Self::Desc => "desc",
83        }
84    }
85}
86
87/// Compare the endpoint and collection provenance echoed by a report source.
88#[cfg(any(feature = "cloud-engine-host", feature = "dashboard-host"))]
89pub(crate) fn source_provenance_mismatch(
90    expected: (&str, &str, &str),
91    actual: (&str, &str, &str),
92) -> Option<String> {
93    [
94        ("source_endpoint", expected.0, actual.0),
95        ("fetched_at", expected.1, actual.1),
96        ("fetched_by", expected.2, actual.2),
97    ]
98    .into_iter()
99    .find_map(|(field, expected, actual)| {
100        (actual != expected)
101            .then(|| format!("{field} is {actual:?}, expected requested value {expected:?}"))
102    })
103}
104
105///
106/// SourceFuture
107///
108/// Caller-runtime future returned by an object-safe report source capability.
109///
110
111pub type SourceFuture<'a, T> = std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send + 'a>>;
112
113///
114/// ReportProvenance
115///
116/// Source and complete-cache metadata shared by report families.
117///
118
119#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
120pub struct ReportProvenance {
121    /// Whether rows came from live collection or a complete cache.
122    pub data_source: ReportDataSource,
123    /// Complete cache path, absent for live results.
124    pub cache_path: Option<String>,
125    /// API-exhaustion evidence, absent for live results.
126    pub cache_complete: Option<bool>,
127}
128
129impl ReportProvenance {
130    /// Build provenance for live reports.
131    pub(crate) const fn live() -> Self {
132        Self {
133            data_source: ReportDataSource::Live,
134            cache_path: None,
135            cache_complete: None,
136        }
137    }
138
139    /// Build provenance for complete-cache reports.
140    #[cfg(any(feature = "nns-host", feature = "sns-host"))]
141    pub(crate) fn cache(cache_path: &Path) -> Self {
142        Self {
143            data_source: ReportDataSource::Cache,
144            cache_path: Some(cache_path.display().to_string()),
145            cache_complete: Some(true),
146        }
147    }
148}
149
150///
151/// ReportDataSource
152///
153/// Origin of the data exposed by a report.
154///
155
156#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
157#[serde(rename_all = "snake_case")]
158pub enum ReportDataSource {
159    /// Rows were collected from live IC query calls.
160    Live,
161    /// Rows were read from a complete local snapshot.
162    Cache,
163}
164
165impl ReportDataSource {
166    /// Return the stable JSON and text label.
167    #[must_use]
168    pub const fn as_str(self) -> &'static str {
169        match self {
170            Self::Live => "live",
171            Self::Cache => "cache",
172        }
173    }
174}
175
176impl fmt::Display for ReportDataSource {
177    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
178        formatter.write_str(self.as_str())
179    }
180}
181
182///
183/// ReportResultScope
184///
185/// Completeness boundary represented by a report view.
186///
187
188#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
189#[serde(rename_all = "kebab-case")]
190pub enum ReportResultScope {
191    /// A bounded page or detail view collected live.
192    BoundedLive,
193    /// A view derived from an API-exhausted complete cache.
194    CompleteCache,
195}
196
197impl ReportResultScope {
198    /// Return the stable JSON and text label.
199    #[must_use]
200    pub const fn as_str(self) -> &'static str {
201        match self {
202            Self::BoundedLive => "bounded-live",
203            Self::CompleteCache => "complete-cache",
204        }
205    }
206}
207
208impl fmt::Display for ReportResultScope {
209    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
210        formatter.write_str(self.as_str())
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::{ReportDataSource, ReportResultScope};
217
218    #[cfg(any(feature = "cloud-engine-host", feature = "dashboard-host"))]
219    #[test]
220    fn source_provenance_comparison_preserves_field_order() {
221        let expected = ("endpoint", "time", "collector");
222        assert_eq!(super::source_provenance_mismatch(expected, expected), None);
223        for (actual, field) in [
224            (("other", "other", "other"), "source_endpoint"),
225            (("endpoint", "other", "other"), "fetched_at"),
226            (("endpoint", "time", "other"), "fetched_by"),
227        ] {
228            let reason = super::source_provenance_mismatch(expected, actual).unwrap();
229            assert!(reason.starts_with(&format!("{field} is ")));
230        }
231    }
232
233    #[test]
234    fn report_provenance_labels_round_trip() {
235        for (source, label) in [
236            (ReportDataSource::Live, "live"),
237            (ReportDataSource::Cache, "cache"),
238        ] {
239            assert_eq!(source.as_str(), label);
240            assert_eq!(source.to_string(), label);
241            assert_eq!(
242                serde_json::to_string(&source).expect("serialize report data source"),
243                format!("\"{label}\"")
244            );
245            assert_eq!(
246                serde_json::from_str::<ReportDataSource>(&format!("\"{label}\""))
247                    .expect("deserialize report data source"),
248                source
249            );
250        }
251        for (scope, label) in [
252            (ReportResultScope::BoundedLive, "bounded-live"),
253            (ReportResultScope::CompleteCache, "complete-cache"),
254        ] {
255            assert_eq!(scope.as_str(), label);
256            assert_eq!(scope.to_string(), label);
257            assert_eq!(
258                serde_json::to_string(&scope).expect("serialize report result scope"),
259                format!("\"{label}\"")
260            );
261            assert_eq!(
262                serde_json::from_str::<ReportResultScope>(&format!("\"{label}\""))
263                    .expect("deserialize report result scope"),
264                scope
265            );
266        }
267        assert!(serde_json::from_str::<ReportDataSource>("\"api\"").is_err());
268        assert!(serde_json::from_str::<ReportResultScope>("\"partial\"").is_err());
269    }
270}