Skip to main content

ao_core/
parity_utils.rs

1//! TS core utilities (ported from `packages/core/src/utils.ts` and friends).
2//!
3//! Parity status: test-only.
4//!
5//! Not wired into the ao-rs runtime. Consumed only by
6//! `tests/parity_utils_parity_test.rs`. The canonical `shell_escape`
7//! implementation lives in `ao_core::shell`.
8
9use std::path::Path;
10
11pub fn escape_applescript(s: &str) -> String {
12    s.replace('\\', "\\\\").replace('"', "\\\"")
13}
14
15pub fn validate_url(url: &str, label: &str) -> Result<(), String> {
16    if url.starts_with("https://") || url.starts_with("http://") {
17        Ok(())
18    } else {
19        Err(format!(
20            "[{label}] Invalid url: must be http(s), got \"{url}\""
21        ))
22    }
23}
24
25pub fn is_git_branch_name_safe(name: &str) -> bool {
26    if name.is_empty() {
27        return false;
28    }
29    if name == "@" || name.starts_with('.') || name.ends_with('.') || name.ends_with('/') {
30        return false;
31    }
32    if name.ends_with(".lock") {
33        return false;
34    }
35    if name.contains("..") || name.contains("//") || name.contains("/.") || name.contains("@{") {
36        return false;
37    }
38    if name.starts_with('/') {
39        return false;
40    }
41    for b in name.bytes() {
42        if b <= 0x1f || b == 0x7f {
43            return false;
44        }
45    }
46    // whitespace and git-forbidden punctuation: ~ ^ : ? * [ \ (and space)
47    if name.chars().any(|c| c.is_whitespace()) {
48        return false;
49    }
50    if name.contains('~')
51        || name.contains('^')
52        || name.contains(':')
53        || name.contains('?')
54        || name.contains('*')
55        || name.contains('[')
56        || name.contains('\\')
57    {
58        return false;
59    }
60    true
61}
62
63pub fn is_retryable_http_status(status: u16) -> bool {
64    status == 429 || status >= 500
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub struct RetryConfig {
69    pub retries: u32,
70    pub retry_delay_ms: u64,
71}
72
73pub fn normalize_retry_config(
74    config: Option<&std::collections::HashMap<String, serde_json::Value>>,
75    defaults: RetryConfig,
76) -> RetryConfig {
77    let raw_retries = config
78        .and_then(|m| m.get("retries"))
79        .and_then(|v| v.as_i64());
80    let raw_delay = config
81        .and_then(|m| m.get("retryDelayMs"))
82        .and_then(|v| v.as_i64());
83
84    let retries = match raw_retries {
85        Some(n) => n.max(0) as u32,
86        None => defaults.retries,
87    };
88    let retry_delay_ms = match raw_delay {
89        Some(n) if n >= 0 => n as u64,
90        _ => defaults.retry_delay_ms,
91    };
92    RetryConfig {
93        retries,
94        retry_delay_ms,
95    }
96}
97
98#[derive(Debug, Clone, PartialEq)]
99pub struct LastJsonlEntry {
100    pub last_type: Option<String>,
101    pub modified_at: std::time::SystemTime,
102}
103
104pub fn read_last_jsonl_entry(path: &Path) -> Option<LastJsonlEntry> {
105    let meta = std::fs::metadata(path).ok()?;
106    if meta.len() == 0 {
107        return None;
108    }
109    let modified_at = meta.modified().unwrap_or(std::time::UNIX_EPOCH);
110    let tail_size = 4096u64.min(meta.len());
111    let offset = meta.len().saturating_sub(tail_size);
112    let bytes = std::fs::read(path).ok()?;
113    let tail = if offset as usize >= bytes.len() {
114        &bytes[..]
115    } else {
116        &bytes[offset as usize..]
117    };
118    let s = String::from_utf8_lossy(tail);
119    let mut lines: Vec<&str> = s.split('\n').filter(|l| !l.trim().is_empty()).collect();
120    if lines.is_empty() {
121        return None;
122    }
123    // If we started mid-file, first line might be partial; drop it if there are other lines.
124    if offset > 0 && lines.len() > 1 {
125        lines.remove(0);
126    }
127    for line in lines.iter().rev() {
128        let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
129            continue;
130        };
131        let last_type = v
132            .get("type")
133            .and_then(|t| t.as_str())
134            .map(|s| s.to_string());
135        return Some(LastJsonlEntry {
136            last_type,
137            modified_at,
138        });
139    }
140    None
141}