1use 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#[derive(Debug, Clone)]
14pub enum DockerfileSelectionResult {
15 Selected {
17 dockerfile: DiscoveredDockerfile,
18 build_context: String,
19 },
20 StartAgent(String),
22 Back,
24 Cancelled,
26}
27
28#[derive(Debug, Clone)]
30enum BuildContextOption {
31 DockerfileDirectory(String),
33 RepositoryRoot,
35 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
55struct 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 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 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
88pub 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
111fn 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
136fn 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 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 select_build_context(dockerfile)
163}
164
165fn 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 let options: Vec<DockerfileOption> = dockerfiles
178 .iter()
179 .map(|df| DockerfileOption {
180 dockerfile: df,
181 project_root,
182 })
183 .collect();
184
185 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 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
206fn 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 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 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 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 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 let selected = DockerfileSelectionResult::Selected {
342 dockerfile: df.clone(),
343 build_context: ".".to_string(),
344 };
345 matches!(selected, DockerfileSelectionResult::Selected { .. });
346
347 let agent = DockerfileSelectionResult::StartAgent("prompt".to_string());
349 matches!(agent, DockerfileSelectionResult::StartAgent(_));
350
351 let _ = DockerfileSelectionResult::Back;
353 let _ = DockerfileSelectionResult::Cancelled;
354 }
355}