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 #[serde(default)]
42 pub push_alias: Option<String>,
43}
44
45impl Host {
46 pub fn push_alias(&self) -> String {
50 self.push_alias
51 .clone()
52 .unwrap_or_else(|| format!("git-ark-{}", self.name))
53 }
54}
55
56#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
57pub struct Registry {
58 #[serde(default)]
59 pub hosts: Vec<Host>,
60}
61
62impl Registry {
63 pub fn load(path: &Path) -> Result<Registry> {
66 if !path.exists() {
67 return Ok(Registry::default());
68 }
69 let text = std::fs::read_to_string(path)
70 .with_context(|| format!("reading registry {}", path.display()))?;
71 let registry: Registry = toml::from_str(&text).context("parsing hosts.toml")?;
72 Ok(registry)
73 }
74
75 pub fn save(&self, path: &Path) -> Result<()> {
78 if let Some(parent) = path.parent() {
79 std::fs::create_dir_all(parent)
80 .with_context(|| format!("creating registry dir {}", parent.display()))?;
81 }
82 let mut sorted = self.clone();
83 sorted.hosts.sort_by(|a, b| a.name.cmp(&b.name));
84 let text = toml::to_string_pretty(&sorted).context("serializing hosts.toml")?;
85 std::fs::write(path, text)
86 .with_context(|| format!("writing registry {}", path.display()))?;
87 Ok(())
88 }
89
90 pub fn upsert(&mut self, host: Host) {
92 if let Some(existing) = self.hosts.iter_mut().find(|h| h.name == host.name) {
93 *existing = host;
94 } else {
95 self.hosts.push(host);
96 }
97 }
98
99 pub fn remove(&mut self, name: &str) -> bool {
101 let before = self.hosts.len();
102 self.hosts.retain(|h| h.name != name);
103 self.hosts.len() != before
104 }
105
106 pub fn list(&self) -> &[Host] {
107 &self.hosts
108 }
109
110 pub fn mirror_host(&self) -> Option<&Host> {
112 self.hosts.iter().find(|h| h.mirror)
113 }
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119
120 fn host(name: &str) -> Host {
121 Host {
122 name: name.to_string(),
123 ssh_target: format!("ark@{name}.example.com"),
124 port: 22,
125 identity: Some(PathBuf::from("/home/op/.ssh/id_ed25519")),
126 triple: "aarch64-unknown-linux-musl".to_string(),
127 install_dir: "/home/ark/git-ark".to_string(),
128 recipient: "age1abc".to_string(),
129 bucket: "b".to_string(),
130 region: "us-east-1".to_string(),
131 prefix: "git-ark".to_string(),
132 endpoint: None,
133 mirror: false,
134 push_alias: None,
135 }
136 }
137
138 #[test]
139 fn push_alias_defaults_to_convention_and_honors_override() {
140 let mut h = host("nas");
141 assert_eq!(h.push_alias(), "git-ark-nas"); h.push_alias = Some("git-ark".to_string());
143 assert_eq!(h.push_alias(), "git-ark"); }
145
146 #[test]
147 fn hosts_toml_without_push_alias_loads_as_none() {
148 let dir = tempfile::tempdir().unwrap();
150 let path = dir.path().join("hosts.toml");
151 std::fs::write(
152 &path,
153 "[[hosts]]\nname = \"old\"\nssh_target = \"ark@old\"\nport = 22\n\
154 triple = \"x86_64-unknown-linux-musl\"\ninstall_dir = \"/home/ark/git-ark\"\n",
155 )
156 .unwrap();
157 let loaded = Registry::load(&path).unwrap();
158 assert_eq!(loaded.list()[0].push_alias, None);
159 assert_eq!(loaded.list()[0].push_alias(), "git-ark-old");
160 }
161
162 #[test]
163 fn round_trip_through_save_and_load() {
164 let dir = tempfile::tempdir().unwrap();
165 let path = dir.path().join("hosts.toml");
166
167 let mut registry = Registry::default();
168 registry.upsert(host("zeta"));
169 registry.upsert(host("alpha"));
170 registry.save(&path).unwrap();
171
172 let loaded = Registry::load(&path).unwrap();
173 let mut expected = Registry::default();
175 expected.upsert(host("alpha"));
176 expected.upsert(host("zeta"));
177 assert_eq!(loaded, expected);
178 }
179
180 #[test]
181 fn upsert_replaces_by_name_latest_wins() {
182 let mut registry = Registry::default();
183 registry.upsert(host("box1"));
184
185 let mut updated = host("box1");
186 updated.port = 2222;
187 updated.triple = "x86_64-unknown-linux-musl".to_string();
188 registry.upsert(updated.clone());
189
190 assert_eq!(registry.list().len(), 1);
191 assert_eq!(registry.list()[0], updated);
192 }
193
194 #[test]
195 fn remove_returns_true_then_false() {
196 let mut registry = Registry::default();
197 registry.upsert(host("box1"));
198
199 assert!(registry.remove("box1"));
200 assert!(!registry.remove("box1"));
201 assert!(registry.list().is_empty());
202 }
203
204 #[test]
205 fn round_trip_preserves_config_fields_and_mirror_flag() {
206 let dir = tempfile::tempdir().unwrap();
207 let path = dir.path().join("hosts.toml");
208
209 let mut h = host("ec2");
210 h.endpoint = Some("http://minio:9000".to_string());
211 h.mirror = true;
212
213 let mut registry = Registry::default();
214 registry.upsert(h.clone());
215 registry.save(&path).unwrap();
216
217 let loaded = Registry::load(&path).unwrap();
218 assert_eq!(loaded.list()[0], h);
219 }
220
221 #[test]
222 fn mirror_host_finds_the_sole_mirror() {
223 let mut registry = Registry::default();
224 registry.upsert(host("nas"));
225 let mut ec2 = host("ec2");
226 ec2.mirror = true;
227 registry.upsert(ec2.clone());
228
229 assert_eq!(registry.mirror_host(), Some(&ec2));
230 }
231
232 #[test]
233 fn mirror_host_is_none_when_no_host_is_designated() {
234 let mut registry = Registry::default();
235 registry.upsert(host("nas"));
236 registry.upsert(host("ec2"));
237
238 assert_eq!(registry.mirror_host(), None);
239 }
240
241 #[test]
242 fn missing_file_loads_empty_registry() {
243 let dir = tempfile::tempdir().unwrap();
244 let path = dir.path().join("does-not-exist").join("hosts.toml");
245
246 let registry = Registry::load(&path).unwrap();
247 assert!(registry.list().is_empty());
248 }
249
250 #[test]
251 fn save_writes_hosts_sorted_by_name() {
252 let dir = tempfile::tempdir().unwrap();
253 let path = dir.path().join("hosts.toml");
254
255 let mut registry = Registry::default();
256 registry.upsert(host("zeta"));
257 registry.upsert(host("alpha"));
258 registry.upsert(host("mid"));
259 registry.save(&path).unwrap();
260
261 let text = std::fs::read_to_string(&path).unwrap();
262 let alpha_pos = text.find("name = \"alpha\"").unwrap();
263 let mid_pos = text.find("name = \"mid\"").unwrap();
264 let zeta_pos = text.find("name = \"zeta\"").unwrap();
265 assert!(alpha_pos < mid_pos, "alpha should precede mid");
266 assert!(mid_pos < zeta_pos, "mid should precede zeta");
267
268 assert_eq!(registry.list()[0].name, "zeta");
270 }
271}