opencode_cloud_core/host/
schema.rs1use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10#[serde(deny_unknown_fields)]
11pub struct HostConfig {
12 pub hostname: String,
14
15 #[serde(default = "default_user")]
17 pub user: String,
18
19 #[serde(default)]
21 pub port: Option<u16>,
22
23 #[serde(default)]
25 pub identity_file: Option<String>,
26
27 #[serde(default)]
29 pub jump_host: Option<String>,
30
31 #[serde(default)]
33 pub groups: Vec<String>,
34
35 #[serde(default)]
37 pub description: Option<String>,
38}
39
40fn default_user() -> String {
41 whoami::username()
42}
43
44impl Default for HostConfig {
45 fn default() -> Self {
46 Self {
47 hostname: String::new(),
48 user: default_user(),
49 port: None,
50 identity_file: None,
51 jump_host: None,
52 groups: Vec::new(),
53 description: None,
54 }
55 }
56}
57
58impl HostConfig {
59 pub fn new(hostname: impl Into<String>) -> Self {
61 Self {
62 hostname: hostname.into(),
63 ..Default::default()
64 }
65 }
66
67 pub fn with_user(mut self, user: impl Into<String>) -> Self {
69 self.user = user.into();
70 self
71 }
72
73 pub fn with_port(mut self, port: u16) -> Self {
75 self.port = Some(port);
76 self
77 }
78
79 pub fn with_identity_file(mut self, path: impl Into<String>) -> Self {
81 self.identity_file = Some(path.into());
82 self
83 }
84
85 pub fn with_jump_host(mut self, jump: impl Into<String>) -> Self {
87 self.jump_host = Some(jump.into());
88 self
89 }
90
91 pub fn with_group(mut self, group: impl Into<String>) -> Self {
93 self.groups.push(group.into());
94 self
95 }
96
97 pub fn with_description(mut self, desc: impl Into<String>) -> Self {
99 self.description = Some(desc.into());
100 self
101 }
102
103 pub fn ssh_args(&self) -> Vec<String> {
108 let mut args = Vec::new();
109
110 if let Some(port) = self.port {
112 args.push("-p".to_string());
113 args.push(port.to_string());
114 }
115
116 if let Some(key) = &self.identity_file {
118 args.push("-i".to_string());
119 args.push(key.clone());
120 }
121
122 if let Some(jump) = &self.jump_host {
124 args.push("-J".to_string());
125 args.push(jump.clone());
126 }
127
128 args.push(format!("{}@{}", self.user, self.hostname));
130
131 args
132 }
133
134 pub fn format_ssh_command(&self) -> String {
139 let mut parts = vec!["ssh".to_string()];
140
141 if let Some(port) = self.port {
143 if port != 22 {
144 parts.push(format!("-p {port}"));
145 }
146 }
147
148 if let Some(key) = &self.identity_file {
150 parts.push(format!("-i {key}"));
151 }
152
153 if let Some(jump) = &self.jump_host {
155 parts.push(format!("-J {jump}"));
156 }
157
158 parts.push(format!("{}@{}", self.user, self.hostname));
160
161 parts.join(" ")
162 }
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
167#[serde(deny_unknown_fields)]
168pub struct HostsFile {
169 #[serde(default = "default_version")]
171 pub version: u32,
172
173 #[serde(default)]
175 pub default_host: Option<String>,
176
177 #[serde(default)]
179 pub hosts: HashMap<String, HostConfig>,
180}
181
182fn default_version() -> u32 {
183 1
184}
185
186impl HostsFile {
187 pub fn new() -> Self {
189 Self::default()
190 }
191
192 pub fn add_host(&mut self, name: impl Into<String>, config: HostConfig) {
194 self.hosts.insert(name.into(), config);
195 }
196
197 pub fn remove_host(&mut self, name: &str) -> Option<HostConfig> {
199 if self.default_host.as_deref() == Some(name) {
201 self.default_host = None;
202 }
203 self.hosts.remove(name)
204 }
205
206 pub fn get_host(&self, name: &str) -> Option<&HostConfig> {
208 self.hosts.get(name)
209 }
210
211 pub fn get_host_mut(&mut self, name: &str) -> Option<&mut HostConfig> {
213 self.hosts.get_mut(name)
214 }
215
216 pub fn has_host(&self, name: &str) -> bool {
218 self.hosts.contains_key(name)
219 }
220
221 pub fn set_default(&mut self, name: Option<String>) {
223 self.default_host = name;
224 }
225
226 pub fn host_names(&self) -> Vec<&str> {
228 self.hosts.keys().map(|s| s.as_str()).collect()
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 #[test]
237 fn test_host_config_defaults() {
238 let config = HostConfig::default();
239 assert!(config.hostname.is_empty());
240 assert!(!config.user.is_empty()); assert!(config.port.is_none());
242 assert!(config.identity_file.is_none());
243 assert!(config.jump_host.is_none());
244 assert!(config.groups.is_empty());
245 assert!(config.description.is_none());
246 }
247
248 #[test]
249 fn test_host_config_builder() {
250 let config = HostConfig::new("example.com")
251 .with_user("admin")
252 .with_port(2222)
253 .with_identity_file("~/.ssh/prod_key")
254 .with_group("production");
255
256 assert_eq!(config.hostname, "example.com");
257 assert_eq!(config.user, "admin");
258 assert_eq!(config.port, Some(2222));
259 assert_eq!(config.identity_file, Some("~/.ssh/prod_key".to_string()));
260 assert_eq!(config.groups, vec!["production"]);
261 }
262
263 #[test]
264 fn test_hosts_file_operations() {
265 let mut hosts = HostsFile::new();
266 assert!(hosts.hosts.is_empty());
267
268 hosts.add_host("prod-1", HostConfig::new("prod1.example.com"));
270 assert!(hosts.has_host("prod-1"));
271 assert!(!hosts.has_host("prod-2"));
272
273 hosts.set_default(Some("prod-1".to_string()));
275 assert_eq!(hosts.default_host, Some("prod-1".to_string()));
276
277 hosts.remove_host("prod-1");
279 assert!(!hosts.has_host("prod-1"));
280 assert!(hosts.default_host.is_none());
281 }
282
283 #[test]
284 fn test_serialize_deserialize() {
285 let mut hosts = HostsFile::new();
286 hosts.add_host(
287 "test",
288 HostConfig::new("test.example.com")
289 .with_user("testuser")
290 .with_port(22),
291 );
292
293 let json = serde_json::to_string_pretty(&hosts).unwrap();
294 let parsed: HostsFile = serde_json::from_str(&json).unwrap();
295
296 assert_eq!(hosts, parsed);
297 }
298
299 #[test]
300 fn test_deserialize_minimal() {
301 let json = r#"{"version": 1}"#;
303 let hosts: HostsFile = serde_json::from_str(json).unwrap();
304 assert_eq!(hosts.version, 1);
305 assert!(hosts.hosts.is_empty());
306 assert!(hosts.default_host.is_none());
307 }
308}