docker_wrapper/command/
version.rs

1//! Docker version command implementation
2//!
3//! This module provides functionality to retrieve Docker version information,
4//! including client and server versions, API versions, and build details.
5
6use super::{CommandExecutor, CommandOutput, DockerCommand};
7use crate::error::{Error, Result};
8use async_trait::async_trait;
9use std::fmt;
10
11/// Command for retrieving Docker version information
12///
13/// The `VersionCommand` provides a builder pattern for constructing Docker version commands
14/// with various output format options.
15///
16/// # Examples
17///
18/// ```rust
19/// use docker_wrapper::VersionCommand;
20///
21/// // Basic version info
22/// let version = VersionCommand::new();
23///
24/// // JSON format output
25/// let version = VersionCommand::new().format_json();
26///
27/// // Custom format
28/// let version = VersionCommand::new()
29///     .format("{{.Client.Version}}");
30/// ```
31#[derive(Debug, Clone)]
32pub struct VersionCommand {
33    /// Output format
34    format: Option<String>,
35    /// Command executor for running the command
36    pub executor: CommandExecutor,
37}
38
39/// Docker client version information
40#[derive(Debug, Clone, PartialEq)]
41pub struct ClientVersion {
42    /// Client version string
43    pub version: String,
44    /// API version
45    pub api_version: String,
46    /// Git commit
47    pub git_commit: String,
48    /// Build time
49    pub built: String,
50    /// Go version used to build
51    pub go_version: String,
52    /// Operating system
53    pub os: String,
54    /// Architecture
55    pub arch: String,
56}
57
58/// Docker server version information
59#[derive(Debug, Clone, PartialEq)]
60pub struct ServerVersion {
61    /// Server version string
62    pub version: String,
63    /// API version
64    pub api_version: String,
65    /// Minimum API version supported
66    pub min_api_version: String,
67    /// Git commit
68    pub git_commit: String,
69    /// Build time
70    pub built: String,
71    /// Go version used to build
72    pub go_version: String,
73    /// Operating system
74    pub os: String,
75    /// Architecture
76    pub arch: String,
77    /// Kernel version
78    pub kernel_version: String,
79    /// Experimental features enabled
80    pub experimental: bool,
81}
82
83/// Complete Docker version information
84#[derive(Debug, Clone, PartialEq)]
85pub struct VersionInfo {
86    /// Client version details
87    pub client: ClientVersion,
88    /// Server version details (if available)
89    pub server: Option<ServerVersion>,
90}
91
92/// Output from a version command execution
93///
94/// Contains the raw output from the Docker version command and provides
95/// convenience methods for parsing version information.
96#[derive(Debug, Clone)]
97pub struct VersionOutput {
98    /// Raw output from the Docker command
99    pub output: CommandOutput,
100    /// Parsed version information
101    pub version_info: Option<VersionInfo>,
102}
103
104impl VersionCommand {
105    /// Creates a new version command
106    ///
107    /// # Examples
108    ///
109    /// ```rust
110    /// use docker_wrapper::VersionCommand;
111    ///
112    /// let version = VersionCommand::new();
113    /// ```
114    #[must_use]
115    pub fn new() -> Self {
116        Self {
117            format: None,
118            executor: CommandExecutor::default(),
119        }
120    }
121
122    /// Sets the output format
123    ///
124    /// # Arguments
125    ///
126    /// * `format` - Output format string or template
127    ///
128    /// # Examples
129    ///
130    /// ```rust
131    /// use docker_wrapper::VersionCommand;
132    ///
133    /// let version = VersionCommand::new()
134    ///     .format("{{.Client.Version}}");
135    /// ```
136    #[must_use]
137    pub fn format(mut self, format: impl Into<String>) -> Self {
138        self.format = Some(format.into());
139        self
140    }
141
142    /// Sets output format to JSON
143    ///
144    /// # Examples
145    ///
146    /// ```rust
147    /// use docker_wrapper::VersionCommand;
148    ///
149    /// let version = VersionCommand::new().format_json();
150    /// ```
151    #[must_use]
152    pub fn format_json(self) -> Self {
153        self.format("json")
154    }
155
156    /// Sets output format to table (default)
157    #[must_use]
158    pub fn format_table(self) -> Self {
159        Self {
160            format: None,
161            executor: self.executor,
162        }
163    }
164
165    /// Gets a reference to the executor
166    #[must_use]
167    pub fn get_executor(&self) -> &CommandExecutor {
168        &self.executor
169    }
170
171    /// Gets a mutable reference to the executor
172    pub fn get_executor_mut(&mut self) -> &mut CommandExecutor {
173        &mut self.executor
174    }
175
176    /// Builds the command arguments for Docker version
177    #[must_use]
178    pub fn build_command_args(&self) -> Vec<String> {
179        let mut args = vec!["version".to_string()];
180
181        // Add format option
182        if let Some(ref format) = self.format {
183            args.push("--format".to_string());
184            args.push(format.clone());
185        }
186
187        // Add any additional raw arguments
188        args.extend(self.executor.raw_args.clone());
189
190        args
191    }
192
193    /// Parses the version output
194    fn parse_output(&self, output: &CommandOutput) -> Result<Option<VersionInfo>> {
195        if let Some(ref format) = self.format {
196            if format == "json" {
197                return Self::parse_json_output(output);
198            }
199        }
200
201        Ok(Self::parse_table_output(output))
202    }
203
204    /// Parses JSON formatted version output
205    fn parse_json_output(output: &CommandOutput) -> Result<Option<VersionInfo>> {
206        let parsed: serde_json::Value = serde_json::from_str(&output.stdout)
207            .map_err(|e| Error::parse_error(format!("Failed to parse version JSON output: {e}")))?;
208
209        // Parse client version
210        let client_data = &parsed["Client"];
211        let client = ClientVersion {
212            version: client_data["Version"].as_str().unwrap_or("").to_string(),
213            api_version: client_data["ApiVersion"].as_str().unwrap_or("").to_string(),
214            git_commit: client_data["GitCommit"].as_str().unwrap_or("").to_string(),
215            built: client_data["Built"].as_str().unwrap_or("").to_string(),
216            go_version: client_data["GoVersion"].as_str().unwrap_or("").to_string(),
217            os: client_data["Os"].as_str().unwrap_or("").to_string(),
218            arch: client_data["Arch"].as_str().unwrap_or("").to_string(),
219        };
220
221        // Parse server version (if available)
222        let server = parsed.get("Server").map(|server_data| ServerVersion {
223            version: server_data["Version"].as_str().unwrap_or("").to_string(),
224            api_version: server_data["ApiVersion"].as_str().unwrap_or("").to_string(),
225            min_api_version: server_data["MinAPIVersion"]
226                .as_str()
227                .unwrap_or("")
228                .to_string(),
229            git_commit: server_data["GitCommit"].as_str().unwrap_or("").to_string(),
230            built: server_data["Built"].as_str().unwrap_or("").to_string(),
231            go_version: server_data["GoVersion"].as_str().unwrap_or("").to_string(),
232            os: server_data["Os"].as_str().unwrap_or("").to_string(),
233            arch: server_data["Arch"].as_str().unwrap_or("").to_string(),
234            kernel_version: server_data["KernelVersion"]
235                .as_str()
236                .unwrap_or("")
237                .to_string(),
238            experimental: server_data["Experimental"].as_bool().unwrap_or(false),
239        });
240
241        Ok(Some(VersionInfo { client, server }))
242    }
243
244    /// Parses table formatted version output
245    fn parse_table_output(output: &CommandOutput) -> Option<VersionInfo> {
246        let lines: Vec<&str> = output.stdout.lines().collect();
247
248        if lines.is_empty() {
249            return None;
250        }
251
252        let mut client_section = false;
253        let mut server_section = false;
254        let mut client_data = std::collections::HashMap::new();
255        let mut server_data = std::collections::HashMap::new();
256
257        for line in lines {
258            let trimmed = line.trim();
259
260            if trimmed.starts_with("Client:") {
261                client_section = true;
262                server_section = false;
263                continue;
264            } else if trimmed.starts_with("Server:") {
265                client_section = false;
266                server_section = true;
267                continue;
268            }
269
270            if trimmed.is_empty() {
271                continue;
272            }
273
274            // Parse key-value pairs
275            if let Some(colon_pos) = trimmed.find(':') {
276                let key = trimmed[..colon_pos].trim();
277                let value = trimmed[colon_pos + 1..].trim();
278
279                if client_section {
280                    client_data.insert(key.to_string(), value.to_string());
281                } else if server_section {
282                    server_data.insert(key.to_string(), value.to_string());
283                }
284            }
285        }
286
287        let client = ClientVersion {
288            version: client_data.get("Version").cloned().unwrap_or_default(),
289            api_version: client_data.get("API version").cloned().unwrap_or_default(),
290            git_commit: client_data.get("Git commit").cloned().unwrap_or_default(),
291            built: client_data.get("Built").cloned().unwrap_or_default(),
292            go_version: client_data.get("Go version").cloned().unwrap_or_default(),
293            os: client_data.get("OS/Arch").cloned().unwrap_or_default(),
294            arch: String::new(), // OS/Arch is combined in table format
295        };
296
297        let server = if server_data.is_empty() {
298            None
299        } else {
300            Some(ServerVersion {
301                version: server_data.get("Version").cloned().unwrap_or_default(),
302                api_version: server_data.get("API version").cloned().unwrap_or_default(),
303                min_api_version: server_data
304                    .get("Minimum API version")
305                    .cloned()
306                    .unwrap_or_default(),
307                git_commit: server_data.get("Git commit").cloned().unwrap_or_default(),
308                built: server_data.get("Built").cloned().unwrap_or_default(),
309                go_version: server_data.get("Go version").cloned().unwrap_or_default(),
310                os: server_data.get("OS/Arch").cloned().unwrap_or_default(),
311                arch: String::new(), // OS/Arch is combined in table format
312                kernel_version: server_data
313                    .get("Kernel Version")
314                    .cloned()
315                    .unwrap_or_default(),
316                experimental: server_data.get("Experimental").is_some_and(|s| s == "true"),
317            })
318        };
319
320        Some(VersionInfo { client, server })
321    }
322
323    /// Gets the output format (if set)
324    #[must_use]
325    pub fn get_format(&self) -> Option<&str> {
326        self.format.as_deref()
327    }
328}
329
330impl Default for VersionCommand {
331    fn default() -> Self {
332        Self::new()
333    }
334}
335
336impl VersionOutput {
337    /// Returns true if the version command was successful
338    #[must_use]
339    pub fn success(&self) -> bool {
340        self.output.success
341    }
342
343    /// Gets the client version string
344    #[must_use]
345    pub fn client_version(&self) -> Option<&str> {
346        self.version_info
347            .as_ref()
348            .map(|v| v.client.version.as_str())
349    }
350
351    /// Gets the server version string
352    #[must_use]
353    pub fn server_version(&self) -> Option<&str> {
354        self.version_info
355            .as_ref()
356            .and_then(|v| v.server.as_ref())
357            .map(|s| s.version.as_str())
358    }
359
360    /// Gets the API version
361    #[must_use]
362    pub fn api_version(&self) -> Option<&str> {
363        self.version_info
364            .as_ref()
365            .map(|v| v.client.api_version.as_str())
366    }
367
368    /// Returns true if server information is available
369    #[must_use]
370    pub fn has_server_info(&self) -> bool {
371        self.version_info
372            .as_ref()
373            .is_some_and(|v| v.server.is_some())
374    }
375
376    /// Returns true if experimental features are enabled
377    #[must_use]
378    pub fn is_experimental(&self) -> bool {
379        self.version_info
380            .as_ref()
381            .and_then(|v| v.server.as_ref())
382            .is_some_and(|s| s.experimental)
383    }
384
385    /// Checks if the Docker version is compatible with a minimum version
386    #[must_use]
387    pub fn is_compatible(&self, min_version: &str) -> bool {
388        if let Some(version) = self.client_version() {
389            // Simple version comparison (would need proper semver for production)
390            version >= min_version
391        } else {
392            false
393        }
394    }
395}
396
397#[async_trait]
398impl DockerCommand for VersionCommand {
399    type Output = VersionOutput;
400
401    fn get_executor(&self) -> &CommandExecutor {
402        &self.executor
403    }
404
405    fn get_executor_mut(&mut self) -> &mut CommandExecutor {
406        &mut self.executor
407    }
408
409    fn build_command_args(&self) -> Vec<String> {
410        self.build_command_args()
411    }
412
413    async fn execute(&self) -> Result<Self::Output> {
414        let args = self.build_command_args();
415        let output = self.execute_command(args).await?;
416
417        let version_info = self.parse_output(&output)?;
418
419        Ok(VersionOutput {
420            output,
421            version_info,
422        })
423    }
424}
425
426impl fmt::Display for VersionCommand {
427    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
428        write!(f, "docker version")?;
429
430        if let Some(ref format) = self.format {
431            write!(f, " --format {format}")?;
432        }
433
434        Ok(())
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    #[test]
443    fn test_version_command_basic() {
444        let version = VersionCommand::new();
445
446        assert_eq!(version.get_format(), None);
447
448        let args = version.build_command_args();
449        assert_eq!(args, vec!["version"]);
450    }
451
452    #[test]
453    fn test_version_command_with_format() {
454        let version = VersionCommand::new().format("{{.Client.Version}}");
455
456        assert_eq!(version.get_format(), Some("{{.Client.Version}}"));
457
458        let args = version.build_command_args();
459        assert_eq!(args, vec!["version", "--format", "{{.Client.Version}}"]);
460    }
461
462    #[test]
463    fn test_version_command_json_format() {
464        let version = VersionCommand::new().format_json();
465
466        assert_eq!(version.get_format(), Some("json"));
467
468        let args = version.build_command_args();
469        assert_eq!(args, vec!["version", "--format", "json"]);
470    }
471
472    #[test]
473    fn test_version_command_table_format() {
474        let version = VersionCommand::new().format_json().format_table();
475
476        assert_eq!(version.get_format(), None);
477
478        let args = version.build_command_args();
479        assert_eq!(args, vec!["version"]);
480    }
481
482    #[test]
483    fn test_version_command_default() {
484        let version = VersionCommand::default();
485
486        assert_eq!(version.get_format(), None);
487        let args = version.build_command_args();
488        assert_eq!(args, vec!["version"]);
489    }
490
491    #[test]
492    fn test_client_version_creation() {
493        let client = ClientVersion {
494            version: "20.10.17".to_string(),
495            api_version: "1.41".to_string(),
496            git_commit: "100c701".to_string(),
497            built: "Mon Jun  6 23:02:57 2022".to_string(),
498            go_version: "go1.17.11".to_string(),
499            os: "linux".to_string(),
500            arch: "amd64".to_string(),
501        };
502
503        assert_eq!(client.version, "20.10.17");
504        assert_eq!(client.api_version, "1.41");
505        assert_eq!(client.os, "linux");
506        assert_eq!(client.arch, "amd64");
507    }
508
509    #[test]
510    fn test_server_version_creation() {
511        let server = ServerVersion {
512            version: "20.10.17".to_string(),
513            api_version: "1.41".to_string(),
514            min_api_version: "1.12".to_string(),
515            git_commit: "100c701".to_string(),
516            built: "Mon Jun  6 23:02:57 2022".to_string(),
517            go_version: "go1.17.11".to_string(),
518            os: "linux".to_string(),
519            arch: "amd64".to_string(),
520            kernel_version: "5.15.0".to_string(),
521            experimental: false,
522        };
523
524        assert_eq!(server.version, "20.10.17");
525        assert_eq!(server.min_api_version, "1.12");
526        assert!(!server.experimental);
527    }
528
529    #[test]
530    fn test_version_info_creation() {
531        let client = ClientVersion {
532            version: "20.10.17".to_string(),
533            api_version: "1.41".to_string(),
534            git_commit: "100c701".to_string(),
535            built: "Mon Jun  6 23:02:57 2022".to_string(),
536            go_version: "go1.17.11".to_string(),
537            os: "linux".to_string(),
538            arch: "amd64".to_string(),
539        };
540
541        let version_info = VersionInfo {
542            client,
543            server: None,
544        };
545
546        assert_eq!(version_info.client.version, "20.10.17");
547        assert!(version_info.server.is_none());
548    }
549
550    #[test]
551    fn test_version_output_helpers() {
552        let client = ClientVersion {
553            version: "20.10.17".to_string(),
554            api_version: "1.41".to_string(),
555            git_commit: "100c701".to_string(),
556            built: "Mon Jun  6 23:02:57 2022".to_string(),
557            go_version: "go1.17.11".to_string(),
558            os: "linux".to_string(),
559            arch: "amd64".to_string(),
560        };
561
562        let server = ServerVersion {
563            version: "20.10.17".to_string(),
564            api_version: "1.41".to_string(),
565            min_api_version: "1.12".to_string(),
566            git_commit: "100c701".to_string(),
567            built: "Mon Jun  6 23:02:57 2022".to_string(),
568            go_version: "go1.17.11".to_string(),
569            os: "linux".to_string(),
570            arch: "amd64".to_string(),
571            kernel_version: "5.15.0".to_string(),
572            experimental: true,
573        };
574
575        let version_info = VersionInfo {
576            client,
577            server: Some(server),
578        };
579
580        let output = VersionOutput {
581            output: CommandOutput {
582                stdout: String::new(),
583                stderr: String::new(),
584                exit_code: 0,
585                success: true,
586            },
587            version_info: Some(version_info),
588        };
589
590        assert_eq!(output.client_version(), Some("20.10.17"));
591        assert_eq!(output.server_version(), Some("20.10.17"));
592        assert_eq!(output.api_version(), Some("1.41"));
593        assert!(output.has_server_info());
594        assert!(output.is_experimental());
595        assert!(output.is_compatible("20.10.0"));
596        assert!(!output.is_compatible("21.0.0"));
597    }
598
599    #[test]
600    fn test_version_output_no_server() {
601        let client = ClientVersion {
602            version: "20.10.17".to_string(),
603            api_version: "1.41".to_string(),
604            git_commit: "100c701".to_string(),
605            built: "Mon Jun  6 23:02:57 2022".to_string(),
606            go_version: "go1.17.11".to_string(),
607            os: "linux".to_string(),
608            arch: "amd64".to_string(),
609        };
610
611        let version_info = VersionInfo {
612            client,
613            server: None,
614        };
615
616        let output = VersionOutput {
617            output: CommandOutput {
618                stdout: String::new(),
619                stderr: String::new(),
620                exit_code: 0,
621                success: true,
622            },
623            version_info: Some(version_info),
624        };
625
626        assert_eq!(output.client_version(), Some("20.10.17"));
627        assert_eq!(output.server_version(), None);
628        assert!(!output.has_server_info());
629        assert!(!output.is_experimental());
630    }
631
632    #[test]
633    fn test_version_command_display() {
634        let version = VersionCommand::new().format("{{.Client.Version}}");
635
636        let display = format!("{version}");
637        assert_eq!(display, "docker version --format {{.Client.Version}}");
638    }
639
640    #[test]
641    fn test_version_command_display_no_format() {
642        let version = VersionCommand::new();
643
644        let display = format!("{version}");
645        assert_eq!(display, "docker version");
646    }
647
648    #[test]
649    fn test_version_command_name() {
650        let version = VersionCommand::new();
651        let args = version.build_command_args();
652        assert_eq!(args[0], "version");
653    }
654
655    #[test]
656    fn test_version_command_extensibility() {
657        let mut version = VersionCommand::new();
658
659        // Test that we can add custom raw arguments
660        version
661            .get_executor_mut()
662            .raw_args
663            .push("--verbose".to_string());
664        version
665            .get_executor_mut()
666            .raw_args
667            .push("--some-flag".to_string());
668
669        let args = version.build_command_args();
670
671        // Verify raw args are included
672        assert!(args.contains(&"--verbose".to_string()));
673        assert!(args.contains(&"--some-flag".to_string()));
674    }
675
676    #[test]
677    fn test_parse_json_output_concept() {
678        // This test demonstrates the concept of parsing JSON output
679        let json_output = r#"{"Client":{"Version":"20.10.17","ApiVersion":"1.41"}}"#;
680
681        let output = CommandOutput {
682            stdout: json_output.to_string(),
683            stderr: String::new(),
684            exit_code: 0,
685            success: true,
686        };
687
688        let result = VersionCommand::parse_json_output(&output);
689
690        // The actual parsing would need real Docker JSON output
691        assert!(result.is_ok());
692    }
693
694    #[test]
695    fn test_parse_table_output_concept() {
696        // This test demonstrates the concept of parsing table output
697        let table_output =
698            "Client:\n Version: 20.10.17\n API version: 1.41\n\nServer:\n Version: 20.10.17";
699
700        let output = CommandOutput {
701            stdout: table_output.to_string(),
702            stderr: String::new(),
703            exit_code: 0,
704            success: true,
705        };
706
707        let result = VersionCommand::parse_table_output(&output);
708
709        // The actual parsing would need real Docker table output
710        assert!(result.is_some() || result.is_none());
711    }
712}