1use 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 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 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}