Skip to main content

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}