Skip to main content

opencode_cloud_core/host/
schema.rs

1//! Host configuration schema
2//!
3//! Data structures for storing remote host configurations.
4
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7
8/// Configuration for a remote host
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10#[serde(deny_unknown_fields)]
11pub struct HostConfig {
12    /// SSH hostname or IP address
13    pub hostname: String,
14
15    /// SSH username (default: current user from whoami)
16    #[serde(default = "default_user")]
17    pub user: String,
18
19    /// SSH port (default: 22)
20    #[serde(default)]
21    pub port: Option<u16>,
22
23    /// Path to SSH identity file (private key)
24    #[serde(default)]
25    pub identity_file: Option<String>,
26
27    /// Jump host for ProxyJump (user@host:port format)
28    #[serde(default)]
29    pub jump_host: Option<String>,
30
31    /// Organization groups/tags for this host
32    #[serde(default)]
33    pub groups: Vec<String>,
34
35    /// Optional description
36    #[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    /// Create a new host config with just hostname
60    pub fn new(hostname: impl Into<String>) -> Self {
61        Self {
62            hostname: hostname.into(),
63            ..Default::default()
64        }
65    }
66
67    /// Builder pattern: set user
68    pub fn with_user(mut self, user: impl Into<String>) -> Self {
69        self.user = user.into();
70        self
71    }
72
73    /// Builder pattern: set port
74    pub fn with_port(mut self, port: u16) -> Self {
75        self.port = Some(port);
76        self
77    }
78
79    /// Builder pattern: set identity file
80    pub fn with_identity_file(mut self, path: impl Into<String>) -> Self {
81        self.identity_file = Some(path.into());
82        self
83    }
84
85    /// Builder pattern: set jump host
86    pub fn with_jump_host(mut self, jump: impl Into<String>) -> Self {
87        self.jump_host = Some(jump.into());
88        self
89    }
90
91    /// Builder pattern: add group
92    pub fn with_group(mut self, group: impl Into<String>) -> Self {
93        self.groups.push(group.into());
94        self
95    }
96
97    /// Builder pattern: set description
98    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
99        self.description = Some(desc.into());
100        self
101    }
102
103    /// Get SSH command arguments for this host
104    ///
105    /// Returns arguments for port, identity file, jump host, and target (user@hostname).
106    /// Does NOT include standard options like BatchMode or ConnectTimeout.
107    pub fn ssh_args(&self) -> Vec<String> {
108        let mut args = Vec::new();
109
110        // Port (if specified)
111        if let Some(port) = self.port {
112            args.push("-p".to_string());
113            args.push(port.to_string());
114        }
115
116        // Identity file
117        if let Some(key) = &self.identity_file {
118            args.push("-i".to_string());
119            args.push(key.clone());
120        }
121
122        // Jump host
123        if let Some(jump) = &self.jump_host {
124            args.push("-J".to_string());
125            args.push(jump.clone());
126        }
127
128        // Target: user@hostname
129        args.push(format!("{}@{}", self.user, self.hostname));
130
131        args
132    }
133
134    /// Format the effective SSH command for display
135    ///
136    /// Returns a human-readable SSH command string showing how the connection
137    /// will be made, useful for debugging and user feedback.
138    pub fn format_ssh_command(&self) -> String {
139        let mut parts = vec!["ssh".to_string()];
140
141        // Port (if non-default)
142        if let Some(port) = self.port {
143            if port != 22 {
144                parts.push(format!("-p {port}"));
145            }
146        }
147
148        // Identity file
149        if let Some(key) = &self.identity_file {
150            parts.push(format!("-i {key}"));
151        }
152
153        // Jump host
154        if let Some(jump) = &self.jump_host {
155            parts.push(format!("-J {jump}"));
156        }
157
158        // Target: user@hostname
159        parts.push(format!("{}@{}", self.user, self.hostname));
160
161        parts.join(" ")
162    }
163}
164
165/// Root structure for hosts.json file
166#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
167#[serde(deny_unknown_fields)]
168pub struct HostsFile {
169    /// Schema version for future migrations
170    #[serde(default = "default_version")]
171    pub version: u32,
172
173    /// Default host name (None = local Docker)
174    #[serde(default)]
175    pub default_host: Option<String>,
176
177    /// Map of host name to configuration
178    #[serde(default)]
179    pub hosts: HashMap<String, HostConfig>,
180}
181
182fn default_version() -> u32 {
183    1
184}
185
186impl HostsFile {
187    /// Create empty hosts file
188    pub fn new() -> Self {
189        Self::default()
190    }
191
192    /// Add a host
193    pub fn add_host(&mut self, name: impl Into<String>, config: HostConfig) {
194        self.hosts.insert(name.into(), config);
195    }
196
197    /// Remove a host
198    pub fn remove_host(&mut self, name: &str) -> Option<HostConfig> {
199        // Clear default if removing the default host
200        if self.default_host.as_deref() == Some(name) {
201            self.default_host = None;
202        }
203        self.hosts.remove(name)
204    }
205
206    /// Get a host by name
207    pub fn get_host(&self, name: &str) -> Option<&HostConfig> {
208        self.hosts.get(name)
209    }
210
211    /// Get mutable reference to a host
212    pub fn get_host_mut(&mut self, name: &str) -> Option<&mut HostConfig> {
213        self.hosts.get_mut(name)
214    }
215
216    /// Check if host exists
217    pub fn has_host(&self, name: &str) -> bool {
218        self.hosts.contains_key(name)
219    }
220
221    /// Set the default host
222    pub fn set_default(&mut self, name: Option<String>) {
223        self.default_host = name;
224    }
225
226    /// Get list of host names
227    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()); // Should be current user
241        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        // Add host
269        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        // Set default
274        hosts.set_default(Some("prod-1".to_string()));
275        assert_eq!(hosts.default_host, Some("prod-1".to_string()));
276
277        // Remove host clears default
278        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        // Minimal JSON should work with defaults
302        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}