Skip to main content

ort_openrouter_cli/common/
utils.rs

1//! ort: Open Router CLI
2//! https://github.com/grahamking/ort
3//!
4//! MIT License
5//! Copyright (c) 2025 Graham King
6
7extern crate alloc;
8use alloc::ffi::CString;
9use alloc::string::{String, ToString};
10use alloc::vec::Vec;
11
12use core::ffi::{c_str::CStr, c_void};
13
14use crate::cli::Env;
15use crate::syscall;
16
17/// Converts the number to a string, putting it plus a carriage return into `buf`.
18/// `buf` must be big enough to hold the largest possible number of digits in
19/// your number + 2 ('\n' and '\0').
20/// Returns the length of the converted string, including null bute.
21pub(crate) fn to_ascii(mut num: usize, buf: &mut [u8]) -> usize {
22    if num == 0 {
23        buf[0] = b'0';
24        buf[1] = 0;
25        return 2;
26    }
27
28    let mut div = 1usize;
29    while num / div >= 10 {
30        div *= 10;
31    }
32
33    let mut i = 0usize;
34    while div != 0 {
35        buf[i] = b'0' + (num / div) as u8;
36        num %= div;
37        div /= 10;
38        i += 1;
39    }
40    buf[i] = b'\n';
41    i += 1;
42    buf[i] = 0;
43    i + 1
44}
45
46pub fn num_to_string<T>(num: T) -> String
47where
48    T: TryInto<i128> + Copy,
49{
50    let Ok(mut num) = num.try_into() else {
51        panic!("num_to_string only supports values that fit in i128");
52    };
53
54    if num == 0 {
55        return "0".to_string();
56    }
57
58    let negative = num < 0;
59    if negative {
60        num = -num;
61    }
62
63    let mut buf: [u8; 40] = [0; 40];
64    let mut div = 1i128;
65    let mut i = 0usize;
66
67    if negative {
68        buf[i] = b'-';
69        i += 1;
70    }
71
72    while num / div >= 10 {
73        div *= 10;
74    }
75
76    while div != 0 {
77        buf[i] = b'0' + (num / div) as u8;
78        num %= div;
79        div /= 10;
80        i += 1;
81    }
82
83    unsafe { String::from_utf8_unchecked(buf[..i].into()) }
84}
85
86/// Convert a float to it's string representation with given number of
87/// significant_digits after the decimal.
88pub(crate) fn float_to_string(mut f: f64, significant_digits: usize) -> String {
89    if f.is_nan() {
90        return "NaN".into();
91    }
92    if f.is_infinite() {
93        return if f < 0.0 { "-inf".into() } else { "inf".into() };
94    }
95
96    let mut result = String::new();
97
98    if f < 0.0 {
99        result.push('-');
100        f = -f;
101    }
102
103    // Handle integer part
104    let mut integer_part = f as u64;
105    let mut fraction_part = f - (integer_part as f64);
106
107    // Naive integer to string conversion
108    if integer_part == 0 {
109        result.push('0');
110    } else {
111        let mut buffer = [0u8; 20]; // Max u64 is ~1.8e19
112        let mut idx = 0;
113        while integer_part > 0 {
114            buffer[idx] = (integer_part % 10) as u8 + b'0';
115            integer_part /= 10;
116            idx += 1;
117        }
118        while idx > 0 {
119            idx -= 1;
120            result.push(buffer[idx] as char);
121        }
122    }
123
124    if significant_digits > 0 {
125        result.push('.');
126
127        for _ in 0..significant_digits {
128            fraction_part *= 10.0;
129            let digit = fraction_part as u8; // Truncate
130            result.push((digit + b'0') as char);
131            fraction_part -= digit as f64;
132        }
133    }
134
135    result
136}
137
138#[allow(unused)]
139pub(crate) fn print_hex(prefix: &CStr, v: &[u8]) {
140    let hex: alloc::string::String = v
141        .iter()
142        .map(|b| alloc::format!(" {:02x}", b))
143        .collect::<Vec<_>>()
144        .join("");
145    print_string(prefix, &hex);
146}
147
148#[allow(unused)]
149pub fn print_string(prefix: &CStr, s: &str) {
150    let msg = CString::new(zclean(&mut s.to_string())).unwrap();
151    let _ = syscall::write(1, prefix.as_ptr().cast::<c_void>(), prefix.count_bytes());
152    let _ = syscall::write(1, msg.as_ptr().cast::<c_void>(), msg.count_bytes());
153    let _ = syscall::write(1, c"\n".as_ptr().cast::<c_void>(), c"\n".count_bytes());
154}
155
156/// Replace any null bytes with an underscore, making it C-safe
157/// Makes this construction safe from panic: `CString::new(zclean(s)).unwrap()`
158pub(crate) fn zclean(s: &mut str) -> &str {
159    for byte in unsafe { s.as_bytes_mut() } {
160        if *byte == 0 {
161            *byte = b'_';
162        }
163    }
164    s
165}
166
167pub(crate) fn slug(s: &str) -> String {
168    let mut out = String::with_capacity(16);
169    out.extend(s.chars().map(|c| {
170        if c.is_alphanumeric() {
171            c.to_lowercase().next().unwrap_or('-')
172        } else {
173            '-'
174        }
175    }));
176    out
177}
178
179// The filename of the last invocation of `ort`, taking into account tmux pane ID.
180pub(crate) fn last_filename(env: &Env) -> String {
181    // We don't expect pane IDs to go beyong 999
182    let mut buf: [u8; 3] = [0; 3];
183    let buf_len = tmux_pane_id(env.TMUX_PANE.unwrap_or_default(), &mut buf);
184
185    let mut out = String::with_capacity(16);
186    out.push_str("last-");
187    // safety: to_ascii only returns chars '0'-'9'.
188    out.push_str(unsafe { str::from_utf8_unchecked(&buf[..buf_len]) });
189    out.push_str(".json");
190
191    out
192}
193
194// Write the ID of this tmux pane as a string into the given buf.
195// Writes 0 if there is no TMUX_PANE env var defined.
196// Returns the length in bytes of the written ID.
197pub fn tmux_pane_id(tmux_pane_var: &str, buf: &mut [u8]) -> usize {
198    let v = tmux_pane_var;
199    if v.is_empty() {
200        buf[0] = b'0';
201        return 1;
202    }
203    // removing leading '%'. Values are e.g. '%4'
204    let id_len = v.len() - 1;
205    buf[..id_len].copy_from_slice(&v.as_bytes()[1..]);
206    id_len
207}
208
209/// Create this directory if necessary. Does not create ancestors.
210pub(crate) fn ensure_dir_exists(dir: &str) {
211    let cs = CString::new(dir).unwrap();
212    if !path_exists(cs.as_ref()) {
213        syscall::mkdir(cs.as_ptr(), 0o755);
214    }
215}
216
217/// Does this file path exists, and is accessible by the user?
218pub(crate) fn path_exists(path: &CStr) -> bool {
219    syscall::access(path.as_ptr(), syscall::F_OK) == 0
220}
221
222/// Read a file into memory
223pub(crate) fn filename_read_to_bytes(filename: &str) -> Result<Vec<u8>, &'static str> {
224    let cs = CString::new(filename).unwrap();
225    let fd = match syscall::open(cs.as_ptr(), syscall::O_RDONLY, 0) {
226        Ok(fd) => fd,
227        Err(v) if v == "Permission denied" => {
228            return Err(v);
229        }
230        Err(_) => {
231            return Err("NOT FOUND");
232        }
233    };
234
235    let mut content = Vec::new();
236    let mut buffer = [0u8; 4096];
237
238    loop {
239        let bytes_read = syscall::read(fd, buffer.as_mut_ptr() as *mut c_void, buffer.len());
240
241        if bytes_read < 0 {
242            let _ = syscall::close(fd);
243            return Err("READ ERROR");
244        }
245        if bytes_read == 0 {
246            break;
247        }
248        let bytes_read = bytes_read as usize; // we checked, it's positive
249        content.extend_from_slice(&buffer[..bytes_read]);
250    }
251
252    Ok(content)
253}
254
255/// Read a text file into memory
256pub(crate) fn filename_read_to_string(filename: &str) -> Result<String, &'static str> {
257    let content = filename_read_to_bytes(filename)?;
258    let out = String::from_utf8_lossy(&content);
259    Ok(out.into_owned().to_string())
260}
261
262#[cfg(test)]
263mod tests {
264    use super::{float_to_string, num_to_string};
265
266    #[test]
267    fn num_to_string_handles_sign() {
268        assert_eq!(num_to_string(-42), "-42");
269        assert_eq!(num_to_string(42usize), "42");
270        assert_eq!(num_to_string(0), "0");
271    }
272
273    #[test]
274    fn nan_and_infinity() {
275        assert_eq!(float_to_string(f64::NAN, 3), "NaN");
276        assert_eq!(float_to_string(f64::INFINITY, 3), "inf");
277        assert_eq!(float_to_string(f64::NEG_INFINITY, 3), "-inf");
278    }
279
280    #[test]
281    fn sign_and_integer_part() {
282        assert_eq!(float_to_string(-2.5, 1), "-2.5");
283        assert_eq!(float_to_string(0.0, 0), "0");
284        assert_eq!(float_to_string(-0.0, 2), "0.00"); // f < 0.0 is false for -0.0
285        assert_eq!(float_to_string(12345.0, 0), "12345");
286    }
287
288    #[test]
289    fn fractional_digits_truncate_not_round() {
290        // 1.875 is exactly representable; with 2 digits -> "1.87" (truncation)
291        assert_eq!(float_to_string(1.875, 2), "1.87");
292    }
293
294    #[test]
295    fn fractional_leading_zeros() {
296        // 1/64 = 0.015625 is exactly representable
297        assert_eq!(float_to_string(0.015625, 3), "0.015");
298    }
299
300    #[test]
301    fn no_decimal_point_when_zero_digits() {
302        assert_eq!(float_to_string(3.75, 0), "3");
303    }
304}