zeroclaw_runtime/util.rs
1//! Utility functions for `ZeroClaw`.
2//!
3//! This module contains reusable helper functions used across the codebase.
4
5/// Allowed serial device path prefixes β reject arbitrary paths for security.
6/// Used by hardware serial transport and peripherals.
7const SERIAL_ALLOWED_PATH_PREFIXES: &[&str] = &[
8 "/dev/ttyACM",
9 "/dev/ttyUSB",
10 "/dev/tty.usbmodem",
11 "/dev/cu.usbmodem",
12 "/dev/tty.usbserial",
13 "/dev/cu.usbserial", // Arduino Uno (FTDI), clones
14 "COM", // Windows
15];
16
17/// Returns true if the path is an allowed serial device (USB CDC, FTDI, etc.).
18/// Rejects arbitrary paths like /etc/passwd or /dev/sda.
19pub fn is_serial_path_allowed(path: &str) -> bool {
20 SERIAL_ALLOWED_PATH_PREFIXES
21 .iter()
22 .any(|prefix| path.starts_with(prefix))
23}
24
25/// Truncate a string to at most `max_chars` characters, appending "..." if truncated.
26///
27/// This function safely handles multi-byte UTF-8 characters (emoji, CJK, accented characters)
28/// by using character boundaries instead of byte indices.
29///
30/// # Arguments
31/// * `s` - The string to truncate
32/// * `max_chars` - Maximum number of characters to keep (excluding "...")
33///
34/// # Returns
35/// * Original string if length <= `max_chars`
36/// * Truncated string with "..." appended if length > `max_chars`
37///
38/// # Examples
39/// ```ignore
40/// use zeroclaw::util::truncate_with_ellipsis;
41///
42/// // ASCII string - no truncation needed
43/// assert_eq!(truncate_with_ellipsis("hello", 10), "hello");
44///
45/// // ASCII string - truncation needed
46/// assert_eq!(truncate_with_ellipsis("hello world", 5), "hello...");
47///
48/// // Multi-byte UTF-8 (emoji) - safe truncation
49/// assert_eq!(truncate_with_ellipsis("Hello π¦ World", 8), "Hello π¦...");
50/// assert_eq!(truncate_with_ellipsis("ππππ", 2), "ππ...");
51///
52/// // Empty string
53/// assert_eq!(truncate_with_ellipsis("", 10), "");
54/// ```
55pub fn truncate_with_ellipsis(s: &str, max_chars: usize) -> String {
56 match s.char_indices().nth(max_chars) {
57 Some((idx, _)) => {
58 let truncated = &s[..idx];
59 // Trim trailing whitespace for cleaner output
60 format!("{}...", truncated.trim_end())
61 }
62 None => s.to_string(),
63 }
64}
65
66/// Utility enum for handling optional values.
67pub enum MaybeSet<T> {
68 Set(T),
69 Unset,
70 Null,
71}
72
73/// Return free heap memory at the top of glibc's arenas to the kernel.
74///
75/// After the session reaper or an explicit `session/close` drops an `Agent`
76/// and its conversation history, glibc keeps the freed pages in its per-arena
77/// free lists instead of `munmap`-ing them, so resident set size stays flat
78/// despite a correct free. This releases the arena tops so the daemon's RSS
79/// actually falls. No-op on targets without glibc's `malloc_trim`.
80///
81/// Gated on Linux + glibc specifically: `libc` is a `cfg(unix)`-only
82/// dependency, and `malloc_trim` is a glibc extension. A bare
83/// `target_env = "gnu"` also matches the `windows-gnu` target, where `libc`
84/// is absent and the call fails to resolve.
85#[cfg(all(target_os = "linux", target_env = "gnu"))]
86pub fn release_freed_heap() {
87 // SAFETY: `malloc_trim` only inspects and releases the allocator's own
88 // free lists. It takes no Rust-owned pointer and frees nothing the program
89 // still references, so it cannot dangle a pointer or double free.
90 unsafe {
91 libc::malloc_trim(0);
92 }
93}
94
95#[cfg(not(all(target_os = "linux", target_env = "gnu")))]
96pub fn release_freed_heap() {}
97
98#[cfg(test)]
99mod tests {
100 use super::*;
101
102 #[test]
103 fn test_truncate_ascii_no_truncation() {
104 // ASCII string shorter than limit - no change
105 assert_eq!(truncate_with_ellipsis("hello", 10), "hello");
106 assert_eq!(truncate_with_ellipsis("hello world", 50), "hello world");
107 }
108
109 #[test]
110 fn test_truncate_ascii_with_truncation() {
111 // ASCII string longer than limit - truncates
112 assert_eq!(truncate_with_ellipsis("hello world", 5), "hello...");
113 assert_eq!(
114 truncate_with_ellipsis("This is a long message", 10),
115 "This is a..."
116 );
117 }
118
119 #[test]
120 fn test_truncate_empty_string() {
121 assert_eq!(truncate_with_ellipsis("", 10), "");
122 }
123
124 #[test]
125 fn test_truncate_at_exact_boundary() {
126 // String exactly at boundary - no truncation
127 assert_eq!(truncate_with_ellipsis("hello", 5), "hello");
128 }
129
130 #[test]
131 fn test_truncate_emoji_single() {
132 // Single emoji (4 bytes) - should not panic
133 let s = "π¦";
134 assert_eq!(truncate_with_ellipsis(s, 10), s);
135 assert_eq!(truncate_with_ellipsis(s, 1), s);
136 }
137
138 #[test]
139 fn test_truncate_emoji_multiple() {
140 // Multiple emoji - safe truncation at character boundary
141 let s = "ππππ"; // 4 emoji, each 4 bytes = 16 bytes total
142 assert_eq!(truncate_with_ellipsis(s, 2), "ππ...");
143 assert_eq!(truncate_with_ellipsis(s, 3), "πππ...");
144 }
145
146 #[test]
147 fn test_truncate_mixed_ascii_emoji() {
148 // Mixed ASCII and emoji
149 assert_eq!(truncate_with_ellipsis("Hello π¦ World", 8), "Hello π¦...");
150 assert_eq!(truncate_with_ellipsis("Hi π", 10), "Hi π");
151 }
152
153 #[test]
154 fn test_truncate_cjk_characters() {
155 // CJK characters (Chinese - each is 3 bytes)
156 let s = "θΏζ―δΈδΈͺζ΅θ―ζΆζ―η¨ζ₯触εε΄©ζΊηδΈζ"; // 21 characters
157 let result = truncate_with_ellipsis(s, 16);
158 assert!(result.ends_with("..."));
159 assert!(result.is_char_boundary(result.len() - 1));
160 }
161
162 #[test]
163 fn test_truncate_accented_characters() {
164 // Accented characters (2 bytes each in UTF-8)
165 let s = "cafΓ© rΓ©sumΓ© naΓ―ve";
166 assert_eq!(truncate_with_ellipsis(s, 10), "cafΓ© rΓ©sum...");
167 }
168
169 #[test]
170 fn test_truncate_unicode_edge_case() {
171 // Mix of 1-byte, 2-byte, 3-byte, and 4-byte characters
172 let s = "aΓ©δ½ ε₯½π¦"; // 1 + 1 + 2 + 2 + 4 bytes = 10 bytes, 5 chars
173 assert_eq!(truncate_with_ellipsis(s, 3), "aΓ©δ½ ...");
174 }
175
176 #[test]
177 fn test_truncate_long_string() {
178 // Long ASCII string
179 let s = "a".repeat(200);
180 let result = truncate_with_ellipsis(&s, 50);
181 assert_eq!(result.len(), 53); // 50 + "..."
182 assert!(result.ends_with("..."));
183 }
184
185 #[test]
186 fn test_truncate_zero_max_chars() {
187 // Edge case: max_chars = 0
188 assert_eq!(truncate_with_ellipsis("hello", 0), "...");
189 }
190}