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)]
120#[serde(tag = "data_source", rename_all = "snake_case", deny_unknown_fields)]
121pub enum ReportProvenance {
122    /// Rows collected directly from their source.
123    Live,
124    /// Rows read from one admitted complete snapshot.
125    Cache {
126        /// Complete snapshot path.
127        cache_path: String,
128    },
129}
130
131impl ReportProvenance {
132    /// Build provenance for live reports.
133    pub(crate) const fn live() -> Self {
134        Self::Live
135    }
136
137    /// Build provenance for complete-cache reports.
138    #[cfg(any(feature = "nns-host", feature = "sns-host"))]
139    pub(crate) fn cache(cache_path: &Path) -> Self {
140        Self::Cache {
141            cache_path: cache_path.display().to_string(),
142        }
143    }
144
145    /// Return the origin of these rows.
146    #[must_use]
147    pub const fn data_source(&self) -> ReportDataSource {
148        match self {
149            Self::Live => ReportDataSource::Live,
150            Self::Cache { .. } => ReportDataSource::Cache,
151        }
152    }
153
154    /// Return the complete snapshot path for cached rows.
155    #[must_use]
156    pub fn cache_path(&self) -> Option<&str> {
157        match self {
158            Self::Live => None,
159            Self::Cache { cache_path } => Some(cache_path),
160        }
161    }
162
163    /// Return API-exhaustion evidence for cached rows.
164    #[must_use]
165    pub const fn cache_complete(&self) -> Option<bool> {
166        match self {
167            Self::Live => None,
168            Self::Cache { .. } => Some(true),
169        }
170    }
171}
172
173///
174/// ReportDataSource
175///
176/// Origin of the data exposed by a report.
177///
178
179#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
180#[serde(rename_all = "snake_case")]
181pub enum ReportDataSource {
182    /// Rows were collected from live IC query calls.
183    Live,
184    /// Rows were read from a complete local snapshot.
185    Cache,
186}
187
188impl ReportDataSource {
189    /// Return the stable JSON and text label.
190    #[must_use]
191    pub const fn as_str(self) -> &'static str {
192        match self {
193            Self::Live => "live",
194            Self::Cache => "cache",
195        }
196    }
197}
198
199impl fmt::Display for ReportDataSource {
200    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
201        formatter.write_str(self.as_str())
202    }
203}
204
205///
206/// ReportResultScope
207///
208/// Completeness boundary represented by a report view.
209///
210
211#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
212#[serde(rename_all = "kebab-case")]
213pub enum ReportResultScope {
214    /// A bounded page or detail view collected live.
215    BoundedLive,
216    /// A view derived from an API-exhausted complete cache.
217    CompleteCache,
218}
219
220impl ReportResultScope {
221    /// Return the stable JSON and text label.
222    #[must_use]
223    pub const fn as_str(self) -> &'static str {
224        match self {
225            Self::BoundedLive => "bounded-live",
226            Self::CompleteCache => "complete-cache",
227        }
228    }
229}
230
231impl fmt::Display for ReportResultScope {
232    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
233        formatter.write_str(self.as_str())
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::{ReportDataSource, ReportResultScope};
240
241    #[cfg(any(feature = "cloud-engine-host", feature = "dashboard-host"))]
242    #[test]
243    fn source_provenance_comparison_preserves_field_order() {
244        let expected = ("endpoint", "time", "collector");
245        assert_eq!(super::source_provenance_mismatch(expected, expected), None);
246        for (actual, field) in [
247            (("other", "other", "other"), "source_endpoint"),
248            (("endpoint", "other", "other"), "fetched_at"),
249            (("endpoint", "time", "other"), "fetched_by"),
250        ] {
251            let reason = super::source_provenance_mismatch(expected, actual).unwrap();
252            assert!(reason.starts_with(&format!("{field} is ")));
253        }
254    }
255
256    #[test]
257    fn report_origins_round_trip_and_require_a_cache_path() {
258        for provenance in [
259            super::ReportProvenance::Live,
260            super::ReportProvenance::Cache {
261                cache_path: "/cache/snapshot.json".to_string(),
262            },
263        ] {
264            let value = serde_json::to_value(&provenance).unwrap();
265            assert_eq!(
266                serde_json::from_value::<super::ReportProvenance>(value).unwrap(),
267                provenance
268            );
269        }
270        assert!(
271            serde_json::from_value::<super::ReportProvenance>(serde_json::json!({
272                "data_source": "cache"
273            }))
274            .is_err()
275        );
276    }
277
278    #[test]
279    fn report_provenance_labels_round_trip() {
280        for (source, label) in [
281            (ReportDataSource::Live, "live"),
282            (ReportDataSource::Cache, "cache"),
283        ] {
284            assert_eq!(source.as_str(), label);
285            assert_eq!(source.to_string(), label);
286            assert_eq!(
287                serde_json::to_string(&source).expect("serialize report data source"),
288                format!("\"{label}\"")
289            );
290            assert_eq!(
291                serde_json::from_str::<ReportDataSource>(&format!("\"{label}\""))
292                    .expect("deserialize report data source"),
293                source
294            );
295        }
296        for (scope, label) in [
297            (ReportResultScope::BoundedLive, "bounded-live"),
298            (ReportResultScope::CompleteCache, "complete-cache"),
299        ] {
300            assert_eq!(scope.as_str(), label);
301            assert_eq!(scope.to_string(), label);
302            assert_eq!(
303                serde_json::to_string(&scope).expect("serialize report result scope"),
304                format!("\"{label}\"")
305            );
306            assert_eq!(
307                serde_json::from_str::<ReportResultScope>(&format!("\"{label}\""))
308                    .expect("deserialize report result scope"),
309                scope
310            );
311        }
312        assert!(serde_json::from_str::<ReportDataSource>("\"api\"").is_err());
313        assert!(serde_json::from_str::<ReportResultScope>("\"partial\"").is_err());
314    }
315}