Skip to main content

ic_testkit/pic/
diagnostics.rs

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