syncable_cli/wizard/
dockerfile_selection.rs

1//! Dockerfile selection step for the deployment wizard
2//!
3//! Provides smart Dockerfile discovery and selection with build context options.
4
5use crate::analyzer::DiscoveredDockerfile;
6use crate::wizard::render::{display_step_header, wizard_render_config};
7use colored::Colorize;
8use inquire::{Confirm, InquireError, Select, Text};
9use std::fmt;
10use std::path::Path;
11
12/// Result of Dockerfile selection step
13#[derive(Debug, Clone)]
14pub enum DockerfileSelectionResult {
15    /// User selected a Dockerfile with build context
16    Selected {
17        dockerfile: DiscoveredDockerfile,
18        build_context: String,
19    },
20    /// User wants the agent to create a Dockerfile
21    StartAgent(String),
22    /// User wants to go back
23    Back,
24    /// User cancelled the wizard
25    Cancelled,
26}
27
28/// Build context options for the user to choose from
29#[derive(Debug, Clone)]
30enum BuildContextOption {
31    /// Directory containing the Dockerfile
32    DockerfileDirectory(String),
33    /// Repository root
34    RepositoryRoot,
35    /// Custom user-specified path
36    Custom,
37}
38
39impl fmt::Display for BuildContextOption {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        match self {
42            BuildContextOption::DockerfileDirectory(path) => {
43                write!(f, "Dockerfile's directory    {}", path.dimmed())
44            }
45            BuildContextOption::RepositoryRoot => {
46                write!(f, "Repository root           {}", ".".dimmed())
47            }
48            BuildContextOption::Custom => {
49                write!(f, "Custom path...")
50            }
51        }
52    }
53}
54
55/// Wrapper for displaying Dockerfile options in the selection menu
56struct DockerfileOption<'a> {
57    dockerfile: &'a DiscoveredDockerfile,
58    project_root: &'a Path,
59}
60
61impl<'a> fmt::Display for DockerfileOption<'a> {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        // Get relative path from project root
64        let relative_path = self
65            .dockerfile
66            .path
67            .strip_prefix(self.project_root)
68            .map(|p| p.to_string_lossy().to_string())
69            .unwrap_or_else(|_| self.dockerfile.path.to_string_lossy().to_string());
70
71        // Show: path → build_context
72        let build_context = if self.dockerfile.build_context == "." {
73            ". (root)".to_string()
74        } else {
75            self.dockerfile.build_context.clone()
76        };
77
78        write!(
79            f,
80            "{}  {}  {}",
81            relative_path,
82            "→".dimmed(),
83            build_context.dimmed()
84        )
85    }
86}
87
88/// Select a Dockerfile from discovered Dockerfiles
89///
90/// Handles three cases:
91/// - Multiple Dockerfiles: Show selection menu
92/// - Single Dockerfile: Auto-select with confirmation
93/// - No Dockerfiles: Offer to start agent for creation
94pub fn select_dockerfile(
95    dockerfiles: &[DiscoveredDockerfile],
96    project_root: &Path,
97) -> DockerfileSelectionResult {
98    display_step_header(
99        5,
100        "Select Dockerfile",
101        "Choose the Dockerfile to use for deployment.",
102    );
103
104    match dockerfiles.len() {
105        0 => handle_no_dockerfiles(),
106        1 => handle_single_dockerfile(&dockerfiles[0], project_root),
107        _ => handle_multiple_dockerfiles(dockerfiles, project_root),
108    }
109}
110
111/// Handle case when no Dockerfiles are found
112fn handle_no_dockerfiles() -> DockerfileSelectionResult {
113    println!(
114        "\n{} {}",
115        "⚠".yellow(),
116        "No Dockerfiles found in this project.".yellow()
117    );
118
119    match Confirm::new("Would you like the agent to help create one?")
120        .with_default(true)
121        .with_help_message("Start an AI-assisted session to generate a Dockerfile")
122        .prompt()
123    {
124        Ok(true) => {
125            let prompt = "Help me create a Dockerfile for this project. Analyze the codebase and suggest an appropriate Dockerfile with best practices for production deployment.".to_string();
126            DockerfileSelectionResult::StartAgent(prompt)
127        }
128        Ok(false) => DockerfileSelectionResult::Cancelled,
129        Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
130            DockerfileSelectionResult::Cancelled
131        }
132        Err(_) => DockerfileSelectionResult::Cancelled,
133    }
134}
135
136/// Handle case when only one Dockerfile is found
137fn handle_single_dockerfile(
138    dockerfile: &DiscoveredDockerfile,
139    project_root: &Path,
140) -> DockerfileSelectionResult {
141    let relative_path = dockerfile
142        .path
143        .strip_prefix(project_root)
144        .map(|p| p.to_string_lossy().to_string())
145        .unwrap_or_else(|_| dockerfile.path.to_string_lossy().to_string());
146
147    println!(
148        "\n{} Found: {}",
149        "✓".green(),
150        relative_path.cyan()
151    );
152
153    // Show additional info if available
154    if let Some(ref base) = dockerfile.base_image {
155        println!("  {} Base image: {}", "│".dimmed(), base.dimmed());
156    }
157    if let Some(port) = dockerfile.suggested_port {
158        println!("  {} Suggested port: {}", "│".dimmed(), port.to_string().dimmed());
159    }
160
161    // Proceed to build context selection
162    select_build_context(dockerfile)
163}
164
165/// Handle case when multiple Dockerfiles are found
166fn handle_multiple_dockerfiles(
167    dockerfiles: &[DiscoveredDockerfile],
168    project_root: &Path,
169) -> DockerfileSelectionResult {
170    println!(
171        "\n{} Found {} Dockerfiles:",
172        "ℹ".blue(),
173        dockerfiles.len().to_string().cyan()
174    );
175
176    // Create display options
177    let options: Vec<DockerfileOption> = dockerfiles
178        .iter()
179        .map(|df| DockerfileOption {
180            dockerfile: df,
181            project_root,
182        })
183        .collect();
184
185    // Build the selection menu
186    let selection = Select::new("Select Dockerfile:", options)
187        .with_render_config(wizard_render_config())
188        .with_help_message("Use ↑/↓ to navigate, Enter to select")
189        .prompt();
190
191    match selection {
192        Ok(selected) => {
193            // Find the selected dockerfile by matching path
194            let selected_df = dockerfiles
195                .iter()
196                .find(|df| std::ptr::eq(*df, selected.dockerfile))
197                .unwrap();
198            select_build_context(selected_df)
199        }
200        Err(InquireError::OperationCanceled) => DockerfileSelectionResult::Back,
201        Err(InquireError::OperationInterrupted) => DockerfileSelectionResult::Cancelled,
202        Err(_) => DockerfileSelectionResult::Cancelled,
203    }
204}
205
206/// Select build context for the chosen Dockerfile
207fn select_build_context(dockerfile: &DiscoveredDockerfile) -> DockerfileSelectionResult {
208    println!();
209    println!(
210        "{}",
211        "─── Build Context ───────────────────────────".dimmed()
212    );
213    println!(
214        "  {}",
215        "The build context is the directory sent to Docker during build.".dimmed()
216    );
217
218    // Compute dockerfile directory (default build context)
219    let dockerfile_dir = dockerfile
220        .path
221        .parent()
222        .map(|p| {
223            if p.as_os_str().is_empty() {
224                ".".to_string()
225            } else {
226                p.to_string_lossy().to_string()
227            }
228        })
229        .unwrap_or_else(|| ".".to_string());
230
231    // Use the computed build_context from discovery as dockerfile directory display
232    let display_dir = if dockerfile.build_context.is_empty() || dockerfile.build_context == "." {
233        ".".to_string()
234    } else {
235        dockerfile.build_context.clone()
236    };
237
238    // Build options
239    let options = vec![
240        BuildContextOption::DockerfileDirectory(display_dir.clone()),
241        BuildContextOption::RepositoryRoot,
242        BuildContextOption::Custom,
243    ];
244
245    let selection = Select::new("Build context:", options)
246        .with_render_config(wizard_render_config())
247        .with_help_message("Select the directory to use as Docker build context")
248        .prompt();
249
250    match selection {
251        Ok(BuildContextOption::DockerfileDirectory(_)) => DockerfileSelectionResult::Selected {
252            dockerfile: dockerfile.clone(),
253            build_context: display_dir,
254        },
255        Ok(BuildContextOption::RepositoryRoot) => DockerfileSelectionResult::Selected {
256            dockerfile: dockerfile.clone(),
257            build_context: ".".to_string(),
258        },
259        Ok(BuildContextOption::Custom) => {
260            // Prompt for custom path
261            match Text::new("Custom build context path:")
262                .with_default(&dockerfile_dir)
263                .with_help_message("Relative path from repository root")
264                .prompt()
265            {
266                Ok(path) => DockerfileSelectionResult::Selected {
267                    dockerfile: dockerfile.clone(),
268                    build_context: path,
269                },
270                Err(InquireError::OperationCanceled) => DockerfileSelectionResult::Back,
271                Err(InquireError::OperationInterrupted) => DockerfileSelectionResult::Cancelled,
272                Err(_) => DockerfileSelectionResult::Cancelled,
273            }
274        }
275        Err(InquireError::OperationCanceled) => DockerfileSelectionResult::Back,
276        Err(InquireError::OperationInterrupted) => DockerfileSelectionResult::Cancelled,
277        Err(_) => DockerfileSelectionResult::Cancelled,
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use std::path::PathBuf;
285
286    fn create_test_dockerfile(path: &str, build_context: &str) -> DiscoveredDockerfile {
287        DiscoveredDockerfile {
288            path: PathBuf::from(path),
289            build_context: build_context.to_string(),
290            suggested_service_name: "test-service".to_string(),
291            suggested_port: Some(8080),
292            base_image: Some("node:18".to_string()),
293            is_multistage: false,
294            environment: None,
295        }
296    }
297
298    #[test]
299    fn test_dockerfile_option_display() {
300        let df = create_test_dockerfile("/project/services/api/Dockerfile", "services/api");
301        let project_root = PathBuf::from("/project");
302        let option = DockerfileOption {
303            dockerfile: &df,
304            project_root: &project_root,
305        };
306        let display = format!("{}", option);
307        assert!(display.contains("services/api/Dockerfile"));
308        assert!(display.contains("→"));
309    }
310
311    #[test]
312    fn test_dockerfile_option_display_root() {
313        let df = create_test_dockerfile("/project/Dockerfile", ".");
314        let project_root = PathBuf::from("/project");
315        let option = DockerfileOption {
316            dockerfile: &df,
317            project_root: &project_root,
318        };
319        let display = format!("{}", option);
320        assert!(display.contains("Dockerfile"));
321        assert!(display.contains("(root)"));
322    }
323
324    #[test]
325    fn test_build_context_option_display() {
326        let dir_option = BuildContextOption::DockerfileDirectory("services/api".to_string());
327        assert!(format!("{}", dir_option).contains("services/api"));
328
329        let root_option = BuildContextOption::RepositoryRoot;
330        assert!(format!("{}", root_option).contains("."));
331
332        let custom_option = BuildContextOption::Custom;
333        assert!(format!("{}", custom_option).contains("Custom"));
334    }
335
336    #[test]
337    fn test_dockerfile_selection_result_variants() {
338        let df = create_test_dockerfile("/project/Dockerfile", ".");
339
340        // Test Selected variant
341        let selected = DockerfileSelectionResult::Selected {
342            dockerfile: df.clone(),
343            build_context: ".".to_string(),
344        };
345        matches!(selected, DockerfileSelectionResult::Selected { .. });
346
347        // Test StartAgent variant
348        let agent = DockerfileSelectionResult::StartAgent("prompt".to_string());
349        matches!(agent, DockerfileSelectionResult::StartAgent(_));
350
351        // Test Back and Cancelled variants
352        let _ = DockerfileSelectionResult::Back;
353        let _ = DockerfileSelectionResult::Cancelled;
354    }
355}