Skip to main content

ic_testkit/pic/
diagnostics.rs

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