docker_wrapper/command/
import.rs

1//! Docker import command implementation.
2//!
3//! This module provides the `docker import` command for importing tarball contents as images.
4
5use super::{CommandExecutor, CommandOutput, DockerCommand};
6use crate::error::Result;
7use async_trait::async_trait;
8
9/// Docker import command builder
10///
11/// Import the contents from a tarball to create a filesystem image.
12///
13/// # Example
14///
15/// ```no_run
16/// use docker_wrapper::ImportCommand;
17///
18/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
19/// // Import from file
20/// let result = ImportCommand::new("backup.tar")
21///     .repository("my-app:imported")
22///     .run()
23///     .await?;
24///
25/// if result.success() {
26///     println!("Image imported: {}", result.image_id().unwrap_or("unknown"));
27/// }
28/// # Ok(())
29/// # }
30/// ```
31#[derive(Debug, Clone)]
32pub struct ImportCommand {
33    /// Source file, URL, or - for stdin
34    source: String,
35    /// Repository name for the imported image
36    repository: Option<String>,
37    /// Commit message for the imported image
38    message: Option<String>,
39    /// Apply Dockerfile instructions while importing
40    changes: Vec<String>,
41    /// Command executor
42    pub executor: CommandExecutor,
43}
44
45impl ImportCommand {
46    /// Create a new import command
47    ///
48    /// # Example
49    ///
50    /// ```
51    /// use docker_wrapper::ImportCommand;
52    ///
53    /// // Import from file
54    /// let cmd = ImportCommand::new("backup.tar");
55    ///
56    /// // Import from URL
57    /// let cmd = ImportCommand::new("http://example.com/image.tar.gz");
58    ///
59    /// // Import from stdin
60    /// let cmd = ImportCommand::new("-");
61    /// ```
62    #[must_use]
63    pub fn new(source: impl Into<String>) -> Self {
64        Self {
65            source: source.into(),
66            repository: None,
67            message: None,
68            changes: Vec::new(),
69            executor: CommandExecutor::new(),
70        }
71    }
72
73    /// Set repository name for the imported image
74    ///
75    /// # Example
76    ///
77    /// ```
78    /// use docker_wrapper::ImportCommand;
79    ///
80    /// let cmd = ImportCommand::new("backup.tar")
81    ///     .repository("my-app:v1.0");
82    /// ```
83    #[must_use]
84    pub fn repository(mut self, repository: impl Into<String>) -> Self {
85        self.repository = Some(repository.into());
86        self
87    }
88
89    /// Set commit message for the imported image
90    ///
91    /// # Example
92    ///
93    /// ```
94    /// use docker_wrapper::ImportCommand;
95    ///
96    /// let cmd = ImportCommand::new("backup.tar")
97    ///     .message("Imported from production backup");
98    /// ```
99    #[must_use]
100    pub fn message(mut self, message: impl Into<String>) -> Self {
101        self.message = Some(message.into());
102        self
103    }
104
105    /// Apply Dockerfile instruction while importing
106    ///
107    /// # Example
108    ///
109    /// ```
110    /// use docker_wrapper::ImportCommand;
111    ///
112    /// let cmd = ImportCommand::new("backup.tar")
113    ///     .change("ENV PATH=/usr/local/bin:$PATH")
114    ///     .change("EXPOSE 8080");
115    /// ```
116    #[must_use]
117    pub fn change(mut self, change: impl Into<String>) -> Self {
118        self.changes.push(change.into());
119        self
120    }
121
122    /// Apply multiple Dockerfile instructions while importing
123    ///
124    /// # Example
125    ///
126    /// ```
127    /// use docker_wrapper::ImportCommand;
128    ///
129    /// let cmd = ImportCommand::new("backup.tar")
130    ///     .changes(vec![
131    ///         "ENV NODE_ENV=production",
132    ///         "EXPOSE 3000",
133    ///         "CMD [\"npm\", \"start\"]"
134    ///     ]);
135    /// ```
136    #[must_use]
137    pub fn changes<I, S>(mut self, changes: I) -> Self
138    where
139        I: IntoIterator<Item = S>,
140        S: Into<String>,
141    {
142        self.changes.extend(changes.into_iter().map(Into::into));
143        self
144    }
145
146    /// Execute the import command
147    ///
148    /// # Errors
149    /// Returns an error if:
150    /// - The Docker daemon is not running
151    /// - The source file doesn't exist or is not readable
152    /// - The tarball is corrupted or invalid
153    /// - Network issues (for URL sources)
154    /// - Insufficient disk space
155    ///
156    /// # Example
157    ///
158    /// ```no_run
159    /// use docker_wrapper::ImportCommand;
160    ///
161    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
162    /// let result = ImportCommand::new("app-backup.tar")
163    ///     .repository("my-app:restored")
164    ///     .message("Restored from backup")
165    ///     .run()
166    ///     .await?;
167    ///
168    /// if result.success() {
169    ///     println!("Import successful!");
170    ///     if let Some(image_id) = result.image_id() {
171    ///         println!("New image ID: {}", image_id);
172    ///     }
173    /// }
174    /// # Ok(())
175    /// # }
176    /// ```
177    pub async fn run(&self) -> Result<ImportResult> {
178        let output = self.execute().await?;
179
180        // Parse image ID from output (usually the only line in stdout)
181        let image_id = Self::parse_image_id(&output.stdout);
182
183        Ok(ImportResult {
184            output,
185            source: self.source.clone(),
186            repository: self.repository.clone(),
187            image_id,
188        })
189    }
190
191    /// Parse image ID from import command output
192    fn parse_image_id(stdout: &str) -> Option<String> {
193        let trimmed = stdout.trim();
194        if trimmed.is_empty() {
195            return None;
196        }
197
198        // The output is typically just the image ID/digest
199        Some(trimmed.to_string())
200    }
201}
202
203#[async_trait]
204impl DockerCommand for ImportCommand {
205    type Output = CommandOutput;
206
207    fn build_command_args(&self) -> Vec<String> {
208        let mut args = vec!["import".to_string()];
209
210        // Add message if specified
211        if let Some(ref message) = self.message {
212            args.push("--message".to_string());
213            args.push(message.clone());
214        }
215
216        // Add changes if specified
217        for change in &self.changes {
218            args.push("--change".to_string());
219            args.push(change.clone());
220        }
221
222        // Add source
223        args.push(self.source.clone());
224
225        // Add repository if specified
226        if let Some(ref repository) = self.repository {
227            args.push(repository.clone());
228        }
229
230        args.extend(self.executor.raw_args.clone());
231        args
232    }
233
234    async fn execute(&self) -> Result<Self::Output> {
235        let args = self.build_command_args();
236        let command_name = args[0].clone();
237        let command_args = args[1..].to_vec();
238        self.executor
239            .execute_command(&command_name, command_args)
240            .await
241    }
242
243    fn get_executor(&self) -> &CommandExecutor {
244        &self.executor
245    }
246
247    fn get_executor_mut(&mut self) -> &mut CommandExecutor {
248        &mut self.executor
249    }
250}
251
252/// Result from the import command
253#[derive(Debug, Clone)]
254pub struct ImportResult {
255    /// Raw command output
256    pub output: CommandOutput,
257    /// Source that was imported
258    pub source: String,
259    /// Repository name (if specified)
260    pub repository: Option<String>,
261    /// Imported image ID
262    pub image_id: Option<String>,
263}
264
265impl ImportResult {
266    /// Check if the import was successful
267    #[must_use]
268    pub fn success(&self) -> bool {
269        self.output.success
270    }
271
272    /// Get the source that was imported
273    #[must_use]
274    pub fn source(&self) -> &str {
275        &self.source
276    }
277
278    /// Get the repository name
279    #[must_use]
280    pub fn repository(&self) -> Option<&str> {
281        self.repository.as_deref()
282    }
283
284    /// Get the imported image ID
285    #[must_use]
286    pub fn image_id(&self) -> Option<&str> {
287        self.image_id.as_deref()
288    }
289
290    /// Get the raw command output
291    #[must_use]
292    pub fn output(&self) -> &CommandOutput {
293        &self.output
294    }
295
296    /// Check if a repository name was specified
297    #[must_use]
298    pub fn has_repository(&self) -> bool {
299        self.repository.is_some()
300    }
301
302    /// Check if import was from stdin
303    #[must_use]
304    pub fn imported_from_stdin(&self) -> bool {
305        self.source == "-"
306    }
307
308    /// Check if import was from URL
309    #[must_use]
310    pub fn imported_from_url(&self) -> bool {
311        self.source.starts_with("http://") || self.source.starts_with("https://")
312    }
313
314    /// Check if import was from file
315    #[must_use]
316    pub fn imported_from_file(&self) -> bool {
317        !self.imported_from_stdin() && !self.imported_from_url()
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[test]
326    fn test_import_basic() {
327        let cmd = ImportCommand::new("backup.tar");
328        let args = cmd.build_command_args();
329        assert_eq!(args, vec!["import", "backup.tar"]);
330    }
331
332    #[test]
333    fn test_import_with_repository() {
334        let cmd = ImportCommand::new("backup.tar").repository("my-app:v1.0");
335        let args = cmd.build_command_args();
336        assert_eq!(args, vec!["import", "backup.tar", "my-app:v1.0"]);
337    }
338
339    #[test]
340    fn test_import_all_options() {
341        let cmd = ImportCommand::new("backup.tar")
342            .repository("my-app:v1.0")
343            .message("Imported from backup")
344            .change("ENV NODE_ENV=production")
345            .change("EXPOSE 3000");
346        let args = cmd.build_command_args();
347        assert_eq!(
348            args,
349            vec![
350                "import",
351                "--message",
352                "Imported from backup",
353                "--change",
354                "ENV NODE_ENV=production",
355                "--change",
356                "EXPOSE 3000",
357                "backup.tar",
358                "my-app:v1.0"
359            ]
360        );
361    }
362
363    #[test]
364    fn test_import_with_changes() {
365        let cmd = ImportCommand::new("app.tar")
366            .changes(vec!["ENV PATH=/usr/local/bin:$PATH", "WORKDIR /app"]);
367        let args = cmd.build_command_args();
368        assert_eq!(
369            args,
370            vec![
371                "import",
372                "--change",
373                "ENV PATH=/usr/local/bin:$PATH",
374                "--change",
375                "WORKDIR /app",
376                "app.tar"
377            ]
378        );
379    }
380
381    #[test]
382    fn test_import_from_stdin() {
383        let cmd = ImportCommand::new("-").repository("stdin-image");
384        let args = cmd.build_command_args();
385        assert_eq!(args, vec!["import", "-", "stdin-image"]);
386    }
387
388    #[test]
389    fn test_import_from_url() {
390        let cmd = ImportCommand::new("http://example.com/image.tar.gz").repository("remote-image");
391        let args = cmd.build_command_args();
392        assert_eq!(
393            args,
394            vec!["import", "http://example.com/image.tar.gz", "remote-image"]
395        );
396    }
397
398    #[test]
399    fn test_parse_image_id() {
400        assert_eq!(
401            ImportCommand::parse_image_id("sha256:abcd1234"),
402            Some("sha256:abcd1234".to_string())
403        );
404        assert_eq!(ImportCommand::parse_image_id(""), None);
405        assert_eq!(ImportCommand::parse_image_id("  \n  "), None);
406    }
407
408    #[test]
409    fn test_import_result() {
410        let result = ImportResult {
411            output: CommandOutput {
412                stdout: "sha256:abcd1234".to_string(),
413                stderr: String::new(),
414                exit_code: 0,
415                success: true,
416            },
417            source: "backup.tar".to_string(),
418            repository: Some("my-app:v1.0".to_string()),
419            image_id: Some("sha256:abcd1234".to_string()),
420        };
421
422        assert!(result.success());
423        assert_eq!(result.source(), "backup.tar");
424        assert_eq!(result.repository(), Some("my-app:v1.0"));
425        assert_eq!(result.image_id(), Some("sha256:abcd1234"));
426        assert!(result.has_repository());
427        assert!(!result.imported_from_stdin());
428        assert!(!result.imported_from_url());
429        assert!(result.imported_from_file());
430    }
431
432    #[test]
433    fn test_import_result_source_types() {
434        let stdin_result = ImportResult {
435            output: CommandOutput {
436                stdout: String::new(),
437                stderr: String::new(),
438                exit_code: 0,
439                success: true,
440            },
441            source: "-".to_string(),
442            repository: None,
443            image_id: None,
444        };
445        assert!(stdin_result.imported_from_stdin());
446        assert!(!stdin_result.imported_from_url());
447        assert!(!stdin_result.imported_from_file());
448
449        let url_result = ImportResult {
450            output: CommandOutput {
451                stdout: String::new(),
452                stderr: String::new(),
453                exit_code: 0,
454                success: true,
455            },
456            source: "https://example.com/image.tar".to_string(),
457            repository: None,
458            image_id: None,
459        };
460        assert!(!url_result.imported_from_stdin());
461        assert!(url_result.imported_from_url());
462        assert!(!url_result.imported_from_file());
463    }
464}