@opencode-cloud/core 1.0.7 → 1.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,394 @@
1
+ //! Remote host provisioning
2
+ //!
3
+ //! Functions to detect Linux distribution and install Docker on remote hosts.
4
+
5
+ use std::process::{Command, Stdio};
6
+
7
+ use super::error::HostError;
8
+ use super::schema::HostConfig;
9
+
10
+ /// Linux distribution family
11
+ #[derive(Debug, Clone, PartialEq, Eq)]
12
+ pub enum DistroFamily {
13
+ /// Debian, Ubuntu, and derivatives (apt-based)
14
+ Debian,
15
+ /// RHEL, CentOS, Fedora, Amazon Linux (dnf/yum-based)
16
+ RedHat,
17
+ /// Alpine Linux (apk-based)
18
+ Alpine,
19
+ /// Arch Linux (pacman-based)
20
+ Arch,
21
+ /// SUSE/openSUSE (zypper-based)
22
+ Suse,
23
+ /// Unknown distribution
24
+ Unknown(String),
25
+ }
26
+
27
+ impl std::fmt::Display for DistroFamily {
28
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29
+ match self {
30
+ DistroFamily::Debian => write!(f, "Debian/Ubuntu"),
31
+ DistroFamily::RedHat => write!(f, "RHEL/Amazon Linux"),
32
+ DistroFamily::Alpine => write!(f, "Alpine"),
33
+ DistroFamily::Arch => write!(f, "Arch"),
34
+ DistroFamily::Suse => write!(f, "SUSE"),
35
+ DistroFamily::Unknown(id) => write!(f, "Unknown ({id})"),
36
+ }
37
+ }
38
+ }
39
+
40
+ /// Detected distribution information
41
+ #[derive(Debug, Clone)]
42
+ pub struct DistroInfo {
43
+ /// Distribution family (Debian, RedHat, etc.)
44
+ pub family: DistroFamily,
45
+ /// Distribution ID (e.g., "ubuntu", "amzn", "debian")
46
+ pub id: String,
47
+ /// Pretty name (e.g., "Ubuntu 22.04.3 LTS")
48
+ pub pretty_name: String,
49
+ /// Version ID (e.g., "22.04", "2023")
50
+ pub version_id: Option<String>,
51
+ }
52
+
53
+ /// Detect the Linux distribution on a remote host
54
+ ///
55
+ /// Runs `cat /etc/os-release` via SSH to parse distribution info.
56
+ pub fn detect_distro(host: &HostConfig) -> Result<DistroInfo, HostError> {
57
+ let output = run_ssh_command(host, "cat /etc/os-release")?;
58
+
59
+ parse_os_release(&output)
60
+ }
61
+
62
+ /// Parse /etc/os-release content into DistroInfo
63
+ fn parse_os_release(content: &str) -> Result<DistroInfo, HostError> {
64
+ let mut id = String::new();
65
+ let mut id_like = String::new();
66
+ let mut pretty_name = String::new();
67
+ let mut version_id = None;
68
+
69
+ for line in content.lines() {
70
+ if let Some((key, value)) = line.split_once('=') {
71
+ let value = value.trim_matches('"');
72
+ match key {
73
+ "ID" => id = value.to_lowercase(),
74
+ "ID_LIKE" => id_like = value.to_lowercase(),
75
+ "PRETTY_NAME" => pretty_name = value.to_string(),
76
+ "VERSION_ID" => version_id = Some(value.to_string()),
77
+ _ => {}
78
+ }
79
+ }
80
+ }
81
+
82
+ if id.is_empty() {
83
+ return Err(HostError::ConnectionFailed(
84
+ "Could not detect Linux distribution".to_string(),
85
+ ));
86
+ }
87
+
88
+ // Determine distribution family
89
+ let family = match id.as_str() {
90
+ "ubuntu" | "debian" | "linuxmint" | "pop" | "elementary" | "raspbian" => {
91
+ DistroFamily::Debian
92
+ }
93
+ "amzn" | "rhel" | "centos" | "fedora" | "rocky" | "almalinux" | "ol" => {
94
+ DistroFamily::RedHat
95
+ }
96
+ "alpine" => DistroFamily::Alpine,
97
+ "arch" | "manjaro" | "endeavouros" => DistroFamily::Arch,
98
+ "opensuse" | "sles" | "opensuse-leap" | "opensuse-tumbleweed" => DistroFamily::Suse,
99
+ _ => {
100
+ // Check ID_LIKE for derivatives
101
+ if id_like.contains("debian") || id_like.contains("ubuntu") {
102
+ DistroFamily::Debian
103
+ } else if id_like.contains("rhel")
104
+ || id_like.contains("fedora")
105
+ || id_like.contains("centos")
106
+ {
107
+ DistroFamily::RedHat
108
+ } else if id_like.contains("arch") {
109
+ DistroFamily::Arch
110
+ } else if id_like.contains("suse") {
111
+ DistroFamily::Suse
112
+ } else {
113
+ DistroFamily::Unknown(id.clone())
114
+ }
115
+ }
116
+ };
117
+
118
+ Ok(DistroInfo {
119
+ family,
120
+ id,
121
+ pretty_name,
122
+ version_id,
123
+ })
124
+ }
125
+
126
+ /// Install Docker on a remote host
127
+ ///
128
+ /// Returns a vector of commands that will be executed (for user review).
129
+ pub fn get_docker_install_commands(distro: &DistroInfo) -> Result<Vec<&'static str>, HostError> {
130
+ match &distro.family {
131
+ DistroFamily::Debian => Ok(vec![
132
+ // Update package index
133
+ "sudo apt-get update",
134
+ // Install prerequisites
135
+ "sudo apt-get install -y ca-certificates curl gnupg",
136
+ // Add Docker's official GPG key
137
+ "sudo install -m 0755 -d /etc/apt/keyrings",
138
+ "curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo \"$ID\")/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg",
139
+ "sudo chmod a+r /etc/apt/keyrings/docker.gpg",
140
+ // Set up the repository
141
+ "echo \"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/$(. /etc/os-release && echo \"$ID\") $(. /etc/os-release && echo \"$VERSION_CODENAME\") stable\" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null",
142
+ // Install Docker
143
+ "sudo apt-get update",
144
+ "sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin",
145
+ // Start Docker
146
+ "sudo systemctl enable docker",
147
+ "sudo systemctl start docker",
148
+ // Add current user to docker group
149
+ "sudo usermod -aG docker $USER",
150
+ ]),
151
+
152
+ DistroFamily::RedHat => {
153
+ // Amazon Linux 2023 uses dnf, Amazon Linux 2 uses yum
154
+ // We'll use a command that works for both
155
+ Ok(vec![
156
+ // Install Docker (Amazon Linux uses amazon-linux-extras or dnf)
157
+ "sudo yum install -y docker || sudo dnf install -y docker",
158
+ // Start Docker
159
+ "sudo systemctl enable docker",
160
+ "sudo systemctl start docker",
161
+ // Add current user to docker group
162
+ "sudo usermod -aG docker $USER",
163
+ ])
164
+ }
165
+
166
+ DistroFamily::Alpine => Ok(vec![
167
+ "sudo apk add docker docker-cli-compose",
168
+ "sudo rc-update add docker boot",
169
+ "sudo service docker start",
170
+ "sudo addgroup $USER docker",
171
+ ]),
172
+
173
+ DistroFamily::Arch => Ok(vec![
174
+ "sudo pacman -Sy --noconfirm docker docker-compose",
175
+ "sudo systemctl enable docker",
176
+ "sudo systemctl start docker",
177
+ "sudo usermod -aG docker $USER",
178
+ ]),
179
+
180
+ DistroFamily::Suse => Ok(vec![
181
+ "sudo zypper install -y docker docker-compose",
182
+ "sudo systemctl enable docker",
183
+ "sudo systemctl start docker",
184
+ "sudo usermod -aG docker $USER",
185
+ ]),
186
+
187
+ DistroFamily::Unknown(id) => Err(HostError::ConnectionFailed(format!(
188
+ "Unsupported Linux distribution: {id}. Please install Docker manually."
189
+ ))),
190
+ }
191
+ }
192
+
193
+ /// Execute Docker installation on remote host
194
+ ///
195
+ /// Runs the installation commands via SSH and captures output.
196
+ pub fn install_docker(
197
+ host: &HostConfig,
198
+ distro: &DistroInfo,
199
+ on_output: impl Fn(&str),
200
+ ) -> Result<(), HostError> {
201
+ let commands = get_docker_install_commands(distro)?;
202
+
203
+ // Combine all commands with && to fail fast
204
+ let combined = commands.join(" && ");
205
+
206
+ on_output(&format!("Installing Docker on {} host...", distro.family));
207
+
208
+ // Run the installation
209
+ run_ssh_command_with_output(host, &combined, on_output)?;
210
+
211
+ Ok(())
212
+ }
213
+
214
+ /// Run a command on remote host via SSH and return output
215
+ fn run_ssh_command(host: &HostConfig, command: &str) -> Result<String, HostError> {
216
+ let mut cmd = build_ssh_command(host);
217
+ cmd.arg(command);
218
+
219
+ cmd.stdin(Stdio::null())
220
+ .stdout(Stdio::piped())
221
+ .stderr(Stdio::piped());
222
+
223
+ let output = cmd.output().map_err(|e| {
224
+ if e.kind() == std::io::ErrorKind::NotFound {
225
+ HostError::SshSpawn("SSH not found. Install OpenSSH client.".to_string())
226
+ } else {
227
+ HostError::SshSpawn(e.to_string())
228
+ }
229
+ })?;
230
+
231
+ if output.status.success() {
232
+ Ok(String::from_utf8_lossy(&output.stdout).to_string())
233
+ } else {
234
+ let stderr = String::from_utf8_lossy(&output.stderr);
235
+ Err(HostError::ConnectionFailed(stderr.to_string()))
236
+ }
237
+ }
238
+
239
+ /// Run a command on remote host via SSH with streaming output
240
+ fn run_ssh_command_with_output(
241
+ host: &HostConfig,
242
+ command: &str,
243
+ on_output: impl Fn(&str),
244
+ ) -> Result<(), HostError> {
245
+ use std::io::{BufRead, BufReader};
246
+
247
+ let mut cmd = build_ssh_command(host);
248
+
249
+ // Request a pseudo-terminal for interactive commands (like sudo)
250
+ cmd.arg("-t").arg("-t");
251
+ cmd.arg(command);
252
+
253
+ cmd.stdin(Stdio::null())
254
+ .stdout(Stdio::piped())
255
+ .stderr(Stdio::piped());
256
+
257
+ let mut child = cmd.spawn().map_err(|e| {
258
+ if e.kind() == std::io::ErrorKind::NotFound {
259
+ HostError::SshSpawn("SSH not found. Install OpenSSH client.".to_string())
260
+ } else {
261
+ HostError::SshSpawn(e.to_string())
262
+ }
263
+ })?;
264
+
265
+ // Stream stdout
266
+ if let Some(stdout) = child.stdout.take() {
267
+ let reader = BufReader::new(stdout);
268
+ for line in reader.lines().map_while(Result::ok) {
269
+ on_output(&line);
270
+ }
271
+ }
272
+
273
+ let status = child
274
+ .wait()
275
+ .map_err(|e| HostError::SshSpawn(e.to_string()))?;
276
+
277
+ if status.success() {
278
+ Ok(())
279
+ } else {
280
+ Err(HostError::ConnectionFailed(
281
+ "Docker installation failed".to_string(),
282
+ ))
283
+ }
284
+ }
285
+
286
+ /// Build base SSH command with host config
287
+ fn build_ssh_command(host: &HostConfig) -> Command {
288
+ let mut cmd = Command::new("ssh");
289
+
290
+ // Standard options
291
+ cmd.arg("-o")
292
+ .arg("BatchMode=yes")
293
+ .arg("-o")
294
+ .arg("ConnectTimeout=30")
295
+ .arg("-o")
296
+ .arg("StrictHostKeyChecking=accept-new");
297
+
298
+ // Host-specific options (port, identity, jump, user@host)
299
+ cmd.args(host.ssh_args());
300
+
301
+ cmd
302
+ }
303
+
304
+ /// Verify Docker is working after installation
305
+ ///
306
+ /// Note: Due to group membership changes, this may fail until the user
307
+ /// reconnects. We run with sudo as a fallback.
308
+ pub fn verify_docker_installed(host: &HostConfig) -> Result<String, HostError> {
309
+ // Try without sudo first (if group membership is active)
310
+ let output = run_ssh_command(
311
+ host,
312
+ "docker version --format '{{.Server.Version}}' 2>/dev/null || sudo docker version --format '{{.Server.Version}}'",
313
+ );
314
+
315
+ match output {
316
+ Ok(version) => Ok(version.trim().to_string()),
317
+ Err(_) => Err(HostError::RemoteDockerUnavailable(
318
+ "Docker installed but not accessible. You may need to reconnect for group membership to take effect.".to_string(),
319
+ )),
320
+ }
321
+ }
322
+
323
+ #[cfg(test)]
324
+ mod tests {
325
+ use super::*;
326
+
327
+ #[test]
328
+ fn test_parse_os_release_ubuntu() {
329
+ let content = r#"
330
+ PRETTY_NAME="Ubuntu 22.04.3 LTS"
331
+ NAME="Ubuntu"
332
+ VERSION_ID="22.04"
333
+ VERSION="22.04.3 LTS (Jammy Jellyfish)"
334
+ VERSION_CODENAME=jammy
335
+ ID=ubuntu
336
+ ID_LIKE=debian
337
+ "#;
338
+ let info = parse_os_release(content).unwrap();
339
+ assert_eq!(info.family, DistroFamily::Debian);
340
+ assert_eq!(info.id, "ubuntu");
341
+ assert_eq!(info.version_id, Some("22.04".to_string()));
342
+ }
343
+
344
+ #[test]
345
+ fn test_parse_os_release_amazon_linux() {
346
+ let content = r#"
347
+ NAME="Amazon Linux"
348
+ VERSION="2023"
349
+ ID="amzn"
350
+ ID_LIKE="fedora"
351
+ VERSION_ID="2023"
352
+ PRETTY_NAME="Amazon Linux 2023"
353
+ "#;
354
+ let info = parse_os_release(content).unwrap();
355
+ assert_eq!(info.family, DistroFamily::RedHat);
356
+ assert_eq!(info.id, "amzn");
357
+ }
358
+
359
+ #[test]
360
+ fn test_parse_os_release_debian() {
361
+ let content = r#"
362
+ PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
363
+ NAME="Debian GNU/Linux"
364
+ VERSION_ID="12"
365
+ VERSION="12 (bookworm)"
366
+ ID=debian
367
+ "#;
368
+ let info = parse_os_release(content).unwrap();
369
+ assert_eq!(info.family, DistroFamily::Debian);
370
+ assert_eq!(info.id, "debian");
371
+ }
372
+
373
+ #[test]
374
+ fn test_get_docker_install_commands() {
375
+ let debian_info = DistroInfo {
376
+ family: DistroFamily::Debian,
377
+ id: "ubuntu".to_string(),
378
+ pretty_name: "Ubuntu 22.04".to_string(),
379
+ version_id: Some("22.04".to_string()),
380
+ };
381
+ let commands = get_docker_install_commands(&debian_info).unwrap();
382
+ assert!(!commands.is_empty());
383
+ assert!(commands.iter().any(|c| c.contains("docker")));
384
+
385
+ let redhat_info = DistroInfo {
386
+ family: DistroFamily::RedHat,
387
+ id: "amzn".to_string(),
388
+ pretty_name: "Amazon Linux 2023".to_string(),
389
+ version_id: Some("2023".to_string()),
390
+ };
391
+ let commands = get_docker_install_commands(&redhat_info).unwrap();
392
+ assert!(!commands.is_empty());
393
+ }
394
+ }
@@ -0,0 +1,308 @@
1
+ //! Host configuration schema
2
+ //!
3
+ //! Data structures for storing remote host configurations.
4
+
5
+ use serde::{Deserialize, Serialize};
6
+ use std::collections::HashMap;
7
+
8
+ /// Configuration for a remote host
9
+ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10
+ #[serde(deny_unknown_fields)]
11
+ pub 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
+
40
+ fn default_user() -> String {
41
+ whoami::username()
42
+ }
43
+
44
+ impl 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
+
58
+ impl 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)]
168
+ pub 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
+
182
+ fn default_version() -> u32 {
183
+ 1
184
+ }
185
+
186
+ impl 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)]
233
+ mod 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
+ }