1use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9use std::path::{Path, PathBuf};
10
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub struct Host {
13 pub name: String,
14 pub ssh_target: String,
15 pub port: u16,
16 #[serde(default)]
17 pub identity: Option<PathBuf>,
18 pub triple: String,
19 pub install_dir: String,
20 #[serde(default)]
26 pub recipient: String,
27 #[serde(default)]
28 pub bucket: String,
29 #[serde(default)]
30 pub region: String,
31 #[serde(default)]
32 pub prefix: String,
33 #[serde(default)]
34 pub endpoint: Option<String>,
35 #[serde(default)]
37 pub mirror: bool,
38}
39
40#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
41pub struct Registry {
42 #[serde(default)]
43 pub hosts: Vec<Host>,
44}
45
46impl Registry {
47 pub fn load(path: &Path) -> Result<Registry> {
50 if !path.exists() {
51 return Ok(Registry::default());
52 }
53 let text = std::fs::read_to_string(path)
54 .with_context(|| format!("reading registry {}", path.display()))?;
55 let registry: Registry = toml::from_str(&text).context("parsing hosts.toml")?;
56 Ok(registry)
57 }
58
59 pub fn save(&self, path: &Path) -> Result<()> {
62 if let Some(parent) = path.parent() {
63 std::fs::create_dir_all(parent)
64 .with_context(|| format!("creating registry dir {}", parent.display()))?;
65 }
66 let mut sorted = self.clone();
67 sorted.hosts.sort_by(|a, b| a.name.cmp(&b.name));
68 let text = toml::to_string_pretty(&sorted).context("serializing hosts.toml")?;
69 std::fs::write(path, text)
70 .with_context(|| format!("writing registry {}", path.display()))?;
71 Ok(())
72 }
73
74 pub fn upsert(&mut self, host: Host) {
76 if let Some(existing) = self.hosts.iter_mut().find(|h| h.name == host.name) {
77 *existing = host;
78 } else {
79 self.hosts.push(host);
80 }
81 }
82
83 pub fn remove(&mut self, name: &str) -> bool {
85 let before = self.hosts.len();
86 self.hosts.retain(|h| h.name != name);
87 self.hosts.len() != before
88 }
89
90 pub fn list(&self) -> &[Host] {
91 &self.hosts
92 }
93
94 pub fn mirror_host(&self) -> Option<&Host> {
96 self.hosts.iter().find(|h| h.mirror)
97 }
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103
104 fn host(name: &str) -> Host {
105 Host {
106 name: name.to_string(),
107 ssh_target: format!("ark@{name}.example.com"),
108 port: 22,
109 identity: Some(PathBuf::from("/home/op/.ssh/id_ed25519")),
110 triple: "aarch64-unknown-linux-musl".to_string(),
111 install_dir: "/home/ark/git-ark".to_string(),
112 recipient: "age1abc".to_string(),
113 bucket: "b".to_string(),
114 region: "us-east-1".to_string(),
115 prefix: "git-ark".to_string(),
116 endpoint: None,
117 mirror: false,
118 }
119 }
120
121 #[test]
122 fn round_trip_through_save_and_load() {
123 let dir = tempfile::tempdir().unwrap();
124 let path = dir.path().join("hosts.toml");
125
126 let mut registry = Registry::default();
127 registry.upsert(host("zeta"));
128 registry.upsert(host("alpha"));
129 registry.save(&path).unwrap();
130
131 let loaded = Registry::load(&path).unwrap();
132 let mut expected = Registry::default();
134 expected.upsert(host("alpha"));
135 expected.upsert(host("zeta"));
136 assert_eq!(loaded, expected);
137 }
138
139 #[test]
140 fn upsert_replaces_by_name_latest_wins() {
141 let mut registry = Registry::default();
142 registry.upsert(host("box1"));
143
144 let mut updated = host("box1");
145 updated.port = 2222;
146 updated.triple = "x86_64-unknown-linux-musl".to_string();
147 registry.upsert(updated.clone());
148
149 assert_eq!(registry.list().len(), 1);
150 assert_eq!(registry.list()[0], updated);
151 }
152
153 #[test]
154 fn remove_returns_true_then_false() {
155 let mut registry = Registry::default();
156 registry.upsert(host("box1"));
157
158 assert!(registry.remove("box1"));
159 assert!(!registry.remove("box1"));
160 assert!(registry.list().is_empty());
161 }
162
163 #[test]
164 fn round_trip_preserves_config_fields_and_mirror_flag() {
165 let dir = tempfile::tempdir().unwrap();
166 let path = dir.path().join("hosts.toml");
167
168 let mut h = host("ec2");
169 h.endpoint = Some("http://minio:9000".to_string());
170 h.mirror = true;
171
172 let mut registry = Registry::default();
173 registry.upsert(h.clone());
174 registry.save(&path).unwrap();
175
176 let loaded = Registry::load(&path).unwrap();
177 assert_eq!(loaded.list()[0], h);
178 }
179
180 #[test]
181 fn mirror_host_finds_the_sole_mirror() {
182 let mut registry = Registry::default();
183 registry.upsert(host("nas"));
184 let mut ec2 = host("ec2");
185 ec2.mirror = true;
186 registry.upsert(ec2.clone());
187
188 assert_eq!(registry.mirror_host(), Some(&ec2));
189 }
190
191 #[test]
192 fn mirror_host_is_none_when_no_host_is_designated() {
193 let mut registry = Registry::default();
194 registry.upsert(host("nas"));
195 registry.upsert(host("ec2"));
196
197 assert_eq!(registry.mirror_host(), None);
198 }
199
200 #[test]
201 fn missing_file_loads_empty_registry() {
202 let dir = tempfile::tempdir().unwrap();
203 let path = dir.path().join("does-not-exist").join("hosts.toml");
204
205 let registry = Registry::load(&path).unwrap();
206 assert!(registry.list().is_empty());
207 }
208
209 #[test]
210 fn save_writes_hosts_sorted_by_name() {
211 let dir = tempfile::tempdir().unwrap();
212 let path = dir.path().join("hosts.toml");
213
214 let mut registry = Registry::default();
215 registry.upsert(host("zeta"));
216 registry.upsert(host("alpha"));
217 registry.upsert(host("mid"));
218 registry.save(&path).unwrap();
219
220 let text = std::fs::read_to_string(&path).unwrap();
221 let alpha_pos = text.find("name = \"alpha\"").unwrap();
222 let mid_pos = text.find("name = \"mid\"").unwrap();
223 let zeta_pos = text.find("name = \"zeta\"").unwrap();
224 assert!(alpha_pos < mid_pos, "alpha should precede mid");
225 assert!(mid_pos < zeta_pos, "mid should precede zeta");
226
227 assert_eq!(registry.list()[0].name, "zeta");
229 }
230}