1use crate::batch::{BatchLabelError, validate_labels};
2
3use std::{
4 fmt,
5 panic::{AssertUnwindSafe, catch_unwind},
6 time::{Duration, Instant},
7};
8
9use candid::Principal;
10use pocket_ic::{CanisterLogRecord, CanisterStatusResult, PocketIc, RejectResponse};
11
12use super::transport;
13
14pub const DEFAULT_CANISTER_LOG_RECORD_LIMIT: usize = 32;
16
17pub const DEFAULT_CANISTER_LOG_BYTE_LIMIT: usize = 16 * 1024;
19
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22pub struct CanisterLogRenderLimits {
23 record_limit: usize,
24 byte_limit: usize,
25}
26
27impl CanisterLogRenderLimits {
28 #[must_use]
32 pub const fn new(record_limit: usize, byte_limit: usize) -> Self {
33 Self {
34 record_limit,
35 byte_limit,
36 }
37 }
38
39 #[must_use]
41 pub const fn record_limit(self) -> usize {
42 self.record_limit
43 }
44
45 #[must_use]
47 pub const fn byte_limit(self) -> usize {
48 self.byte_limit
49 }
50}
51
52impl Default for CanisterLogRenderLimits {
53 fn default() -> Self {
54 Self::new(
55 DEFAULT_CANISTER_LOG_RECORD_LIMIT,
56 DEFAULT_CANISTER_LOG_BYTE_LIMIT,
57 )
58 }
59}
60
61#[derive(Clone, Copy, Debug, Eq, PartialEq)]
63pub struct CanisterDiagnosticsRequest {
64 canister_id: Principal,
65 status_sender: Principal,
66 log_sender: Principal,
67 log_limits: CanisterLogRenderLimits,
68}
69
70impl CanisterDiagnosticsRequest {
71 #[must_use]
76 pub fn new(canister_id: Principal, status_sender: Principal, log_sender: Principal) -> Self {
77 Self {
78 canister_id,
79 status_sender,
80 log_sender,
81 log_limits: CanisterLogRenderLimits::default(),
82 }
83 }
84
85 #[must_use]
87 pub const fn with_log_limits(mut self, limits: CanisterLogRenderLimits) -> Self {
88 self.log_limits = limits;
89 self
90 }
91
92 #[must_use]
94 pub const fn canister_id(self) -> Principal {
95 self.canister_id
96 }
97
98 #[must_use]
100 pub const fn status_sender(self) -> Principal {
101 self.status_sender
102 }
103
104 #[must_use]
106 pub const fn log_sender(self) -> Principal {
107 self.log_sender
108 }
109
110 #[must_use]
112 pub const fn log_limits(self) -> CanisterLogRenderLimits {
113 self.log_limits
114 }
115}
116
117#[derive(Clone, Debug, Eq, PartialEq)]
119pub struct LabeledCanisterDiagnosticsRequest {
120 label: String,
121 request: CanisterDiagnosticsRequest,
122}
123
124#[non_exhaustive]
126#[derive(Clone, Debug, Eq, PartialEq)]
127pub enum CanisterDiagnosticsBatchContractError {
128 EmptyLabel {
130 index: usize,
132 },
133 DuplicateLabel {
135 label: String,
137 first_index: usize,
139 duplicate_index: usize,
141 },
142}
143
144impl LabeledCanisterDiagnosticsRequest {
145 #[must_use]
147 pub fn new(label: impl Into<String>, request: CanisterDiagnosticsRequest) -> Self {
148 Self {
149 label: label.into(),
150 request,
151 }
152 }
153
154 #[must_use]
156 pub fn label(&self) -> &str {
157 &self.label
158 }
159
160 #[must_use]
162 pub const fn request(&self) -> CanisterDiagnosticsRequest {
163 self.request
164 }
165
166 #[must_use]
168 pub fn into_parts(self) -> (String, CanisterDiagnosticsRequest) {
169 (self.label, self.request)
170 }
171}
172
173#[non_exhaustive]
175#[derive(Debug)]
176pub enum CanisterDiagnosticFailure {
177 Rejected(RejectResponse),
179 InstanceUnavailable {
181 message: String,
183 },
184 Panicked {
186 message: String,
188 },
189}
190
191impl fmt::Display for CanisterDiagnosticFailure {
192 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
193 match self {
194 Self::Rejected(response) => write!(formatter, "rejected: {response:?}"),
195 Self::InstanceUnavailable { message } => {
196 write!(formatter, "PocketIC instance unavailable: {message}")
197 }
198 Self::Panicked { message } => write!(formatter, "panicked: {message}"),
199 }
200 }
201}
202
203impl std::error::Error for CanisterDiagnosticFailure {}
204
205#[derive(Clone, Debug, Eq, PartialEq)]
207pub struct CanisterDiagnosticLogRecord {
208 index: u64,
209 timestamp_nanos: u64,
210 content: String,
211 original_content_bytes: usize,
212 omitted_content_bytes: usize,
213}
214
215impl CanisterDiagnosticLogRecord {
216 #[must_use]
218 pub const fn index(&self) -> u64 {
219 self.index
220 }
221
222 #[must_use]
224 pub const fn timestamp_nanos(&self) -> u64 {
225 self.timestamp_nanos
226 }
227
228 #[must_use]
230 pub fn content(&self) -> &str {
231 &self.content
232 }
233
234 #[must_use]
236 pub const fn original_content_bytes(&self) -> usize {
237 self.original_content_bytes
238 }
239
240 #[must_use]
242 pub const fn omitted_content_bytes(&self) -> usize {
243 self.omitted_content_bytes
244 }
245
246 #[must_use]
248 pub const fn was_truncated(&self) -> bool {
249 self.omitted_content_bytes != 0
250 }
251}
252
253#[derive(Clone, Debug, Eq, PartialEq)]
255pub struct CanisterDiagnosticLogs {
256 records: Vec<CanisterDiagnosticLogRecord>,
257 total_records: usize,
258 total_content_bytes: usize,
259 omitted_content_bytes: usize,
260}
261
262impl CanisterDiagnosticLogs {
263 #[must_use]
265 pub fn records(&self) -> &[CanisterDiagnosticLogRecord] {
266 &self.records
267 }
268
269 #[must_use]
271 pub const fn total_records(&self) -> usize {
272 self.total_records
273 }
274
275 #[must_use]
277 pub const fn total_content_bytes(&self) -> usize {
278 self.total_content_bytes
279 }
280
281 #[must_use]
283 pub const fn omitted_records(&self) -> usize {
284 self.total_records - self.records.len()
285 }
286
287 #[must_use]
289 pub const fn omitted_content_bytes(&self) -> usize {
290 self.omitted_content_bytes
291 }
292
293 #[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(),
332 self.omitted_content_bytes
333 )?;
334 }
335 Ok(())
336 }
337}
338
339#[derive(Debug)]
341pub struct CanisterDiagnosticsReport {
342 request: CanisterDiagnosticsRequest,
343 status: Result<CanisterStatusResult, CanisterDiagnosticFailure>,
344 logs: Result<CanisterDiagnosticLogs, CanisterDiagnosticFailure>,
345}
346
347impl CanisterDiagnosticsReport {
348 #[must_use]
350 pub const fn request(&self) -> CanisterDiagnosticsRequest {
351 self.request
352 }
353
354 pub const fn status(&self) -> Result<&CanisterStatusResult, &CanisterDiagnosticFailure> {
356 self.status.as_ref()
357 }
358
359 pub const fn logs(&self) -> Result<&CanisterDiagnosticLogs, &CanisterDiagnosticFailure> {
361 self.logs.as_ref()
362 }
363
364 #[must_use]
366 pub const fn is_success(&self) -> bool {
367 self.status.is_ok() && self.logs.is_ok()
368 }
369
370 pub fn into_parts(
372 self,
373 ) -> (
374 CanisterDiagnosticsRequest,
375 Result<CanisterStatusResult, CanisterDiagnosticFailure>,
376 Result<CanisterDiagnosticLogs, CanisterDiagnosticFailure>,
377 ) {
378 (self.request, self.status, self.logs)
379 }
380
381 #[must_use]
383 pub fn render_compact(&self) -> String {
384 self.to_string()
385 }
386}
387
388#[derive(Debug)]
390pub struct CanisterDiagnosticsBatchEntry {
391 label: String,
392 report: CanisterDiagnosticsReport,
393 entry_elapsed: Duration,
394}
395
396impl CanisterDiagnosticsBatchEntry {
397 #[must_use]
399 pub fn label(&self) -> &str {
400 &self.label
401 }
402
403 #[must_use]
405 pub const fn report(&self) -> &CanisterDiagnosticsReport {
406 &self.report
407 }
408
409 #[must_use]
411 pub const fn is_success(&self) -> bool {
412 self.report.is_success()
413 }
414
415 #[must_use]
417 pub const fn entry_elapsed(&self) -> Duration {
418 self.entry_elapsed
419 }
420
421 #[must_use]
423 pub fn into_parts(self) -> (String, CanisterDiagnosticsReport, Duration) {
424 (self.label, self.report, self.entry_elapsed)
425 }
426}
427
428#[derive(Debug, Default)]
430pub struct CanisterDiagnosticsBatchReport {
431 entries: Vec<CanisterDiagnosticsBatchEntry>,
432 total: Duration,
433}
434
435impl CanisterDiagnosticsBatchReport {
436 #[must_use]
438 pub fn entries(&self) -> &[CanisterDiagnosticsBatchEntry] {
439 &self.entries
440 }
441
442 pub fn failures(&self) -> impl Iterator<Item = &CanisterDiagnosticsBatchEntry> {
444 self.entries.iter().filter(|entry| !entry.is_success())
445 }
446
447 #[must_use]
449 pub fn is_success(&self) -> bool {
450 self.entries
451 .iter()
452 .all(CanisterDiagnosticsBatchEntry::is_success)
453 }
454
455 #[must_use]
457 pub const fn total(&self) -> Duration {
458 self.total
459 }
460
461 #[must_use]
463 pub fn into_entries(self) -> Vec<CanisterDiagnosticsBatchEntry> {
464 self.entries
465 }
466
467 #[must_use]
469 pub fn render_compact(&self) -> String {
470 self.to_string()
471 }
472}
473
474impl fmt::Display for CanisterDiagnosticsBatchReport {
475 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
476 write!(
477 formatter,
478 "diagnostics={} total={:?}",
479 self.entries.len(),
480 self.total
481 )?;
482 for entry in &self.entries {
483 write!(
484 formatter,
485 "; label={:?} elapsed={:?} {}",
486 entry.label, entry.entry_elapsed, entry.report
487 )?;
488 }
489 Ok(())
490 }
491}
492
493impl fmt::Display for CanisterDiagnosticsReport {
494 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
495 write!(
496 formatter,
497 "canister={} status_sender={} status=",
498 self.request.canister_id, self.request.status_sender
499 )?;
500 match &self.status {
501 Ok(status) => write!(
502 formatter,
503 "ok(state={:?} version={} controllers={} module_hash_bytes={} memory_bytes={} cycles={})",
504 status.status,
505 status.version,
506 status.settings.controllers.len(),
507 status.module_hash.as_ref().map_or(0, Vec::len),
508 status.memory_size,
509 status.cycles,
510 ),
511 Err(failure) => write!(formatter, "<{failure}>"),
512 }?;
513 write!(formatter, " log_sender={} logs=", self.request.log_sender)?;
514 match &self.logs {
515 Err(failure) => write!(formatter, "<{failure}>")?,
516 Ok(logs) => write!(formatter, "{logs}")?,
517 }
518 Ok(())
519 }
520}
521
522pub trait PocketIcDiagnosticsExt {
524 fn collect_canister_diagnostics(
529 &self,
530 request: CanisterDiagnosticsRequest,
531 ) -> CanisterDiagnosticsReport;
532
533 fn collect_canister_diagnostics_batch(
539 &self,
540 requests: &[LabeledCanisterDiagnosticsRequest],
541 ) -> Result<CanisterDiagnosticsBatchReport, CanisterDiagnosticsBatchContractError> {
542 validate_diagnostics_batch_labels(requests)?;
543 let started = Instant::now();
544 let entries = requests
545 .iter()
546 .map(|labeled| {
547 let entry_started = Instant::now();
548 let request = labeled.request;
549 let report = catch_unwind(AssertUnwindSafe(|| {
550 self.collect_canister_diagnostics(request)
551 }))
552 .unwrap_or_else(|payload| {
553 let message = transport::panic_payload_to_string(payload.as_ref());
554 CanisterDiagnosticsReport {
555 request,
556 status: Err(diagnostic_panic_failure(message.clone())),
557 logs: Err(diagnostic_panic_failure(message)),
558 }
559 });
560 CanisterDiagnosticsBatchEntry {
561 label: labeled.label.clone(),
562 report,
563 entry_elapsed: entry_started.elapsed(),
564 }
565 })
566 .collect();
567 Ok(CanisterDiagnosticsBatchReport {
568 entries,
569 total: started.elapsed(),
570 })
571 }
572}
573
574fn validate_diagnostics_batch_labels(
575 requests: &[LabeledCanisterDiagnosticsRequest],
576) -> Result<(), CanisterDiagnosticsBatchContractError> {
577 validate_labels(requests.iter().map(|labeled| labeled.label.as_str())).map_err(|error| {
578 match error {
579 BatchLabelError::Empty { index } => {
580 CanisterDiagnosticsBatchContractError::EmptyLabel { index }
581 }
582 BatchLabelError::Duplicate {
583 label,
584 first_index,
585 duplicate_index,
586 } => CanisterDiagnosticsBatchContractError::DuplicateLabel {
587 label,
588 first_index,
589 duplicate_index,
590 },
591 }
592 })
593}
594
595impl fmt::Display for CanisterDiagnosticsBatchContractError {
596 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
597 match self {
598 Self::EmptyLabel { index } => {
599 write!(
600 formatter,
601 "diagnostics batch label at index {index} is empty"
602 )
603 }
604 Self::DuplicateLabel {
605 label,
606 first_index,
607 duplicate_index,
608 } => write!(
609 formatter,
610 "diagnostics batch label {label:?} at index {duplicate_index} duplicates index {first_index}",
611 ),
612 }
613 }
614}
615
616impl std::error::Error for CanisterDiagnosticsBatchContractError {}
617
618impl PocketIcDiagnosticsExt for PocketIc {
619 fn collect_canister_diagnostics(
620 &self,
621 request: CanisterDiagnosticsRequest,
622 ) -> CanisterDiagnosticsReport {
623 let status = capture_diagnostic_call(|| {
624 self.canister_status(request.canister_id, Some(request.status_sender))
625 });
626 let logs = capture_diagnostic_call(|| {
627 self.fetch_canister_logs(request.canister_id, request.log_sender)
628 })
629 .map(|records| render_log_records(records, request.log_limits));
630
631 CanisterDiagnosticsReport {
632 request,
633 status,
634 logs,
635 }
636 }
637}
638
639fn capture_diagnostic_call<T>(
640 call: impl FnOnce() -> Result<T, RejectResponse>,
641) -> Result<T, CanisterDiagnosticFailure> {
642 match catch_unwind(AssertUnwindSafe(call)) {
643 Ok(Ok(value)) => Ok(value),
644 Ok(Err(response)) => Err(CanisterDiagnosticFailure::Rejected(response)),
645 Err(payload) => {
646 let message = transport::panic_payload_to_string(payload.as_ref());
647 Err(diagnostic_panic_failure(message))
648 }
649 }
650}
651
652fn diagnostic_panic_failure(message: String) -> CanisterDiagnosticFailure {
653 if transport::is_dead_instance_transport_error(&message) {
654 CanisterDiagnosticFailure::InstanceUnavailable { message }
655 } else {
656 CanisterDiagnosticFailure::Panicked { message }
657 }
658}
659
660fn render_log_records(
661 records: Vec<CanisterLogRecord>,
662 limits: CanisterLogRenderLimits,
663) -> CanisterDiagnosticLogs {
664 let total_records = records.len();
665 let mut total_content_bytes = 0usize;
666 let mut rendered = Vec::with_capacity(total_records.min(limits.record_limit));
667 let mut retained_bytes = 0usize;
668 let mut omitted_content_bytes = 0usize;
669
670 for record in records {
671 total_content_bytes = total_content_bytes.saturating_add(record.content.len());
672 if rendered.len() == limits.record_limit || retained_bytes == limits.byte_limit {
673 omitted_content_bytes = omitted_content_bytes.saturating_add(record.content.len());
674 continue;
675 }
676
677 let available = limits.byte_limit.saturating_sub(retained_bytes);
678 let retained = record.content.len().min(available);
679 let omitted = record.content.len().saturating_sub(retained);
680 let content = String::from_utf8_lossy(&record.content[..retained]).into_owned();
681 retained_bytes = retained_bytes.saturating_add(retained);
682 omitted_content_bytes = omitted_content_bytes.saturating_add(omitted);
683 rendered.push(CanisterDiagnosticLogRecord {
684 index: record.idx,
685 timestamp_nanos: record.timestamp_nanos,
686 content,
687 original_content_bytes: record.content.len(),
688 omitted_content_bytes: omitted,
689 });
690 }
691
692 CanisterDiagnosticLogs {
693 records: rendered,
694 total_records,
695 total_content_bytes,
696 omitted_content_bytes,
697 }
698}
699
700#[cfg(test)]
701mod tests {
702 use std::cell::Cell;
703
704 use candid::Principal;
705 use pocket_ic::CanisterLogRecord;
706
707 use super::{
708 CanisterDiagnosticFailure, CanisterDiagnosticsBatchContractError,
709 CanisterDiagnosticsReport, CanisterDiagnosticsRequest, CanisterLogRenderLimits,
710 LabeledCanisterDiagnosticsRequest, PocketIcDiagnosticsExt, capture_diagnostic_call,
711 render_log_records,
712 };
713
714 #[test]
715 fn diagnostic_transport_classification_survives_context_and_io_wrapping() {
716 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\" }) }";
717 for message in [REFUSED, "application worker channel closed"] {
718 let error = capture_diagnostic_call::<()>(|| panic!("{message}"))
719 .expect_err("diagnostic operation should capture the panic");
720 let expected = message == REFUSED;
721 assert_eq!(
722 matches!(
723 &error,
724 CanisterDiagnosticFailure::InstanceUnavailable { .. }
725 ),
726 expected,
727 );
728 assert_eq!(
729 super::super::is_dead_pocket_ic_transport_error(&error),
730 expected,
731 );
732 let wrapped = std::io::Error::other(error);
733 assert_eq!(
734 super::super::is_dead_pocket_ic_transport_error(&wrapped),
735 expected,
736 );
737 }
738 }
739
740 struct PanickingThenReporting {
741 calls: Cell<usize>,
742 }
743
744 impl PocketIcDiagnosticsExt for PanickingThenReporting {
745 fn collect_canister_diagnostics(
746 &self,
747 request: CanisterDiagnosticsRequest,
748 ) -> CanisterDiagnosticsReport {
749 let call = self.calls.get();
750 self.calls.set(call + 1);
751 assert_ne!(call, 0, "synthetic first-entry diagnostic panic");
752 CanisterDiagnosticsReport {
753 request,
754 status: Err(CanisterDiagnosticFailure::Panicked {
755 message: "synthetic status failure".to_owned(),
756 }),
757 logs: Err(CanisterDiagnosticFailure::Panicked {
758 message: "synthetic log failure".to_owned(),
759 }),
760 }
761 }
762 }
763
764 #[test]
765 fn labeled_batch_retains_order_and_continues_after_entry_panic() {
766 let collector = PanickingThenReporting {
767 calls: Cell::new(0),
768 };
769 let first = CanisterDiagnosticsRequest::new(
770 Principal::from_slice(&[1]),
771 Principal::from_slice(&[2]),
772 Principal::from_slice(&[3]),
773 );
774 let second = CanisterDiagnosticsRequest::new(
775 Principal::from_slice(&[4]),
776 Principal::from_slice(&[5]),
777 Principal::from_slice(&[6]),
778 );
779 let report = collector
780 .collect_canister_diagnostics_batch(&[
781 LabeledCanisterDiagnosticsRequest::new("root", first),
782 LabeledCanisterDiagnosticsRequest::new("worker", second),
783 ])
784 .expect("valid labeled diagnostics batch");
785
786 assert_eq!(collector.calls.get(), 2);
787 assert_eq!(report.entries().len(), 2);
788 assert_eq!(report.entries()[0].label(), "root");
789 assert_eq!(report.entries()[0].report().request(), first);
790 assert_eq!(report.entries()[1].label(), "worker");
791 assert_eq!(report.entries()[1].report().request(), second);
792 assert!(
793 report
794 .entries()
795 .iter()
796 .all(|entry| entry.entry_elapsed() <= report.total())
797 );
798 assert_eq!(report.failures().count(), 2);
799 assert!(!report.is_success());
800 let compact = report.render_compact();
801 assert!(compact.contains("label=\"root\""));
802 assert!(compact.contains("label=\"worker\""));
803 assert!(compact.contains("synthetic first-entry diagnostic panic"));
804 }
805
806 #[test]
807 fn diagnostic_batch_rejects_invalid_labels_before_collection() {
808 let collector = PanickingThenReporting {
809 calls: Cell::new(0),
810 };
811 let request = CanisterDiagnosticsRequest::new(
812 Principal::from_slice(&[1]),
813 Principal::from_slice(&[2]),
814 Principal::from_slice(&[3]),
815 );
816 let empty = collector
817 .collect_canister_diagnostics_batch(&[LabeledCanisterDiagnosticsRequest::new(
818 "", request,
819 )])
820 .expect_err("empty label must reject diagnostics batch");
821 assert_eq!(
822 empty,
823 CanisterDiagnosticsBatchContractError::EmptyLabel { index: 0 }
824 );
825 assert_eq!(collector.calls.get(), 0);
826
827 let duplicate = collector
828 .collect_canister_diagnostics_batch(&[
829 LabeledCanisterDiagnosticsRequest::new("same", request),
830 LabeledCanisterDiagnosticsRequest::new("same", request),
831 ])
832 .expect_err("duplicate labels must reject diagnostics batch");
833 assert_eq!(
834 duplicate,
835 CanisterDiagnosticsBatchContractError::DuplicateLabel {
836 label: "same".to_owned(),
837 first_index: 0,
838 duplicate_index: 1,
839 }
840 );
841 assert_eq!(collector.calls.get(), 0);
842 }
843
844 #[test]
845 fn log_rendering_is_bounded_lossy_utf8_and_reports_truncation() {
846 let logs = render_log_records(
847 vec![
848 CanisterLogRecord {
849 idx: 7,
850 timestamp_nanos: 11,
851 content: vec![b'f', 0x80, b'o'],
852 },
853 CanisterLogRecord {
854 idx: 8,
855 timestamp_nanos: 12,
856 content: b"bar".to_vec(),
857 },
858 ],
859 CanisterLogRenderLimits::new(1, 2),
860 );
861
862 assert_eq!(logs.total_records(), 2);
863 assert_eq!(logs.total_content_bytes(), 6);
864 assert_eq!(logs.omitted_records(), 1);
865 assert_eq!(logs.omitted_content_bytes(), 4);
866 assert!(logs.was_truncated());
867 assert_eq!(logs.records().len(), 1);
868 assert_eq!(logs.records()[0].content(), "f�");
869 assert_eq!(logs.records()[0].original_content_bytes(), 3);
870 assert_eq!(logs.records()[0].omitted_content_bytes(), 1);
871 assert!(logs.records()[0].was_truncated());
872 let rendered = logs.to_string();
873 assert!(rendered.contains("f�"));
874 assert!(rendered.contains("truncated omitted_records=1 omitted_content_bytes=4"));
875 }
876
877 #[test]
878 fn zero_log_bounds_retain_only_aggregate_truncation() {
879 for (record_limit, byte_limit) in [(0, 0), (0, 16), (16, 0)] {
880 let logs = render_log_records(
881 vec![CanisterLogRecord {
882 idx: 1,
883 timestamp_nanos: 2,
884 content: b"hello".to_vec(),
885 }],
886 CanisterLogRenderLimits::new(record_limit, byte_limit),
887 );
888
889 assert_eq!(logs.records(), []);
890 assert_eq!(logs.total_records(), 1);
891 assert_eq!(logs.total_content_bytes(), 5);
892 assert_eq!(logs.omitted_records(), 1);
893 assert_eq!(logs.omitted_content_bytes(), 5);
894 assert!(logs.was_truncated());
895 assert_eq!(
896 logs.to_string(),
897 "<no retained records>; truncated omitted_records=1 omitted_content_bytes=5"
898 );
899 }
900 }
901
902 #[test]
903 fn logs_within_bounds_preserve_empty_records_and_raw_byte_totals() {
904 let limits = CanisterLogRenderLimits::new(3, 3);
905 let empty = render_log_records(vec![], limits);
906 assert_eq!(empty.total_records(), 0);
907 assert_eq!(empty.total_content_bytes(), 0);
908 assert_eq!(empty.omitted_records(), 0);
909 assert_eq!(empty.omitted_content_bytes(), 0);
910 assert!(!empty.was_truncated());
911 assert_eq!(empty.to_string(), "<empty>");
912
913 let logs = render_log_records(
914 vec![
915 CanisterLogRecord {
916 idx: 1,
917 timestamp_nanos: 2,
918 content: vec![],
919 },
920 CanisterLogRecord {
921 idx: 3,
922 timestamp_nanos: 4,
923 content: vec![0x80, b'a'],
924 },
925 CanisterLogRecord {
926 idx: 5,
927 timestamp_nanos: 6,
928 content: vec![],
929 },
930 ],
931 limits,
932 );
933 assert_eq!(logs.total_records(), 3);
934 assert_eq!(logs.records().len(), 3);
935 assert_eq!(logs.total_content_bytes(), 2);
936 assert_eq!(logs.records()[0].content(), "");
937 assert_eq!(logs.records()[1].content(), "�a");
938 assert_eq!(logs.records()[2].content(), "");
939 assert_eq!(logs.omitted_records(), 0);
940 assert_eq!(logs.omitted_content_bytes(), 0);
941 assert!(!logs.was_truncated());
942 }
943}