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