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