1use std::{
2 collections::HashMap,
3 fmt,
4 time::{Duration, Instant},
5};
6
7use candid::Principal;
8use pocket_ic::{CanisterLogRecord, CanisterStatusResult, PocketIc, RejectResponse};
9
10use super::{PocketIcOperationError, transport};
11
12pub const DEFAULT_CANISTER_LOG_RECORD_LIMIT: usize = 32;
14
15pub const DEFAULT_CANISTER_LOG_BYTE_LIMIT: usize = 16 * 1024;
17
18#[derive(Clone, Copy, Debug, Eq, PartialEq)]
20pub struct CanisterLogRenderLimits {
21 record_limit: usize,
22 byte_limit: usize,
23}
24
25impl CanisterLogRenderLimits {
26 #[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 #[must_use]
39 pub const fn record_limit(self) -> usize {
40 self.record_limit
41 }
42
43 #[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#[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 #[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 #[must_use]
85 pub const fn with_log_limits(mut self, limits: CanisterLogRenderLimits) -> Self {
86 self.log_limits = limits;
87 self
88 }
89
90 #[must_use]
92 pub const fn canister_id(self) -> Principal {
93 self.canister_id
94 }
95
96 #[must_use]
98 pub const fn status_sender(self) -> Principal {
99 self.status_sender
100 }
101
102 #[must_use]
104 pub const fn log_sender(self) -> Principal {
105 self.log_sender
106 }
107
108 #[must_use]
110 pub const fn log_limits(self) -> CanisterLogRenderLimits {
111 self.log_limits
112 }
113}
114
115#[derive(Clone, Debug, Eq, PartialEq)]
117pub struct LabeledCanisterDiagnosticsRequest {
118 label: String,
119 request: CanisterDiagnosticsRequest,
120}
121
122#[non_exhaustive]
124#[derive(Clone, Debug, Eq, PartialEq)]
125pub enum CanisterDiagnosticsBatchContractError {
126 EmptyLabel {
128 index: usize,
130 },
131 DuplicateLabel {
133 label: String,
135 first_index: usize,
137 duplicate_index: usize,
139 },
140}
141
142impl LabeledCanisterDiagnosticsRequest {
143 #[must_use]
145 pub fn new(label: impl Into<String>, request: CanisterDiagnosticsRequest) -> Self {
146 Self {
147 label: label.into(),
148 request,
149 }
150 }
151
152 #[must_use]
154 pub fn label(&self) -> &str {
155 &self.label
156 }
157
158 #[must_use]
160 pub const fn request(&self) -> CanisterDiagnosticsRequest {
161 self.request
162 }
163
164 #[must_use]
166 pub fn into_parts(self) -> (String, CanisterDiagnosticsRequest) {
167 (self.label, self.request)
168 }
169}
170
171#[non_exhaustive]
173#[derive(Debug)]
174pub enum CanisterDiagnosticFailure {
175 Rejected(RejectResponse),
177 Operation(PocketIcOperationError),
179}
180
181impl fmt::Display for CanisterDiagnosticFailure {
182 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
183 match self {
184 Self::Rejected(response) => write!(formatter, "rejected: {response:?}"),
185 Self::Operation(source) if source.is_transport() => {
186 write!(formatter, "PocketIC instance unavailable: {source}")
187 }
188 Self::Operation(source) => write!(formatter, "panicked: {source}"),
189 }
190 }
191}
192
193impl std::error::Error for CanisterDiagnosticFailure {
194 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
195 match self {
196 Self::Rejected(_) => None,
197 Self::Operation(source) => Some(source),
198 }
199 }
200}
201
202#[derive(Clone, Debug, Eq, PartialEq)]
204pub struct CanisterDiagnosticLogRecord {
205 index: u64,
206 timestamp_nanos: u64,
207 content: String,
208 original_content_bytes: usize,
209 omitted_content_bytes: usize,
210}
211
212impl CanisterDiagnosticLogRecord {
213 #[must_use]
215 pub const fn index(&self) -> u64 {
216 self.index
217 }
218
219 #[must_use]
221 pub const fn timestamp_nanos(&self) -> u64 {
222 self.timestamp_nanos
223 }
224
225 #[must_use]
227 pub fn content(&self) -> &str {
228 &self.content
229 }
230
231 #[must_use]
233 pub const fn original_content_bytes(&self) -> usize {
234 self.original_content_bytes
235 }
236
237 #[must_use]
239 pub const fn omitted_content_bytes(&self) -> usize {
240 self.omitted_content_bytes
241 }
242
243 #[must_use]
245 pub const fn was_truncated(&self) -> bool {
246 self.omitted_content_bytes != 0
247 }
248}
249
250#[derive(Clone, Debug, Eq, PartialEq)]
252pub struct CanisterDiagnosticLogs {
253 records: Vec<CanisterDiagnosticLogRecord>,
254 total_records: usize,
255 total_content_bytes: usize,
256 omitted_content_bytes: usize,
257}
258
259impl CanisterDiagnosticLogs {
260 #[must_use]
262 pub fn records(&self) -> &[CanisterDiagnosticLogRecord] {
263 &self.records
264 }
265
266 #[must_use]
268 pub const fn total_records(&self) -> usize {
269 self.total_records
270 }
271
272 #[must_use]
274 pub const fn total_content_bytes(&self) -> usize {
275 self.total_content_bytes
276 }
277
278 #[must_use]
280 pub const fn omitted_records(&self) -> usize {
281 self.total_records - self.records.len()
282 }
283
284 #[must_use]
286 pub const fn omitted_content_bytes(&self) -> usize {
287 self.omitted_content_bytes
288 }
289
290 #[must_use]
292 pub const fn was_truncated(&self) -> bool {
293 self.omitted_records() != 0 || self.omitted_content_bytes != 0
294 }
295}
296
297impl fmt::Display for CanisterDiagnosticLogs {
298 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
299 if self.records.is_empty() {
300 if self.total_records == 0 {
301 formatter.write_str("<empty>")?;
302 } else {
303 formatter.write_str("<no retained records>")?;
304 }
305 } else {
306 for (position, record) in self.records.iter().enumerate() {
307 if position != 0 {
308 formatter.write_str(", ")?;
309 }
310 write!(
311 formatter,
312 "[{}@{}]={:?}",
313 record.index, record.timestamp_nanos, record.content
314 )?;
315 if record.was_truncated() {
316 write!(
317 formatter,
318 " (truncated {} bytes)",
319 record.omitted_content_bytes
320 )?;
321 }
322 }
323 }
324 if self.was_truncated() {
325 write!(
326 formatter,
327 "; truncated omitted_records={} omitted_content_bytes={}",
328 self.omitted_records(),
329 self.omitted_content_bytes
330 )?;
331 }
332 Ok(())
333 }
334}
335
336#[derive(Debug)]
338pub struct CanisterDiagnosticsReport {
339 request: CanisterDiagnosticsRequest,
340 status: Result<CanisterStatusResult, CanisterDiagnosticFailure>,
341 logs: Result<CanisterDiagnosticLogs, CanisterDiagnosticFailure>,
342}
343
344impl CanisterDiagnosticsReport {
345 #[must_use]
347 pub const fn request(&self) -> CanisterDiagnosticsRequest {
348 self.request
349 }
350
351 pub const fn status(&self) -> Result<&CanisterStatusResult, &CanisterDiagnosticFailure> {
353 self.status.as_ref()
354 }
355
356 pub const fn logs(&self) -> Result<&CanisterDiagnosticLogs, &CanisterDiagnosticFailure> {
358 self.logs.as_ref()
359 }
360
361 #[must_use]
363 pub const fn is_success(&self) -> bool {
364 self.status.is_ok() && self.logs.is_ok()
365 }
366
367 pub fn into_parts(
369 self,
370 ) -> (
371 CanisterDiagnosticsRequest,
372 Result<CanisterStatusResult, CanisterDiagnosticFailure>,
373 Result<CanisterDiagnosticLogs, CanisterDiagnosticFailure>,
374 ) {
375 (self.request, self.status, self.logs)
376 }
377}
378
379#[derive(Debug)]
381pub struct CanisterDiagnosticsBatchEntry {
382 label: String,
383 report: CanisterDiagnosticsReport,
384 entry_elapsed: Duration,
385}
386
387impl CanisterDiagnosticsBatchEntry {
388 #[must_use]
390 pub fn label(&self) -> &str {
391 &self.label
392 }
393
394 #[must_use]
396 pub const fn report(&self) -> &CanisterDiagnosticsReport {
397 &self.report
398 }
399
400 #[must_use]
402 pub const fn is_success(&self) -> bool {
403 self.report.is_success()
404 }
405
406 #[must_use]
408 pub const fn entry_elapsed(&self) -> Duration {
409 self.entry_elapsed
410 }
411
412 #[must_use]
414 pub fn into_parts(self) -> (String, CanisterDiagnosticsReport, Duration) {
415 (self.label, self.report, self.entry_elapsed)
416 }
417}
418
419#[derive(Debug, Default)]
421pub struct CanisterDiagnosticsBatchReport {
422 entries: Vec<CanisterDiagnosticsBatchEntry>,
423 total: Duration,
424}
425
426impl CanisterDiagnosticsBatchReport {
427 #[must_use]
429 pub fn entries(&self) -> &[CanisterDiagnosticsBatchEntry] {
430 &self.entries
431 }
432
433 pub fn failures(&self) -> impl Iterator<Item = &CanisterDiagnosticsBatchEntry> {
435 self.entries.iter().filter(|entry| !entry.is_success())
436 }
437
438 #[must_use]
440 pub fn is_success(&self) -> bool {
441 self.entries
442 .iter()
443 .all(CanisterDiagnosticsBatchEntry::is_success)
444 }
445
446 #[must_use]
448 pub const fn total(&self) -> Duration {
449 self.total
450 }
451
452 #[must_use]
454 pub fn into_entries(self) -> Vec<CanisterDiagnosticsBatchEntry> {
455 self.entries
456 }
457}
458
459impl fmt::Display for CanisterDiagnosticsBatchReport {
460 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
461 write!(
462 formatter,
463 "diagnostics={} total={:?}",
464 self.entries.len(),
465 self.total
466 )?;
467 for entry in &self.entries {
468 write!(
469 formatter,
470 "; label={:?} elapsed={:?} {}",
471 entry.label, entry.entry_elapsed, entry.report
472 )?;
473 }
474 Ok(())
475 }
476}
477
478impl fmt::Display for CanisterDiagnosticsReport {
479 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
480 write!(
481 formatter,
482 "canister={} status_sender={} status=",
483 self.request.canister_id, self.request.status_sender
484 )?;
485 match &self.status {
486 Ok(status) => write!(
487 formatter,
488 "ok(state={:?} version={} controllers={} module_hash_bytes={} memory_bytes={} cycles={})",
489 status.status,
490 status.version,
491 status.settings.controllers.len(),
492 status.module_hash.as_ref().map_or(0, Vec::len),
493 status.memory_size,
494 status.cycles,
495 ),
496 Err(failure) => write!(formatter, "<{failure}>"),
497 }?;
498 write!(formatter, " log_sender={} logs=", self.request.log_sender)?;
499 match &self.logs {
500 Err(failure) => write!(formatter, "<{failure}>")?,
501 Ok(logs) => write!(formatter, "{logs}")?,
502 }
503 Ok(())
504 }
505}
506
507pub trait PocketIcDiagnosticsExt {
509 fn collect_canister_diagnostics(
514 &self,
515 request: CanisterDiagnosticsRequest,
516 ) -> CanisterDiagnosticsReport;
517
518 fn collect_canister_diagnostics_batch(
524 &self,
525 requests: &[LabeledCanisterDiagnosticsRequest],
526 ) -> Result<CanisterDiagnosticsBatchReport, CanisterDiagnosticsBatchContractError> {
527 validate_diagnostics_batch_labels(requests)?;
528 let started = Instant::now();
529 let entries = requests
530 .iter()
531 .map(|labeled| {
532 let entry_started = Instant::now();
533 let request = labeled.request;
534 let report =
535 transport::capture_operation(|| self.collect_canister_diagnostics(request))
536 .unwrap_or_else(|source| CanisterDiagnosticsReport {
537 request,
538 status: Err(CanisterDiagnosticFailure::Operation(source.clone())),
539 logs: Err(CanisterDiagnosticFailure::Operation(source)),
540 });
541 CanisterDiagnosticsBatchEntry {
542 label: labeled.label.clone(),
543 report,
544 entry_elapsed: entry_started.elapsed(),
545 }
546 })
547 .collect();
548 Ok(CanisterDiagnosticsBatchReport {
549 entries,
550 total: started.elapsed(),
551 })
552 }
553}
554
555fn validate_diagnostics_batch_labels(
556 requests: &[LabeledCanisterDiagnosticsRequest],
557) -> Result<(), CanisterDiagnosticsBatchContractError> {
558 let mut seen = HashMap::new();
559 for (index, request) in requests.iter().enumerate() {
560 let label = request.label.as_str();
561 if label.is_empty() {
562 return Err(CanisterDiagnosticsBatchContractError::EmptyLabel { index });
563 }
564 if let Some(first_index) = seen.insert(label, index) {
565 return Err(CanisterDiagnosticsBatchContractError::DuplicateLabel {
566 label: label.to_owned(),
567 first_index,
568 duplicate_index: index,
569 });
570 }
571 }
572 Ok(())
573}
574
575impl fmt::Display for CanisterDiagnosticsBatchContractError {
576 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
577 match self {
578 Self::EmptyLabel { index } => {
579 write!(
580 formatter,
581 "diagnostics batch label at index {index} is empty"
582 )
583 }
584 Self::DuplicateLabel {
585 label,
586 first_index,
587 duplicate_index,
588 } => write!(
589 formatter,
590 "diagnostics batch label {label:?} at index {duplicate_index} duplicates index {first_index}",
591 ),
592 }
593 }
594}
595
596impl std::error::Error for CanisterDiagnosticsBatchContractError {}
597
598impl PocketIcDiagnosticsExt for PocketIc {
599 fn collect_canister_diagnostics(
600 &self,
601 request: CanisterDiagnosticsRequest,
602 ) -> CanisterDiagnosticsReport {
603 let status = capture_diagnostic_call(|| {
604 self.canister_status(request.canister_id, Some(request.status_sender))
605 });
606 let logs = capture_diagnostic_call(|| {
607 self.fetch_canister_logs(request.canister_id, request.log_sender)
608 })
609 .map(|records| render_log_records(records, request.log_limits));
610
611 CanisterDiagnosticsReport {
612 request,
613 status,
614 logs,
615 }
616 }
617}
618
619fn capture_diagnostic_call<T>(
620 call: impl FnOnce() -> Result<T, RejectResponse>,
621) -> Result<T, CanisterDiagnosticFailure> {
622 match transport::capture_operation(call) {
623 Ok(Ok(value)) => Ok(value),
624 Ok(Err(response)) => Err(CanisterDiagnosticFailure::Rejected(response)),
625 Err(source) => Err(CanisterDiagnosticFailure::Operation(source)),
626 }
627}
628
629fn render_log_records(
630 records: Vec<CanisterLogRecord>,
631 limits: CanisterLogRenderLimits,
632) -> CanisterDiagnosticLogs {
633 let total_records = records.len();
634 let mut total_content_bytes = 0usize;
635 let mut rendered = Vec::with_capacity(total_records.min(limits.record_limit));
636 let mut retained_bytes = 0usize;
637 let mut omitted_content_bytes = 0usize;
638
639 for record in records {
640 total_content_bytes = total_content_bytes.saturating_add(record.content.len());
641 if rendered.len() == limits.record_limit || retained_bytes == limits.byte_limit {
642 omitted_content_bytes = omitted_content_bytes.saturating_add(record.content.len());
643 continue;
644 }
645
646 let available = limits.byte_limit.saturating_sub(retained_bytes);
647 let retained = record.content.len().min(available);
648 let omitted = record.content.len().saturating_sub(retained);
649 let content = String::from_utf8_lossy(&record.content[..retained]).into_owned();
650 retained_bytes = retained_bytes.saturating_add(retained);
651 omitted_content_bytes = omitted_content_bytes.saturating_add(omitted);
652 rendered.push(CanisterDiagnosticLogRecord {
653 index: record.idx,
654 timestamp_nanos: record.timestamp_nanos,
655 content,
656 original_content_bytes: record.content.len(),
657 omitted_content_bytes: omitted,
658 });
659 }
660
661 CanisterDiagnosticLogs {
662 records: rendered,
663 total_records,
664 total_content_bytes,
665 omitted_content_bytes,
666 }
667}
668
669#[cfg(test)]
670mod tests {
671 use std::cell::Cell;
672
673 use candid::Principal;
674 use pocket_ic::CanisterLogRecord;
675
676 use super::{
677 CanisterDiagnosticFailure, CanisterDiagnosticsBatchContractError,
678 CanisterDiagnosticsReport, CanisterDiagnosticsRequest, CanisterLogRenderLimits,
679 LabeledCanisterDiagnosticsRequest, PocketIcDiagnosticsExt, capture_diagnostic_call,
680 render_log_records,
681 };
682
683 #[test]
684 fn diagnostic_transport_classification_survives_context_and_io_wrapping() {
685 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\" }) }";
686 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\" })) }";
687 for message in [REFUSED, RESET, "application worker channel closed"] {
688 let error = capture_diagnostic_call::<()>(|| panic!("{message}"))
689 .expect_err("diagnostic operation should capture the panic");
690 let expected = message != "application worker channel closed";
691 let CanisterDiagnosticFailure::Operation(source) = &error else {
692 panic!("expected the original operation error");
693 };
694 assert_eq!(source.message(), message);
695 assert_eq!(source.is_transport(), expected);
696 let chained = std::error::Error::source(&error)
697 .and_then(|cause| cause.downcast_ref::<super::PocketIcOperationError>())
698 .expect("diagnostic error exposes its original typed cause");
699 assert_eq!(chained, source);
700 assert_eq!(
701 super::super::is_dead_pocket_ic_transport_error(&error),
702 expected,
703 );
704 let wrapped = std::io::Error::other(error);
705 assert_eq!(
706 super::super::is_dead_pocket_ic_transport_error(&wrapped),
707 expected,
708 );
709 }
710 }
711
712 struct PanickingThenReporting {
713 calls: Cell<usize>,
714 }
715
716 impl PocketIcDiagnosticsExt for PanickingThenReporting {
717 fn collect_canister_diagnostics(
718 &self,
719 request: CanisterDiagnosticsRequest,
720 ) -> CanisterDiagnosticsReport {
721 let call = self.calls.get();
722 self.calls.set(call + 1);
723 assert_ne!(call, 0, "synthetic first-entry diagnostic panic");
724 CanisterDiagnosticsReport {
725 request,
726 status: Err(CanisterDiagnosticFailure::Operation(
727 super::PocketIcOperationError::new("synthetic status failure"),
728 )),
729 logs: Err(CanisterDiagnosticFailure::Operation(
730 super::PocketIcOperationError::new("synthetic log failure"),
731 )),
732 }
733 }
734 }
735
736 #[test]
737 fn labeled_batch_retains_order_and_continues_after_entry_panic() {
738 let collector = PanickingThenReporting {
739 calls: Cell::new(0),
740 };
741 let first = CanisterDiagnosticsRequest::new(
742 Principal::from_slice(&[1]),
743 Principal::from_slice(&[2]),
744 Principal::from_slice(&[3]),
745 );
746 let second = CanisterDiagnosticsRequest::new(
747 Principal::from_slice(&[4]),
748 Principal::from_slice(&[5]),
749 Principal::from_slice(&[6]),
750 );
751 let report = collector
752 .collect_canister_diagnostics_batch(&[
753 LabeledCanisterDiagnosticsRequest::new("root", first),
754 LabeledCanisterDiagnosticsRequest::new("worker", second),
755 ])
756 .expect("valid labeled diagnostics batch");
757
758 assert_eq!(collector.calls.get(), 2);
759 assert_eq!(report.entries().len(), 2);
760 assert_eq!(report.entries()[0].label(), "root");
761 assert_eq!(report.entries()[0].report().request(), first);
762 assert_eq!(report.entries()[1].label(), "worker");
763 assert_eq!(report.entries()[1].report().request(), second);
764 assert!(
765 report
766 .entries()
767 .iter()
768 .all(|entry| entry.entry_elapsed() <= report.total())
769 );
770 assert_eq!(report.failures().count(), 2);
771 assert!(!report.is_success());
772 let compact = report.to_string();
773 assert!(compact.contains("label=\"root\""));
774 assert!(compact.contains("label=\"worker\""));
775 assert!(compact.contains("synthetic first-entry diagnostic panic"));
776 }
777
778 #[test]
779 fn diagnostic_batch_rejects_invalid_labels_before_collection() {
780 let collector = PanickingThenReporting {
781 calls: Cell::new(0),
782 };
783 let request = CanisterDiagnosticsRequest::new(
784 Principal::from_slice(&[1]),
785 Principal::from_slice(&[2]),
786 Principal::from_slice(&[3]),
787 );
788 let empty = collector
789 .collect_canister_diagnostics_batch(&[LabeledCanisterDiagnosticsRequest::new(
790 "", request,
791 )])
792 .expect_err("empty label must reject diagnostics batch");
793 assert_eq!(
794 empty,
795 CanisterDiagnosticsBatchContractError::EmptyLabel { index: 0 }
796 );
797 assert_eq!(collector.calls.get(), 0);
798
799 let duplicate = collector
800 .collect_canister_diagnostics_batch(&[
801 LabeledCanisterDiagnosticsRequest::new("same", request),
802 LabeledCanisterDiagnosticsRequest::new("same", request),
803 ])
804 .expect_err("duplicate labels must reject diagnostics batch");
805 assert_eq!(
806 duplicate,
807 CanisterDiagnosticsBatchContractError::DuplicateLabel {
808 label: "same".to_owned(),
809 first_index: 0,
810 duplicate_index: 1,
811 }
812 );
813 assert_eq!(collector.calls.get(), 0);
814 }
815
816 #[test]
817 fn log_rendering_is_bounded_lossy_utf8_and_reports_truncation() {
818 let logs = render_log_records(
819 vec![
820 CanisterLogRecord {
821 idx: 7,
822 timestamp_nanos: 11,
823 content: vec![b'f', 0x80, b'o'],
824 },
825 CanisterLogRecord {
826 idx: 8,
827 timestamp_nanos: 12,
828 content: b"bar".to_vec(),
829 },
830 ],
831 CanisterLogRenderLimits::new(1, 2),
832 );
833
834 assert_eq!(logs.total_records(), 2);
835 assert_eq!(logs.total_content_bytes(), 6);
836 assert_eq!(logs.omitted_records(), 1);
837 assert_eq!(logs.omitted_content_bytes(), 4);
838 assert!(logs.was_truncated());
839 assert_eq!(logs.records().len(), 1);
840 assert_eq!(logs.records()[0].content(), "f�");
841 assert_eq!(logs.records()[0].original_content_bytes(), 3);
842 assert_eq!(logs.records()[0].omitted_content_bytes(), 1);
843 assert!(logs.records()[0].was_truncated());
844 let rendered = logs.to_string();
845 assert!(rendered.contains("f�"));
846 assert!(rendered.contains("truncated omitted_records=1 omitted_content_bytes=4"));
847 }
848
849 #[test]
850 fn zero_log_bounds_retain_only_aggregate_truncation() {
851 for (record_limit, byte_limit) in [(0, 0), (0, 16), (16, 0)] {
852 let logs = render_log_records(
853 vec![CanisterLogRecord {
854 idx: 1,
855 timestamp_nanos: 2,
856 content: b"hello".to_vec(),
857 }],
858 CanisterLogRenderLimits::new(record_limit, byte_limit),
859 );
860
861 assert_eq!(logs.records(), []);
862 assert_eq!(logs.total_records(), 1);
863 assert_eq!(logs.total_content_bytes(), 5);
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 }
873
874 #[test]
875 fn logs_within_bounds_preserve_empty_records_and_raw_byte_totals() {
876 let limits = CanisterLogRenderLimits::new(3, 3);
877 let empty = render_log_records(vec![], limits);
878 assert_eq!(empty.total_records(), 0);
879 assert_eq!(empty.total_content_bytes(), 0);
880 assert_eq!(empty.omitted_records(), 0);
881 assert_eq!(empty.omitted_content_bytes(), 0);
882 assert!(!empty.was_truncated());
883 assert_eq!(empty.to_string(), "<empty>");
884
885 let logs = render_log_records(
886 vec![
887 CanisterLogRecord {
888 idx: 1,
889 timestamp_nanos: 2,
890 content: vec![],
891 },
892 CanisterLogRecord {
893 idx: 3,
894 timestamp_nanos: 4,
895 content: vec![0x80, b'a'],
896 },
897 CanisterLogRecord {
898 idx: 5,
899 timestamp_nanos: 6,
900 content: vec![],
901 },
902 ],
903 limits,
904 );
905 assert_eq!(logs.total_records(), 3);
906 assert_eq!(logs.records().len(), 3);
907 assert_eq!(logs.total_content_bytes(), 2);
908 assert_eq!(logs.records()[0].content(), "");
909 assert_eq!(logs.records()[1].content(), "�a");
910 assert_eq!(logs.records()[2].content(), "");
911 assert_eq!(logs.omitted_records(), 0);
912 assert_eq!(logs.omitted_content_bytes(), 0);
913 assert!(!logs.was_truncated());
914 }
915}