1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::fs::{File, OpenOptions};
4use std::io::Write;
5use std::path::{Path, PathBuf};
6
7#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
8pub struct ProxyEntry {
9 pub provider: String,
10 pub upstream: String,
11 pub proxy_port: u16,
12 #[serde(skip_serializing_if = "Option::is_none")]
13 pub api_port: Option<u16>,
14 pub data_dir: PathBuf,
15 pub started_at: String,
16 pub restart_count: u32,
17}
18
19#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
20pub struct DaemonState {
21 pub schema_version: u32,
22 pub pid: u32,
23 pub started_at: String,
24 pub stopped_at: Option<String>,
25 pub data_root: PathBuf,
26 #[serde(skip_serializing_if = "Option::is_none")]
27 pub agg_port: Option<u16>,
28 pub proxies: Vec<ProxyEntry>,
29}
30
31impl DaemonState {
32 pub fn load(path: &Path) -> Result<Option<DaemonState>> {
35 let raw = match std::fs::read_to_string(path) {
36 Ok(contents) => contents,
37 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
38 Err(err) => {
39 return Err(err)
40 .with_context(|| format!("failed to read daemon state at {}", path.display()));
41 }
42 };
43 let state: DaemonState = serde_json::from_str(&raw)
44 .with_context(|| format!("failed to parse daemon state at {}", path.display()))?;
45 Ok(Some(state))
46 }
47
48 pub fn save(&self, path: &Path) -> Result<()> {
51 let tmp_path = PathBuf::from(format!("{}.tmp", path.display()));
52 let json = serde_json::to_string_pretty(self)
53 .context("failed to serialize daemon state to JSON")?;
54 write_tmp_then_rename(&tmp_path, path, json.as_bytes())
55 }
56
57 pub fn find_proxy(&self, provider: &str, upstream: &str) -> Option<&ProxyEntry> {
59 self.proxies
60 .iter()
61 .find(|entry| entry.provider == provider && entry.upstream == upstream)
62 }
63}
64
65fn write_tmp_then_rename(tmp_path: &Path, final_path: &Path, bytes: &[u8]) -> Result<()> {
66 {
67 let mut file = open_tmp_for_write(tmp_path)?;
68 file.write_all(bytes)
69 .with_context(|| format!("failed to write daemon state to {}", tmp_path.display()))?;
70 file.sync_all()
71 .with_context(|| format!("failed to fsync daemon state at {}", tmp_path.display()))?;
72 }
73 std::fs::rename(tmp_path, final_path).with_context(|| {
74 format!(
75 "failed to rename {} -> {}",
76 tmp_path.display(),
77 final_path.display()
78 )
79 })
80}
81
82#[cfg(unix)]
83fn open_tmp_for_write(tmp_path: &Path) -> Result<File> {
84 use std::os::unix::fs::OpenOptionsExt;
85 OpenOptions::new()
86 .write(true)
87 .create(true)
88 .truncate(true)
89 .mode(0o600)
90 .open(tmp_path)
91 .with_context(|| format!("failed to open {} for write", tmp_path.display()))
92}
93
94#[cfg(not(unix))]
95fn open_tmp_for_write(tmp_path: &Path) -> Result<File> {
96 OpenOptions::new()
97 .write(true)
98 .create(true)
99 .truncate(true)
100 .open(tmp_path)
101 .with_context(|| format!("failed to open {} for write", tmp_path.display()))
102}
103
104#[cfg(test)]
105mod tests {
106 use super::{DaemonState, ProxyEntry};
107 use std::path::PathBuf;
108 use tempfile::TempDir;
109
110 fn sample_proxy(provider: &str, upstream: &str, proxy_port: u16) -> ProxyEntry {
111 ProxyEntry {
112 provider: provider.to_owned(),
113 upstream: upstream.to_owned(),
114 proxy_port,
115 api_port: Some(9000),
116 data_dir: PathBuf::from("/tmp/ccs"),
117 started_at: "2026-05-28T00:00:00Z".to_owned(),
118 restart_count: 0,
119 }
120 }
121
122 fn sample_state(proxies: Vec<ProxyEntry>) -> DaemonState {
123 DaemonState {
124 schema_version: 2,
125 pid: 4242,
126 started_at: "2026-05-28T00:00:00Z".to_owned(),
127 stopped_at: None,
128 data_root: PathBuf::from("/tmp/ccs"),
129 agg_port: None,
130 proxies,
131 }
132 }
133
134 #[test]
135 fn load_save_round_trip() {
136 let dir = TempDir::new().unwrap();
137 let path = dir.path().join("state.json");
138 let state = sample_state(vec![
139 sample_proxy("claude", "https://api.anthropic.com", 8080),
140 sample_proxy("codex", "https://api.openai.com", 8081),
141 ]);
142 state.save(&path).unwrap();
143 let loaded = DaemonState::load(&path).unwrap().expect("file exists");
144 assert_eq!(state, loaded);
145 }
146
147 #[test]
148 fn load_missing_file_returns_none() {
149 let dir = TempDir::new().unwrap();
150 let path = dir.path().join("does_not_exist.json");
151 assert!(DaemonState::load(&path).unwrap().is_none());
152 }
153
154 #[test]
155 fn load_corrupt_json_returns_err_with_path() {
156 let dir = TempDir::new().unwrap();
157 let path = dir.path().join("corrupt.json");
158 std::fs::write(&path, "{not json").unwrap();
159 let err = DaemonState::load(&path).unwrap_err();
160 let rendered = format!("{err:#}");
161 assert!(
162 rendered.contains(path.to_string_lossy().as_ref()),
163 "error message should contain path; got: {rendered}"
164 );
165 }
166
167 #[test]
168 fn find_proxy_exact_match() {
169 let entry = sample_proxy("claude", "https://api.anthropic.com", 8080);
170 let state = sample_state(vec![entry.clone()]);
171 assert_eq!(
172 state.find_proxy("claude", "https://api.anthropic.com"),
173 Some(&entry)
174 );
175 assert_eq!(
176 state.find_proxy("claude", "https://api.anthropic.com/"),
177 None
178 );
179 assert_eq!(state.find_proxy("codex", "https://api.anthropic.com"), None);
180 }
181
182 #[test]
183 fn save_atomic_no_partial_file() {
184 let dir = TempDir::new().unwrap();
185 let path = dir.path().join("state.json");
186 let first = sample_state(vec![sample_proxy("claude", "https://a.example", 8080)]);
187 first.save(&path).unwrap();
188 let second = sample_state(vec![sample_proxy("codex", "https://b.example", 8081)]);
189 second.save(&path).unwrap();
190
191 let loaded = DaemonState::load(&path).unwrap().expect("file exists");
192 assert_eq!(second, loaded);
193
194 let tmp_path = PathBuf::from(format!("{}.tmp", path.display()));
195 assert!(
196 !tmp_path.exists(),
197 "temp file {tmp_path:?} should be renamed away after save"
198 );
199 }
200
201 #[cfg(unix)]
202 #[test]
203 fn save_sets_unix_0600_permissions() {
204 use std::os::unix::fs::PermissionsExt;
205 let dir = TempDir::new().unwrap();
206 let path = dir.path().join("state.json");
207 let state = sample_state(vec![]);
208 state.save(&path).unwrap();
209 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
210 assert_eq!(mode & 0o777, 0o600, "expected 0600, got {:o}", mode & 0o777);
211 }
212}