Skip to main content

cc_switch/daemon/
state.rs

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    /// Load state from disk. Returns `Ok(None)` when the file does not exist;
33    /// returns `Err` with the path on corrupt JSON or other IO errors.
34    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    /// Save state atomically: write to `<path>.tmp` (mode 0600 on Unix),
49    /// fsync, then rename over `path`.
50    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    /// Exact-match lookup. No URL normalization.
58    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}