Skip to main content

ic_testkit/pic/
diagnostics.rs

1use std::{
2    collections::HashMap,
3    fmt,
4    time::{Duration, Instant},
5};
6
7use candid::Principal;
8use pocket_ic::{CanisterLogRecord, CanisterStatusResult, PocketIc};
9
10use super::{
11    PocketIcCallFailure,
12    transport::{self, capture_call},
13};
14
15/// Default maximum number of canister-log records retained in diagnostics.
16pub const DEFAULT_CANISTER_LOG_RECORD_LIMIT: usize = 32;
17
18/// Default maximum aggregate raw canister-log bytes retained in diagnostics.
19pub const DEFAULT_CANISTER_LOG_BYTE_LIMIT: usize = 16 * 1024;
20
21/// Explicit bounds applied when canister logs are converted to diagnostic text.
22#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23pub struct CanisterLogRenderLimits {
24    record_limit: usize,
25    byte_limit: usize,
26}
27
28impl CanisterLogRenderLimits {
29    /// Set exact record and aggregate raw-content byte limits.
30    ///
31    /// Zero is valid for either limit and retains no content in that dimension.
32    #[must_use]
33    pub const fn new(record_limit: usize, byte_limit: usize) -> Self {
34        Self {
35            record_limit,
36            byte_limit,
37        }
38    }
39
40    /// Maximum number of retained records.
41    #[must_use]
42    pub const fn record_limit(self) -> usize {
43        self.record_limit
44    }
45
46    /// Maximum aggregate number of retained raw content bytes.
47    #[must_use]
48    pub const fn byte_limit(self) -> usize {
49        self.byte_limit
50    }
51}
52
53impl Default for CanisterLogRenderLimits {
54    fn default() -> Self {
55        Self::new(
56            DEFAULT_CANISTER_LOG_RECORD_LIMIT,
57            DEFAULT_CANISTER_LOG_BYTE_LIMIT,
58        )
59    }
60}
61
62/// Exact controller-aware inputs for one best-effort diagnostic collection.
63#[derive(Clone, Copy, Debug, Eq, PartialEq)]
64pub struct CanisterDiagnosticsRequest {
65    canister_id: Principal,
66    status_sender: Principal,
67    log_sender: Principal,
68    log_limits: CanisterLogRenderLimits,
69}
70
71impl CanisterDiagnosticsRequest {
72    /// Create a request with independent, exact status and log senders.
73    ///
74    /// Anonymous access remains available only by explicitly supplying
75    /// [`Principal::anonymous`] for the corresponding operation.
76    #[must_use]
77    pub fn new(canister_id: Principal, status_sender: Principal, log_sender: Principal) -> Self {
78        Self {
79            canister_id,
80            status_sender,
81            log_sender,
82            log_limits: CanisterLogRenderLimits::default(),
83        }
84    }
85
86    /// Override the bounds used to retain and render fetched log content.
87    #[must_use]
88    pub const fn with_log_limits(mut self, limits: CanisterLogRenderLimits) -> Self {
89        self.log_limits = limits;
90        self
91    }
92
93    /// Target canister.
94    #[must_use]
95    pub const fn canister_id(self) -> Principal {
96        self.canister_id
97    }
98
99    /// Exact sender supplied to `canister_status`.
100    #[must_use]
101    pub const fn status_sender(self) -> Principal {
102        self.status_sender
103    }
104
105    /// Exact sender supplied to `fetch_canister_logs`.
106    #[must_use]
107    pub const fn log_sender(self) -> Principal {
108        self.log_sender
109    }
110
111    /// Log rendering bounds.
112    #[must_use]
113    pub const fn log_limits(self) -> CanisterLogRenderLimits {
114        self.log_limits
115    }
116}
117
118/// One caller-labeled exact diagnostic request in a collect-all batch.
119#[derive(Clone, Debug, Eq, PartialEq)]
120pub struct LabeledCanisterDiagnosticsRequest {
121    label: String,
122    request: CanisterDiagnosticsRequest,
123}
124
125/// Structural error that prevents a diagnostics batch from starting.
126#[non_exhaustive]
127#[derive(Clone, Debug, Eq, PartialEq)]
128pub enum CanisterDiagnosticsBatchContractError {
129    /// An entry label was empty.
130    EmptyLabel {
131        /// Zero-based position of the invalid entry.
132        index: usize,
133    },
134    /// Two entries used the same label.
135    DuplicateLabel {
136        /// Duplicated caller label.
137        label: String,
138        /// Position where the label first appeared.
139        first_index: usize,
140        /// Position where the label was repeated.
141        duplicate_index: usize,
142    },
143}
144
145impl LabeledCanisterDiagnosticsRequest {
146    /// Attach a stable caller-facing label to an exact diagnostic request.
147    #[must_use]
148    pub fn new(label: impl Into<String>, request: CanisterDiagnosticsRequest) -> Self {
149        Self {
150            label: label.into(),
151            request,
152        }
153    }
154
155    /// Caller-supplied label retained in the batch report.
156    #[must_use]
157    pub fn label(&self) -> &str {
158        &self.label
159    }
160
161    /// Exact controller-aware request.
162    #[must_use]
163    pub const fn request(&self) -> CanisterDiagnosticsRequest {
164        self.request
165    }
166
167    /// Consume the labeled request into its caller label and exact request.
168    #[must_use]
169    pub fn into_parts(self) -> (String, CanisterDiagnosticsRequest) {
170        (self.label, self.request)
171    }
172}
173
174/// One canister-log record retained as bounded lossy UTF-8 text.
175#[derive(Clone, Debug, Eq, PartialEq)]
176pub struct CanisterDiagnosticLogRecord {
177    index: u64,
178    timestamp_nanos: u64,
179    content: String,
180    original_content_bytes: usize,
181    omitted_content_bytes: usize,
182}
183
184impl CanisterDiagnosticLogRecord {
185    /// Upstream record index.
186    #[must_use]
187    pub const fn index(&self) -> u64 {
188        self.index
189    }
190
191    /// Upstream record timestamp in nanoseconds.
192    #[must_use]
193    pub const fn timestamp_nanos(&self) -> u64 {
194        self.timestamp_nanos
195    }
196
197    /// Retained content converted with lossy UTF-8 decoding.
198    #[must_use]
199    pub fn content(&self) -> &str {
200        &self.content
201    }
202
203    /// Original raw content length before bounding and conversion.
204    #[must_use]
205    pub const fn original_content_bytes(&self) -> usize {
206        self.original_content_bytes
207    }
208
209    /// Raw content bytes omitted from this retained record.
210    #[must_use]
211    pub const fn omitted_content_bytes(&self) -> usize {
212        self.omitted_content_bytes
213    }
214
215    /// Whether this record's content was truncated.
216    #[must_use]
217    pub const fn was_truncated(&self) -> bool {
218        self.omitted_content_bytes != 0
219    }
220}
221
222/// Successfully fetched canister logs after bounded text conversion.
223#[derive(Clone, Debug, Eq, PartialEq)]
224pub struct CanisterDiagnosticLogs {
225    records: Vec<CanisterDiagnosticLogRecord>,
226    total_records: usize,
227    total_content_bytes: usize,
228    omitted_content_bytes: usize,
229}
230
231impl CanisterDiagnosticLogs {
232    /// Retained records in upstream order.
233    #[must_use]
234    pub fn records(&self) -> &[CanisterDiagnosticLogRecord] {
235        &self.records
236    }
237
238    /// Total number of records returned by PocketIC before bounding.
239    #[must_use]
240    pub const fn total_records(&self) -> usize {
241        self.total_records
242    }
243
244    /// Total raw content bytes returned by PocketIC before bounding.
245    #[must_use]
246    pub const fn total_content_bytes(&self) -> usize {
247        self.total_content_bytes
248    }
249
250    /// Number of whole records omitted by the configured bounds.
251    #[must_use]
252    pub const fn omitted_records(&self) -> usize {
253        self.total_records - self.records.len()
254    }
255
256    /// Aggregate raw content bytes omitted across retained and omitted records.
257    #[must_use]
258    pub const fn omitted_content_bytes(&self) -> usize {
259        self.omitted_content_bytes
260    }
261
262    /// Whether either whole records or record content were truncated.
263    #[must_use]
264    pub const fn was_truncated(&self) -> bool {
265        self.omitted_records() != 0 || self.omitted_content_bytes != 0
266    }
267}
268
269impl fmt::Display for CanisterDiagnosticLogs {
270    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
271        if self.records.is_empty() {
272            if self.total_records == 0 {
273                formatter.write_str("<empty>")?;
274            } else {
275                formatter.write_str("<no retained records>")?;
276            }
277        } else {
278            for (position, record) in self.records.iter().enumerate() {
279                if position != 0 {
280                    formatter.write_str(", ")?;
281                }
282                write!(
283                    formatter,
284                    "[{}@{}]={:?}",
285                    record.index, record.timestamp_nanos, record.content
286                )?;
287                if record.was_truncated() {
288                    write!(
289                        formatter,
290                        " (truncated {} bytes)",
291                        record.omitted_content_bytes
292                    )?;
293                }
294            }
295        }
296        if self.was_truncated() {
297            write!(
298                formatter,
299                "; truncated omitted_records={} omitted_content_bytes={}",
300                self.omitted_records(),
301                self.omitted_content_bytes
302            )?;
303        }
304        Ok(())
305    }
306}
307
308/// Independent status and log outcomes for one diagnostic collection.
309#[derive(Debug)]
310pub struct CanisterDiagnosticsReport {
311    request: CanisterDiagnosticsRequest,
312    status: Result<CanisterStatusResult, PocketIcCallFailure>,
313    logs: Result<CanisterDiagnosticLogs, PocketIcCallFailure>,
314}
315
316impl CanisterDiagnosticsReport {
317    /// Exact request used for this report.
318    #[must_use]
319    pub const fn request(&self) -> CanisterDiagnosticsRequest {
320        self.request
321    }
322
323    /// Status result, independent of log retrieval.
324    pub const fn status(&self) -> Result<&CanisterStatusResult, &PocketIcCallFailure> {
325        self.status.as_ref()
326    }
327
328    /// Log result, independent of status retrieval.
329    pub const fn logs(&self) -> Result<&CanisterDiagnosticLogs, &PocketIcCallFailure> {
330        self.logs.as_ref()
331    }
332
333    /// Whether both status and log collection succeeded.
334    #[must_use]
335    pub const fn is_success(&self) -> bool {
336        self.status.is_ok() && self.logs.is_ok()
337    }
338
339    /// Consume the report into its exact request and independent outcomes.
340    pub fn into_parts(
341        self,
342    ) -> (
343        CanisterDiagnosticsRequest,
344        Result<CanisterStatusResult, PocketIcCallFailure>,
345        Result<CanisterDiagnosticLogs, PocketIcCallFailure>,
346    ) {
347        (self.request, self.status, self.logs)
348    }
349}
350
351/// One ordered labeled entry in a collect-all diagnostics batch.
352#[derive(Debug)]
353pub struct CanisterDiagnosticsBatchEntry {
354    label: String,
355    report: CanisterDiagnosticsReport,
356    entry_elapsed: Duration,
357}
358
359impl CanisterDiagnosticsBatchEntry {
360    /// Caller-supplied label.
361    #[must_use]
362    pub fn label(&self) -> &str {
363        &self.label
364    }
365
366    /// Structured status and log outcomes for this entry.
367    #[must_use]
368    pub const fn report(&self) -> &CanisterDiagnosticsReport {
369        &self.report
370    }
371
372    /// Whether both status and log collection succeeded for this entry.
373    #[must_use]
374    pub const fn is_success(&self) -> bool {
375        self.report.is_success()
376    }
377
378    /// Complete wall-clock time for this target's diagnostic collection.
379    #[must_use]
380    pub const fn entry_elapsed(&self) -> Duration {
381        self.entry_elapsed
382    }
383
384    /// Consume the entry into its label, structured report, and wall time.
385    #[must_use]
386    pub fn into_parts(self) -> (String, CanisterDiagnosticsReport, Duration) {
387        (self.label, self.report, self.entry_elapsed)
388    }
389}
390
391/// Ordered entries from a sequential collect-all diagnostics batch.
392#[derive(Debug, Default)]
393pub struct CanisterDiagnosticsBatchReport {
394    entries: Vec<CanisterDiagnosticsBatchEntry>,
395    total: Duration,
396}
397
398impl CanisterDiagnosticsBatchReport {
399    /// Entries in the supplied request order.
400    #[must_use]
401    pub fn entries(&self) -> &[CanisterDiagnosticsBatchEntry] {
402        &self.entries
403    }
404
405    /// Entries with at least one failed status or log operation.
406    pub fn failures(&self) -> impl Iterator<Item = &CanisterDiagnosticsBatchEntry> {
407        self.entries.iter().filter(|entry| !entry.is_success())
408    }
409
410    /// Whether every entry collected both status and logs successfully.
411    #[must_use]
412    pub fn is_success(&self) -> bool {
413        self.entries
414            .iter()
415            .all(CanisterDiagnosticsBatchEntry::is_success)
416    }
417
418    /// Complete wall-clock time for the sequential collect-all batch.
419    #[must_use]
420    pub const fn total(&self) -> Duration {
421        self.total
422    }
423
424    /// Consume the report into its ordered entries.
425    #[must_use]
426    pub fn into_entries(self) -> Vec<CanisterDiagnosticsBatchEntry> {
427        self.entries
428    }
429}
430
431impl fmt::Display for CanisterDiagnosticsBatchReport {
432    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
433        write!(
434            formatter,
435            "diagnostics={} total={:?}",
436            self.entries.len(),
437            self.total
438        )?;
439        for entry in &self.entries {
440            write!(
441                formatter,
442                "; label={:?} elapsed={:?} {}",
443                entry.label, entry.entry_elapsed, entry.report
444            )?;
445        }
446        Ok(())
447    }
448}
449
450impl fmt::Display for CanisterDiagnosticsReport {
451    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
452        write!(
453            formatter,
454            "canister={} status_sender={} status=",
455            self.request.canister_id, self.request.status_sender
456        )?;
457        match &self.status {
458            Ok(status) => write!(
459                formatter,
460                "ok(state={:?} version={} controllers={} module_hash_bytes={} memory_bytes={} cycles={})",
461                status.status,
462                status.version,
463                status.settings.controllers.len(),
464                status.module_hash.as_ref().map_or(0, Vec::len),
465                status.memory_size,
466                status.cycles,
467            ),
468            Err(failure) => write!(formatter, "<{failure}>"),
469        }?;
470        write!(formatter, " log_sender={} logs=", self.request.log_sender)?;
471        match &self.logs {
472            Err(failure) => write!(formatter, "<{failure}>")?,
473            Ok(logs) => write!(formatter, "{logs}")?,
474        }
475        Ok(())
476    }
477}
478
479/// Reusable structured PocketIC failure diagnostics.
480pub trait PocketIcDiagnosticsExt {
481    /// Collect status and bounded logs using the request's exact senders.
482    ///
483    /// Both calls are attempted independently. PocketIC transport panics are
484    /// captured so diagnostics can remain subordinate to the original failure.
485    fn collect_canister_diagnostics(
486        &self,
487        request: CanisterDiagnosticsRequest,
488    ) -> CanisterDiagnosticsReport;
489
490    /// Collect every labeled request sequentially in its supplied order.
491    ///
492    /// Every target is attempted even when an earlier entry fails or panics.
493    /// Each entry preserves its exact request and independent status/log
494    /// outcomes; no anonymous retry or fallback is performed.
495    fn collect_canister_diagnostics_batch(
496        &self,
497        requests: &[LabeledCanisterDiagnosticsRequest],
498    ) -> Result<CanisterDiagnosticsBatchReport, CanisterDiagnosticsBatchContractError> {
499        validate_diagnostics_batch_labels(requests)?;
500        let started = Instant::now();
501        let entries = requests
502            .iter()
503            .map(|labeled| {
504                let entry_started = Instant::now();
505                let request = labeled.request;
506                let report =
507                    transport::capture_operation(|| self.collect_canister_diagnostics(request))
508                        .unwrap_or_else(|source| CanisterDiagnosticsReport {
509                            request,
510                            status: Err(PocketIcCallFailure::Operation(source.clone())),
511                            logs: Err(PocketIcCallFailure::Operation(source)),
512                        });
513                CanisterDiagnosticsBatchEntry {
514                    label: labeled.label.clone(),
515                    report,
516                    entry_elapsed: entry_started.elapsed(),
517                }
518            })
519            .collect();
520        Ok(CanisterDiagnosticsBatchReport {
521            entries,
522            total: started.elapsed(),
523        })
524    }
525}
526
527fn validate_diagnostics_batch_labels(
528    requests: &[LabeledCanisterDiagnosticsRequest],
529) -> Result<(), CanisterDiagnosticsBatchContractError> {
530    let mut seen = HashMap::new();
531    for (index, request) in requests.iter().enumerate() {
532        let label = request.label.as_str();
533        if label.is_empty() {
534            return Err(CanisterDiagnosticsBatchContractError::EmptyLabel { index });
535        }
536        if let Some(first_index) = seen.insert(label, index) {
537            return Err(CanisterDiagnosticsBatchContractError::DuplicateLabel {
538                label: label.to_owned(),
539                first_index,
540                duplicate_index: index,
541            });
542        }
543    }
544    Ok(())
545}
546
547impl fmt::Display for CanisterDiagnosticsBatchContractError {
548    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
549        match self {
550            Self::EmptyLabel { index } => {
551                write!(
552                    formatter,
553                    "diagnostics batch label at index {index} is empty"
554                )
555            }
556            Self::DuplicateLabel {
557                label,
558                first_index,
559                duplicate_index,
560            } => write!(
561                formatter,
562                "diagnostics batch label {label:?} at index {duplicate_index} duplicates index {first_index}",
563            ),
564        }
565    }
566}
567
568impl std::error::Error for CanisterDiagnosticsBatchContractError {}
569
570impl PocketIcDiagnosticsExt for PocketIc {
571    fn collect_canister_diagnostics(
572        &self,
573        request: CanisterDiagnosticsRequest,
574    ) -> CanisterDiagnosticsReport {
575        let status =
576            capture_call(|| self.canister_status(request.canister_id, Some(request.status_sender)));
577        let logs =
578            capture_call(|| self.fetch_canister_logs(request.canister_id, request.log_sender))
579                .map(|records| render_log_records(records, request.log_limits));
580
581        CanisterDiagnosticsReport {
582            request,
583            status,
584            logs,
585        }
586    }
587}
588
589fn render_log_records(
590    records: Vec<CanisterLogRecord>,
591    limits: CanisterLogRenderLimits,
592) -> CanisterDiagnosticLogs {
593    let total_records = records.len();
594    let mut total_content_bytes = 0usize;
595    let mut rendered = Vec::with_capacity(total_records.min(limits.record_limit));
596    let mut retained_bytes = 0usize;
597    let mut omitted_content_bytes = 0usize;
598
599    for record in records {
600        total_content_bytes = total_content_bytes.saturating_add(record.content.len());
601        if rendered.len() == limits.record_limit || retained_bytes == limits.byte_limit {
602            omitted_content_bytes = omitted_content_bytes.saturating_add(record.content.len());
603            continue;
604        }
605
606        let available = limits.byte_limit.saturating_sub(retained_bytes);
607        let retained = record.content.len().min(available);
608        let omitted = record.content.len().saturating_sub(retained);
609        let content = String::from_utf8_lossy(&record.content[..retained]).into_owned();
610        retained_bytes = retained_bytes.saturating_add(retained);
611        omitted_content_bytes = omitted_content_bytes.saturating_add(omitted);
612        rendered.push(CanisterDiagnosticLogRecord {
613            index: record.idx,
614            timestamp_nanos: record.timestamp_nanos,
615            content,
616            original_content_bytes: record.content.len(),
617            omitted_content_bytes: omitted,
618        });
619    }
620
621    CanisterDiagnosticLogs {
622        records: rendered,
623        total_records,
624        total_content_bytes,
625        omitted_content_bytes,
626    }
627}
628
629#[cfg(test)]
630mod tests {
631    use std::cell::Cell;
632
633    use candid::Principal;
634    use pocket_ic::CanisterLogRecord;
635
636    use super::{
637        CanisterDiagnosticsBatchContractError, CanisterDiagnosticsReport,
638        CanisterDiagnosticsRequest, CanisterLogRenderLimits, LabeledCanisterDiagnosticsRequest,
639        PocketIcCallFailure, PocketIcDiagnosticsExt, capture_call, render_log_records,
640    };
641
642    #[test]
643    fn diagnostic_transport_classification_survives_context_and_io_wrapping() {
644        const REFUSED: &str = "HTTP failure: reqwest::Error { kind: Request, url: \"http://127.0.0.1:1234/instances/0/read/get_time\", source: ConnectError(\"tcp connect error\", Os { code: 111, kind: ConnectionRefused, message: \"Connection refused\" }) }";
645        const RESET: &str = "HTTP failure: reqwest::Error { kind: Request, url: \"http://127.0.0.1:49238/instances/0/read/get_time\", source: hyper_util::client::legacy::Error(SendRequest, hyper::Error(Io, Os { code: 54, kind: ConnectionReset, message: \"Connection reset by peer\" })) }";
646        for message in [REFUSED, RESET, "application worker channel closed"] {
647            let error = capture_call::<()>(|| panic!("{message}"))
648                .expect_err("diagnostic operation should capture the panic");
649            let expected = message != "application worker channel closed";
650            let PocketIcCallFailure::Operation(source) = &error else {
651                panic!("expected the original operation error");
652            };
653            assert_eq!(source.message(), message);
654            assert_eq!(source.is_transport(), expected);
655            let chained = std::error::Error::source(&error)
656                .and_then(|cause| cause.downcast_ref::<super::super::PocketIcOperationError>())
657                .expect("diagnostic error exposes its original typed cause");
658            assert_eq!(chained, source);
659            assert_eq!(
660                super::super::is_dead_pocket_ic_transport_error(&error),
661                expected,
662            );
663            let wrapped = std::io::Error::other(error);
664            assert_eq!(
665                super::super::is_dead_pocket_ic_transport_error(&wrapped),
666                expected,
667            );
668        }
669    }
670
671    struct PanickingThenReporting {
672        calls: Cell<usize>,
673    }
674
675    impl PocketIcDiagnosticsExt for PanickingThenReporting {
676        fn collect_canister_diagnostics(
677            &self,
678            request: CanisterDiagnosticsRequest,
679        ) -> CanisterDiagnosticsReport {
680            let call = self.calls.get();
681            self.calls.set(call + 1);
682            assert_ne!(call, 0, "synthetic first-entry diagnostic panic");
683            CanisterDiagnosticsReport {
684                request,
685                status: Err(PocketIcCallFailure::Operation(
686                    super::super::PocketIcOperationError::new("synthetic status failure"),
687                )),
688                logs: Err(PocketIcCallFailure::Operation(
689                    super::super::PocketIcOperationError::new("synthetic log failure"),
690                )),
691            }
692        }
693    }
694
695    #[test]
696    fn labeled_batch_retains_order_and_continues_after_entry_panic() {
697        let collector = PanickingThenReporting {
698            calls: Cell::new(0),
699        };
700        let first = CanisterDiagnosticsRequest::new(
701            Principal::from_slice(&[1]),
702            Principal::from_slice(&[2]),
703            Principal::from_slice(&[3]),
704        );
705        let second = CanisterDiagnosticsRequest::new(
706            Principal::from_slice(&[4]),
707            Principal::from_slice(&[5]),
708            Principal::from_slice(&[6]),
709        );
710        let report = collector
711            .collect_canister_diagnostics_batch(&[
712                LabeledCanisterDiagnosticsRequest::new("root", first),
713                LabeledCanisterDiagnosticsRequest::new("worker", second),
714            ])
715            .expect("valid labeled diagnostics batch");
716
717        assert_eq!(collector.calls.get(), 2);
718        assert_eq!(report.entries().len(), 2);
719        assert_eq!(report.entries()[0].label(), "root");
720        assert_eq!(report.entries()[0].report().request(), first);
721        assert_eq!(report.entries()[1].label(), "worker");
722        assert_eq!(report.entries()[1].report().request(), second);
723        assert!(
724            report
725                .entries()
726                .iter()
727                .all(|entry| entry.entry_elapsed() <= report.total())
728        );
729        assert_eq!(report.failures().count(), 2);
730        assert!(!report.is_success());
731        let compact = report.to_string();
732        assert!(compact.contains("label=\"root\""));
733        assert!(compact.contains("label=\"worker\""));
734        assert!(compact.contains("synthetic first-entry diagnostic panic"));
735    }
736
737    #[test]
738    fn diagnostic_batch_rejects_invalid_labels_before_collection() {
739        let collector = PanickingThenReporting {
740            calls: Cell::new(0),
741        };
742        let request = CanisterDiagnosticsRequest::new(
743            Principal::from_slice(&[1]),
744            Principal::from_slice(&[2]),
745            Principal::from_slice(&[3]),
746        );
747        let empty = collector
748            .collect_canister_diagnostics_batch(&[LabeledCanisterDiagnosticsRequest::new(
749                "", request,
750            )])
751            .expect_err("empty label must reject diagnostics batch");
752        assert_eq!(
753            empty,
754            CanisterDiagnosticsBatchContractError::EmptyLabel { index: 0 }
755        );
756        assert_eq!(collector.calls.get(), 0);
757
758        let duplicate = collector
759            .collect_canister_diagnostics_batch(&[
760                LabeledCanisterDiagnosticsRequest::new("same", request),
761                LabeledCanisterDiagnosticsRequest::new("same", request),
762            ])
763            .expect_err("duplicate labels must reject diagnostics batch");
764        assert_eq!(
765            duplicate,
766            CanisterDiagnosticsBatchContractError::DuplicateLabel {
767                label: "same".to_owned(),
768                first_index: 0,
769                duplicate_index: 1,
770            }
771        );
772        assert_eq!(collector.calls.get(), 0);
773    }
774
775    #[test]
776    fn log_rendering_is_bounded_lossy_utf8_and_reports_truncation() {
777        let logs = render_log_records(
778            vec![
779                CanisterLogRecord {
780                    idx: 7,
781                    timestamp_nanos: 11,
782                    content: vec![b'f', 0x80, b'o'],
783                },
784                CanisterLogRecord {
785                    idx: 8,
786                    timestamp_nanos: 12,
787                    content: b"bar".to_vec(),
788                },
789            ],
790            CanisterLogRenderLimits::new(1, 2),
791        );
792
793        assert_eq!(logs.total_records(), 2);
794        assert_eq!(logs.total_content_bytes(), 6);
795        assert_eq!(logs.omitted_records(), 1);
796        assert_eq!(logs.omitted_content_bytes(), 4);
797        assert!(logs.was_truncated());
798        assert_eq!(logs.records().len(), 1);
799        assert_eq!(logs.records()[0].content(), "f�");
800        assert_eq!(logs.records()[0].original_content_bytes(), 3);
801        assert_eq!(logs.records()[0].omitted_content_bytes(), 1);
802        assert!(logs.records()[0].was_truncated());
803        let rendered = logs.to_string();
804        assert!(rendered.contains("f�"));
805        assert!(rendered.contains("truncated omitted_records=1 omitted_content_bytes=4"));
806    }
807
808    #[test]
809    fn zero_log_bounds_retain_only_aggregate_truncation() {
810        for (record_limit, byte_limit) in [(0, 0), (0, 16), (16, 0)] {
811            let logs = render_log_records(
812                vec![CanisterLogRecord {
813                    idx: 1,
814                    timestamp_nanos: 2,
815                    content: b"hello".to_vec(),
816                }],
817                CanisterLogRenderLimits::new(record_limit, byte_limit),
818            );
819
820            assert_eq!(logs.records(), []);
821            assert_eq!(logs.total_records(), 1);
822            assert_eq!(logs.total_content_bytes(), 5);
823            assert_eq!(logs.omitted_records(), 1);
824            assert_eq!(logs.omitted_content_bytes(), 5);
825            assert!(logs.was_truncated());
826            assert_eq!(
827                logs.to_string(),
828                "<no retained records>; truncated omitted_records=1 omitted_content_bytes=5"
829            );
830        }
831    }
832
833    #[test]
834    fn logs_within_bounds_preserve_empty_records_and_raw_byte_totals() {
835        let limits = CanisterLogRenderLimits::new(3, 3);
836        let empty = render_log_records(vec![], limits);
837        assert_eq!(empty.total_records(), 0);
838        assert_eq!(empty.total_content_bytes(), 0);
839        assert_eq!(empty.omitted_records(), 0);
840        assert_eq!(empty.omitted_content_bytes(), 0);
841        assert!(!empty.was_truncated());
842        assert_eq!(empty.to_string(), "<empty>");
843
844        let logs = render_log_records(
845            vec![
846                CanisterLogRecord {
847                    idx: 1,
848                    timestamp_nanos: 2,
849                    content: vec![],
850                },
851                CanisterLogRecord {
852                    idx: 3,
853                    timestamp_nanos: 4,
854                    content: vec![0x80, b'a'],
855                },
856                CanisterLogRecord {
857                    idx: 5,
858                    timestamp_nanos: 6,
859                    content: vec![],
860                },
861            ],
862            limits,
863        );
864        assert_eq!(logs.total_records(), 3);
865        assert_eq!(logs.records().len(), 3);
866        assert_eq!(logs.total_content_bytes(), 2);
867        assert_eq!(logs.records()[0].content(), "");
868        assert_eq!(logs.records()[1].content(), "�a");
869        assert_eq!(logs.records()[2].content(), "");
870        assert_eq!(logs.omitted_records(), 0);
871        assert_eq!(logs.omitted_content_bytes(), 0);
872        assert!(!logs.was_truncated());
873    }
874}