1use std::{
38 collections::{HashMap, HashSet},
39 fmt,
40 time::Duration,
41};
42
43use crate::target::Target;
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
48pub enum EventKind {
49 StringDecrypted,
51 ConstantDecrypted,
53 ConstantFolded,
55 BranchSimplified,
57 InstructionRemoved,
59 BlockRemoved,
61 MethodInlined,
63 PhiSimplified,
65 ValueResolved,
67 MethodMarkedDead,
69 ControlFlowRestructured,
71 OpaquePredicateRemoved,
73 CopyPropagated,
75 ArrayDecrypted,
77 StrengthReduced,
79 VariablesCompacted,
81 MethodBodyDecrypted,
83 ResourceDecrypted,
86 AntiTamperRemoved,
88 ArtifactRemoved,
90 CodeRegenerated,
92}
93
94impl EventKind {
95 #[must_use]
97 pub fn description(&self) -> &'static str {
98 match self {
99 Self::StringDecrypted => "string decrypted",
100 Self::ConstantDecrypted => "constant decrypted",
101 Self::ConstantFolded => "constant folded",
102 Self::BranchSimplified => "branch simplified",
103 Self::InstructionRemoved => "instruction removed",
104 Self::BlockRemoved => "block removed",
105 Self::MethodInlined => "method inlined",
106 Self::PhiSimplified => "phi simplified",
107 Self::ValueResolved => "value resolved",
108 Self::MethodMarkedDead => "method marked dead",
109 Self::ControlFlowRestructured => "control flow restructured",
110 Self::OpaquePredicateRemoved => "opaque predicate removed",
111 Self::CopyPropagated => "copy propagated",
112 Self::ArrayDecrypted => "array decrypted",
113 Self::StrengthReduced => "strength reduced",
114 Self::VariablesCompacted => "variables compacted",
115 Self::MethodBodyDecrypted => "method body decrypted",
116 Self::ResourceDecrypted => "resource decrypted",
117 Self::AntiTamperRemoved => "anti-tamper removed",
118 Self::ArtifactRemoved => "artifact removed",
119 Self::CodeRegenerated => "code regenerated",
120 }
121 }
122
123 #[must_use]
125 pub fn is_transformation(&self) -> bool {
126 !matches!(self, Self::CodeRegenerated)
127 }
128}
129
130impl fmt::Display for EventKind {
131 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132 f.write_str(self.description())
133 }
134}
135
136#[derive(Debug, Clone)]
138pub struct Event<T: Target> {
139 pub kind: EventKind,
141 pub method: Option<T::MethodRef>,
143 pub location: Option<usize>,
145 pub message: String,
147 pub pass: Option<String>,
149}
150
151impl<T: Target> fmt::Display for Event<T> {
152 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153 write!(f, "[{}] {}", self.kind, self.message)
154 }
155}
156
157pub trait EventListener<T: Target> {
165 fn push(&self, event: Event<T>);
167
168 fn record(&self, kind: EventKind) -> EventBuilder<'_, T, Self>
172 where
173 Self: Sized,
174 {
175 EventBuilder::new(self, kind)
176 }
177}
178
179#[derive(Debug, Default, Clone, Copy)]
185pub struct NullListener;
186
187impl<T: Target> EventListener<T> for NullListener {
188 fn push(&self, _event: Event<T>) {}
189}
190
191pub struct EventBuilder<'a, T: Target, L: EventListener<T> + ?Sized> {
195 listener: &'a L,
196 kind: EventKind,
197 method: Option<T::MethodRef>,
198 location: Option<usize>,
199 message: Option<String>,
200 pass: Option<String>,
201}
202
203impl<'a, T: Target, L: EventListener<T> + ?Sized> EventBuilder<'a, T, L> {
204 fn new(listener: &'a L, kind: EventKind) -> Self {
205 Self {
206 listener,
207 kind,
208 method: None,
209 location: None,
210 message: None,
211 pass: None,
212 }
213 }
214
215 pub fn at(mut self, method: impl Into<T::MethodRef>, location: usize) -> Self {
219 self.method = Some(method.into());
220 self.location = Some(location);
221 self
222 }
223
224 pub fn method(mut self, method: impl Into<T::MethodRef>) -> Self {
226 self.method = Some(method.into());
227 self
228 }
229
230 pub fn location(mut self, location: usize) -> Self {
232 self.location = Some(location);
233 self
234 }
235
236 pub fn message(mut self, msg: impl Into<String>) -> Self {
238 self.message = Some(msg.into());
239 self
240 }
241
242 pub fn pass(mut self, pass_name: impl Into<String>) -> Self {
244 self.pass = Some(pass_name.into());
245 self
246 }
247}
248
249impl<T: Target, L: EventListener<T> + ?Sized> Drop for EventBuilder<'_, T, L> {
250 fn drop(&mut self) {
251 let message = self
252 .message
253 .take()
254 .unwrap_or_else(|| self.kind.description().to_string());
255
256 let event = Event {
257 kind: self.kind,
258 method: self.method.take(),
259 location: self.location.take(),
260 message,
261 pass: self.pass.take(),
262 };
263
264 self.listener.push(event);
265 }
266}
267
268pub struct EventLog<T: Target> {
273 events: boxcar::Vec<Event<T>>,
274}
275
276impl<T: Target> Default for EventLog<T> {
277 fn default() -> Self {
278 Self {
279 events: boxcar::Vec::new(),
280 }
281 }
282}
283
284impl<T: Target> fmt::Debug for EventLog<T> {
285 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
286 f.debug_struct("EventLog")
287 .field("len", &self.len())
288 .finish()
289 }
290}
291
292impl<T: Target> Clone for EventLog<T> {
293 fn clone(&self) -> Self {
294 let new_log = Self::new();
295 for (_, event) in &self.events {
296 new_log.events.push(event.clone());
297 }
298 new_log
299 }
300}
301
302impl<T: Target> EventListener<T> for EventLog<T> {
303 fn push(&self, event: Event<T>) {
304 self.events.push(event);
305 }
306}
307
308impl<T: Target> EventLog<T> {
309 #[must_use]
311 pub fn new() -> Self {
312 Self::default()
313 }
314
315 pub fn record(&self, kind: EventKind) -> EventBuilder<'_, T, Self> {
320 EventBuilder::new(self, kind)
321 }
322
323 #[must_use]
325 pub fn is_empty(&self) -> bool {
326 self.events.count() == 0
327 }
328
329 #[must_use]
331 pub fn len(&self) -> usize {
332 self.events.count()
333 }
334
335 pub fn merge(&self, other: &EventLog<T>) {
337 for (_, event) in &other.events {
338 self.events.push(event.clone());
339 }
340 }
341
342 #[must_use]
344 pub fn has(&self, kind: EventKind) -> bool {
345 self.events.iter().any(|(_, e)| e.kind == kind)
346 }
347
348 #[must_use]
350 pub fn has_any(&self, kinds: &[EventKind]) -> bool {
351 self.events.iter().any(|(_, e)| kinds.contains(&e.kind))
352 }
353
354 #[must_use]
356 pub fn count_kind(&self, kind: EventKind) -> usize {
357 self.events.iter().filter(|(_, e)| e.kind == kind).count()
358 }
359
360 pub fn iter(&self) -> impl Iterator<Item = &Event<T>> {
362 self.events.iter().map(|(_, e)| e)
363 }
364
365 pub fn filter_kind(&self, kind: EventKind) -> impl Iterator<Item = &Event<T>> + '_ {
367 self.events
368 .iter()
369 .filter_map(move |(_, e)| if e.kind == kind { Some(e) } else { None })
370 }
371
372 #[must_use]
378 pub fn take(&self) -> EventLog<T> {
379 self.clone()
380 }
381
382 pub fn filter_method<'a>(
384 &'a self,
385 method: &'a T::MethodRef,
386 ) -> impl Iterator<Item = &'a Event<T>> + 'a {
387 self.events.iter().filter_map(move |(_, e)| {
388 if e.method.as_ref() == Some(method) {
389 Some(e)
390 } else {
391 None
392 }
393 })
394 }
395
396 pub fn transformations(&self) -> impl Iterator<Item = &Event<T>> + '_ {
398 self.events.iter().filter_map(|(_, e)| {
399 if e.kind.is_transformation() {
400 Some(e)
401 } else {
402 None
403 }
404 })
405 }
406
407 #[must_use]
409 pub fn count_by_kind(&self) -> HashMap<EventKind, usize> {
410 let mut counts: HashMap<EventKind, usize> = HashMap::new();
411 for (_, event) in &self.events {
412 let entry = counts.entry(event.kind).or_insert(0);
413 *entry = entry.saturating_add(1);
414 }
415 counts
416 }
417
418 #[must_use]
423 pub fn count_by_kind_since(&self, offset: usize) -> HashMap<EventKind, usize> {
424 let mut counts: HashMap<EventKind, usize> = HashMap::new();
425 for (idx, event) in &self.events {
426 if idx >= offset {
427 let entry = counts.entry(event.kind).or_insert(0);
428 *entry = entry.saturating_add(1);
429 }
430 }
431 counts
432 }
433
434 #[must_use]
436 pub fn transformation_count(&self) -> usize {
437 self.events
438 .iter()
439 .filter(|(_, e)| e.kind.is_transformation())
440 .count()
441 }
442
443 #[must_use]
445 pub fn methods_affected(&self) -> usize {
446 self.events
447 .iter()
448 .filter_map(|(_, e)| e.method.as_ref())
449 .collect::<HashSet<_>>()
450 .len()
451 }
452
453 #[must_use]
455 pub fn summary(&self) -> String {
456 if self.is_empty() {
457 return "no events".to_string();
458 }
459
460 let counts = self.count_by_kind();
461
462 let mut parts: Vec<String> = counts
463 .iter()
464 .filter(|(k, _)| k.is_transformation())
465 .map(|(kind, count)| format!("{} {}", count, kind.description()))
466 .collect();
467
468 if parts.is_empty() {
469 return format!("{} events", self.len());
470 }
471
472 parts.sort();
473 parts.join(", ")
474 }
475}
476
477pub struct EventLogIter<'a, T: Target> {
479 inner: boxcar::Iter<'a, Event<T>>,
480}
481
482impl<'a, T: Target> Iterator for EventLogIter<'a, T> {
483 type Item = &'a Event<T>;
484
485 fn next(&mut self) -> Option<Self::Item> {
486 self.inner.next().map(|(_, e)| e)
487 }
488}
489
490impl<'a, T: Target> IntoIterator for &'a EventLog<T> {
491 type Item = &'a Event<T>;
492 type IntoIter = EventLogIter<'a, T>;
493
494 fn into_iter(self) -> Self::IntoIter {
495 EventLogIter {
496 inner: self.events.iter(),
497 }
498 }
499}
500
501impl<T: Target> Extend<Event<T>> for EventLog<T> {
502 fn extend<I: IntoIterator<Item = Event<T>>>(&mut self, iter: I) {
503 for event in iter {
504 self.events.push(event);
505 }
506 }
507}
508
509impl<T: Target> FromIterator<Event<T>> for EventLog<T> {
510 fn from_iter<I: IntoIterator<Item = Event<T>>>(iter: I) -> Self {
511 let log = Self::new();
512 for event in iter {
513 log.events.push(event);
514 }
515 log
516 }
517}
518
519#[derive(Debug, Clone, Default)]
522pub struct DerivedStats {
523 pub methods_transformed: usize,
525 pub strings_decrypted: usize,
527 pub arrays_decrypted: usize,
529 pub constants_folded: usize,
531 pub constants_decrypted: usize,
533 pub instructions_removed: usize,
535 pub blocks_removed: usize,
537 pub branches_simplified: usize,
539 pub opaque_predicates_removed: usize,
541 pub methods_inlined: usize,
543 pub methods_marked_dead: usize,
545 pub methods_regenerated: usize,
547 pub artifacts_removed: usize,
549 pub iterations: usize,
551 pub total_time: Duration,
553}
554
555impl DerivedStats {
556 #[must_use]
558 pub fn from_log<T: Target>(log: &EventLog<T>) -> Self {
559 let counts = log.count_by_kind();
560 let get = |kind: EventKind| counts.get(&kind).copied().unwrap_or(0);
561
562 Self {
563 methods_transformed: log.methods_affected(),
564 strings_decrypted: get(EventKind::StringDecrypted),
565 arrays_decrypted: get(EventKind::ArrayDecrypted),
566 constants_folded: get(EventKind::ConstantFolded),
567 constants_decrypted: get(EventKind::ConstantDecrypted),
568 instructions_removed: get(EventKind::InstructionRemoved),
569 blocks_removed: get(EventKind::BlockRemoved),
570 branches_simplified: get(EventKind::BranchSimplified),
571 opaque_predicates_removed: get(EventKind::OpaquePredicateRemoved),
572 methods_inlined: get(EventKind::MethodInlined),
573 methods_marked_dead: get(EventKind::MethodMarkedDead),
574 methods_regenerated: get(EventKind::CodeRegenerated),
575 artifacts_removed: get(EventKind::ArtifactRemoved),
576 iterations: 0,
577 total_time: Duration::ZERO,
578 }
579 }
580
581 #[must_use]
583 pub fn with_time(mut self, time: Duration) -> Self {
584 self.total_time = time;
585 self
586 }
587
588 #[must_use]
590 pub fn with_iterations(mut self, iterations: usize) -> Self {
591 self.iterations = iterations;
592 self
593 }
594
595 #[must_use]
597 pub fn summary(&self) -> String {
598 let mut parts = Vec::new();
599
600 if self.methods_transformed > 0 {
601 parts.push(format!("{} methods", self.methods_transformed));
602 }
603
604 if self.strings_decrypted > 0 {
605 parts.push(format!("{} strings decrypted", self.strings_decrypted));
606 }
607 if self.arrays_decrypted > 0 {
608 parts.push(format!("{} arrays decrypted", self.arrays_decrypted));
609 }
610 if self.constants_decrypted > 0 {
611 parts.push(format!("{} constants decrypted", self.constants_decrypted));
612 }
613
614 if self.constants_folded > 0 {
615 parts.push(format!("{} constants folded", self.constants_folded));
616 }
617 if self.instructions_removed > 0 {
618 parts.push(format!(
619 "{} instructions removed",
620 self.instructions_removed
621 ));
622 }
623 if self.blocks_removed > 0 {
624 parts.push(format!("{} blocks removed", self.blocks_removed));
625 }
626 if self.branches_simplified > 0 {
627 parts.push(format!("{} branches simplified", self.branches_simplified));
628 }
629 if self.methods_inlined > 0 {
630 parts.push(format!("{} inlined", self.methods_inlined));
631 }
632 if self.opaque_predicates_removed > 0 {
633 parts.push(format!(
634 "{} opaque predicates",
635 self.opaque_predicates_removed
636 ));
637 }
638
639 if self.methods_marked_dead > 0 {
640 parts.push(format!("{} dead methods", self.methods_marked_dead));
641 }
642 if self.methods_regenerated > 0 {
643 parts.push(format!("{} regenerated", self.methods_regenerated));
644 }
645 if self.artifacts_removed > 0 {
646 parts.push(format!("{} artifacts removed", self.artifacts_removed));
647 }
648
649 let stats = if parts.is_empty() {
650 "no transformations".to_string()
651 } else {
652 parts.join(", ")
653 };
654
655 if self.total_time.as_millis() > 0 {
656 format!(
657 "{} in {:?} ({} iterations)",
658 stats, self.total_time, self.iterations
659 )
660 } else {
661 stats
662 }
663 }
664}
665
666impl fmt::Display for DerivedStats {
667 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
668 f.write_str(&self.summary())
669 }
670}
671
672#[must_use]
674pub fn truncate_string(s: &str, max_len: usize) -> String {
675 if s.len() <= max_len {
676 s.to_string()
677 } else {
678 let end = max_len.saturating_sub(3);
679 let split = s
680 .char_indices()
681 .map(|(i, _)| i)
682 .take_while(|&i| i <= end)
683 .last()
684 .unwrap_or(0);
685 format!("{}...", &s[..split])
686 }
687}
688
689#[cfg(test)]
690mod tests {
691 use super::*;
692
693 use std::{sync::Arc, thread};
694
695 use crate::testing::MockTarget;
696
697 type Method = <MockTarget as Target>::MethodRef;
698
699 fn method(id: u32) -> Method {
700 id
701 }
702
703 #[test]
704 fn empty_log() {
705 let log: EventLog<MockTarget> = EventLog::new();
706 assert!(log.is_empty());
707 assert_eq!(log.len(), 0);
708 assert!(!log.has(EventKind::StringDecrypted));
709 }
710
711 #[test]
712 fn record_event() {
713 let log: EventLog<MockTarget> = EventLog::new();
714 let m = method(0x06000001);
715
716 log.record(EventKind::StringDecrypted)
717 .at(m, 0x10)
718 .message("decrypted: \"hello\"");
719
720 assert!(!log.is_empty());
721 assert_eq!(log.len(), 1);
722 assert!(log.has(EventKind::StringDecrypted));
723
724 let event = log.iter().next().unwrap();
725 assert_eq!(event.method, Some(m));
726 assert_eq!(event.location, Some(0x10));
727 assert_eq!(event.message, "decrypted: \"hello\"");
728 }
729
730 #[test]
731 fn null_listener_discards() {
732 let listener = NullListener;
733 let m = method(0x06000001);
734
735 EventListener::<MockTarget>::record(&listener, EventKind::StringDecrypted)
736 .at(m, 0x10)
737 .message("dropped on the floor");
738
739 }
742
743 #[test]
744 fn multiple_events() {
745 let log: EventLog<MockTarget> = EventLog::new();
746 let m = method(0x06000001);
747
748 log.record(EventKind::StringDecrypted)
749 .at(m, 0x10)
750 .message("first");
751 log.record(EventKind::ConstantFolded)
752 .at(m, 0x20)
753 .message("second");
754
755 assert_eq!(log.len(), 2);
756 assert!(log.has(EventKind::StringDecrypted));
757 assert!(log.has(EventKind::ConstantFolded));
758 assert!(!log.has(EventKind::BlockRemoved));
759 }
760
761 #[test]
762 fn has_any() {
763 let log: EventLog<MockTarget> = EventLog::new();
764 log.record(EventKind::StringDecrypted)
765 .at(method(0x06000001), 0x10);
766
767 assert!(log.has_any(&[EventKind::StringDecrypted, EventKind::ArrayDecrypted]));
768 assert!(!log.has_any(&[EventKind::BlockRemoved, EventKind::MethodInlined]));
769 }
770
771 #[test]
772 fn merge() {
773 let log1: EventLog<MockTarget> = EventLog::new();
774 let log2: EventLog<MockTarget> = EventLog::new();
775 let m = method(0x06000001);
776
777 log1.record(EventKind::StringDecrypted).at(m, 0x10);
778 log2.record(EventKind::ConstantFolded).at(m, 0x20);
779
780 log1.merge(&log2);
781
782 assert_eq!(log1.len(), 2);
783 assert!(log1.has(EventKind::StringDecrypted));
784 assert!(log1.has(EventKind::ConstantFolded));
785 }
786
787 #[test]
788 fn summary() {
789 let log: EventLog<MockTarget> = EventLog::new();
790 let m = method(0x06000001);
791
792 log.record(EventKind::StringDecrypted).at(m, 0x10);
793 log.record(EventKind::StringDecrypted).at(m, 0x20);
794 log.record(EventKind::ConstantFolded).at(m, 0x30);
795
796 let summary = log.summary();
797 assert!(summary.contains("2 string decrypted"));
798 assert!(summary.contains("1 constant folded"));
799 }
800
801 #[test]
802 fn count_by_kind() {
803 let log: EventLog<MockTarget> = EventLog::new();
804 let m = method(0x06000001);
805
806 log.record(EventKind::StringDecrypted).at(m, 0x10);
807 log.record(EventKind::StringDecrypted).at(m, 0x20);
808 log.record(EventKind::ConstantFolded).at(m, 0x30);
809
810 let counts = log.count_by_kind();
811 assert_eq!(counts.get(&EventKind::StringDecrypted), Some(&2));
812 assert_eq!(counts.get(&EventKind::ConstantFolded), Some(&1));
813 assert_eq!(counts.get(&EventKind::BlockRemoved), None);
814 }
815
816 #[test]
817 fn count_by_kind_since() {
818 let log: EventLog<MockTarget> = EventLog::new();
819 let m = method(0x06000001);
820
821 log.record(EventKind::StringDecrypted).at(m, 0x10);
822 log.record(EventKind::StringDecrypted).at(m, 0x20);
823
824 let offset = log.len();
825
826 log.record(EventKind::ConstantFolded).at(m, 0x30);
827 log.record(EventKind::ConstantFolded).at(m, 0x40);
828 log.record(EventKind::StringDecrypted).at(m, 0x50);
829
830 let counts = log.count_by_kind_since(offset);
831 assert_eq!(counts.get(&EventKind::ConstantFolded), Some(&2));
832 assert_eq!(counts.get(&EventKind::StringDecrypted), Some(&1));
833 assert_eq!(counts.get(&EventKind::BlockRemoved), None);
834
835 let all = log.count_by_kind_since(0);
836 assert_eq!(all.get(&EventKind::StringDecrypted), Some(&3));
837 assert_eq!(all.get(&EventKind::ConstantFolded), Some(&2));
838 }
839
840 #[test]
841 fn derived_stats() {
842 let log: EventLog<MockTarget> = EventLog::new();
843 let m1 = method(0x06000001);
844 let m2 = method(0x06000002);
845
846 log.record(EventKind::StringDecrypted).at(m1, 0x10);
847 log.record(EventKind::StringDecrypted).at(m2, 0x20);
848 log.record(EventKind::ConstantFolded).at(m1, 0x30);
849
850 let stats = DerivedStats::from_log(&log);
851 assert_eq!(stats.methods_transformed, 2);
852 assert_eq!(stats.strings_decrypted, 2);
853 assert_eq!(stats.constants_folded, 1);
854 }
855
856 #[test]
857 fn filter_methods() {
858 let log: EventLog<MockTarget> = EventLog::new();
859 let m1 = method(0x06000001);
860 let m2 = method(0x06000002);
861
862 log.record(EventKind::StringDecrypted).at(m1, 0x10);
863 log.record(EventKind::ConstantFolded).at(m2, 0x20);
864 log.record(EventKind::BlockRemoved).at(m1, 0x30);
865
866 let m1_events: Vec<_> = log.filter_method(&m1).collect();
867 assert_eq!(m1_events.len(), 2);
868 }
869
870 #[test]
871 fn transformations_filter() {
872 let log: EventLog<MockTarget> = EventLog::new();
873 let m = method(0x06000001);
874
875 log.record(EventKind::StringDecrypted).at(m, 0x10);
876 log.record(EventKind::BlockRemoved).at(m, 0x20);
877
878 let transformations: Vec<_> = log.transformations().collect();
879 assert_eq!(transformations.len(), 2);
880 }
881
882 #[test]
883 fn event_with_pass() {
884 let log: EventLog<MockTarget> = EventLog::new();
885 let m = method(0x06000001);
886
887 log.record(EventKind::ConstantFolded)
888 .at(m, 0x10)
889 .pass("ConstantFolding")
890 .message("42 + 0 → 42");
891
892 let event = log.iter().next().unwrap();
893 assert_eq!(event.pass.as_deref(), Some("ConstantFolding"));
894 }
895
896 #[test]
897 fn default_message() {
898 let log: EventLog<MockTarget> = EventLog::new();
899 let m = method(0x06000001);
900
901 log.record(EventKind::StringDecrypted).at(m, 0x10);
902
903 let event = log.iter().next().unwrap();
904 assert_eq!(event.message, "string decrypted");
905 }
906
907 #[test]
908 fn thread_safe_append() {
909 let log: Arc<EventLog<MockTarget>> = Arc::new(EventLog::new());
910 let mut handles = vec![];
911
912 for i in 0..4u32 {
913 let log_clone = Arc::clone(&log);
914 handles.push(thread::spawn(move || {
915 for j in 0..100u32 {
916 let m = method(
917 0x06000000u32
918 .saturating_add(i.saturating_mul(100))
919 .saturating_add(j),
920 );
921 log_clone
922 .record(EventKind::StringDecrypted)
923 .at(m, j as usize)
924 .message(format!("thread {} event {}", i, j));
925 }
926 }));
927 }
928
929 for handle in handles {
930 handle.join().unwrap();
931 }
932
933 assert_eq!(log.len(), 400);
934 }
935
936 #[test]
937 fn truncate_string_short() {
938 assert_eq!(truncate_string("hi", 10), "hi");
939 }
940
941 #[test]
942 fn truncate_string_long() {
943 let result = truncate_string("hello world", 8);
944 assert!(result.ends_with("..."));
945 assert!(result.len() <= 8);
946 }
947}