1use crate::analyzer::DiscoveredDockerfile;
4use crate::platform::api::types::{
5 CloudProvider, DeploymentSecretInput, DeploymentTarget, WizardDeploymentConfig,
6};
7use crate::wizard::render::display_step_header;
8use colored::Colorize;
9use inquire::{Confirm, InquireError, Select, Text};
10use std::path::{Path, PathBuf};
11
12const IGNORED_DIRS: &[&str] = &[
13 "node_modules",
14 ".git",
15 "target",
16 "vendor",
17 "dist",
18 ".next",
19 ".nuxt",
20 "__pycache__",
21 ".venv",
22 "venv",
23];
24const MAX_DEPTH: usize = 3;
25
26pub fn discover_env_files(root: &Path) -> Vec<PathBuf> {
30 let mut found = Vec::new();
31 walk_for_env_files(root, root, 0, &mut found);
32 found.sort();
33 found
34}
35
36fn walk_for_env_files(root: &Path, dir: &Path, depth: usize, found: &mut Vec<PathBuf>) {
37 if depth > MAX_DEPTH {
38 return;
39 }
40 let entries = match std::fs::read_dir(dir) {
41 Ok(e) => e,
42 Err(_) => return,
43 };
44 for entry in entries.flatten() {
45 let path = entry.path();
46 let name = entry.file_name();
47 let name_str = name.to_string_lossy();
48 if path.is_file() && name_str.starts_with(".env") && !name_str.starts_with(".envrc") {
49 if let Ok(rel) = path.strip_prefix(root) {
50 found.push(rel.to_path_buf());
51 }
52 } else if path.is_dir() && !IGNORED_DIRS.contains(&name_str.as_ref()) {
53 walk_for_env_files(root, &path, depth + 1, found);
54 }
55 }
56}
57
58#[derive(Debug, Clone)]
60pub struct EnvFileEntry {
61 pub key: String,
62 pub value: String,
63 pub is_secret: bool,
64}
65
66pub fn parse_env_file(path: &Path) -> Result<Vec<EnvFileEntry>, std::io::Error> {
71 let content = std::fs::read_to_string(path)?;
72 let entries = content
73 .lines()
74 .filter_map(|line| {
75 let line = line.trim();
76 if line.is_empty() || line.starts_with('#') {
77 return None;
78 }
79 let (key, value) = line.split_once('=')?;
80 let key = key.trim().to_string();
81 let value = value.trim().to_string();
82 let value = value
83 .strip_prefix('"')
84 .and_then(|v| v.strip_suffix('"'))
85 .map(|v| v.to_string())
86 .or_else(|| {
87 value
88 .strip_prefix('\'')
89 .and_then(|v| v.strip_suffix('\''))
90 .map(|v| v.to_string())
91 })
92 .unwrap_or(value);
93 if key.is_empty() {
94 return None;
95 }
96 Some(EnvFileEntry {
97 is_secret: is_likely_secret(&key),
98 key,
99 value,
100 })
101 })
102 .collect();
103 Ok(entries)
104}
105
106fn count_env_vars_in_file(path: &Path) -> usize {
108 std::fs::read_to_string(path)
109 .map(|c| {
110 c.lines()
111 .filter(|l| {
112 let l = l.trim();
113 !l.is_empty() && !l.starts_with('#') && l.contains('=')
114 })
115 .count()
116 })
117 .unwrap_or(0)
118}
119
120#[derive(Debug, Clone)]
122pub enum ConfigFormResult {
123 Completed(WizardDeploymentConfig),
125 Back,
127 Cancelled,
129}
130
131#[allow(clippy::too_many_arguments)]
137pub fn collect_config(
138 provider: CloudProvider,
139 target: DeploymentTarget,
140 cluster_id: Option<String>,
141 registry_id: Option<String>,
142 environment_id: &str,
143 dockerfile_path: &str,
144 build_context: &str,
145 discovered_dockerfile: &DiscoveredDockerfile,
146 region: Option<String>,
147 machine_type: Option<String>,
148 cpu: Option<String>,
149 memory: Option<String>,
150 step_number: u8,
151) -> ConfigFormResult {
152 display_step_header(
153 step_number,
154 "Configure Service",
155 "Provide details for your service deployment.",
156 );
157
158 println!(" {} Dockerfile: {}", "│".dimmed(), dockerfile_path.cyan());
160 println!(" {} Build context: {}", "│".dimmed(), build_context.cyan());
161 if let Some(ref r) = region {
162 println!(" {} Region: {}", "│".dimmed(), r.cyan());
163 }
164 if let Some(ref c) = cpu {
165 if let Some(ref m) = memory {
166 println!(
167 " {} Resources: {} vCPU / {}",
168 "│".dimmed(),
169 c.cyan(),
170 m.cyan()
171 );
172 }
173 } else if let Some(ref m) = machine_type {
174 println!(" {} Machine: {}", "│".dimmed(), m.cyan());
175 }
176 println!();
177
178 let default_name = discovered_dockerfile.suggested_service_name.clone();
180 let default_port = discovered_dockerfile.suggested_port.unwrap_or(8080);
181
182 let default_branch = get_current_branch().unwrap_or_else(|| "main".to_string());
184
185 let service_name = match Text::new("Service name:")
187 .with_default(&default_name)
188 .with_help_message("K8s-compatible name (lowercase, hyphens)")
189 .prompt()
190 {
191 Ok(name) => sanitize_service_name(&name),
192 Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
193 return ConfigFormResult::Cancelled;
194 }
195 Err(_) => return ConfigFormResult::Cancelled,
196 };
197
198 let port_str = default_port.to_string();
200 let port = match Text::new("Service port:")
201 .with_default(&port_str)
202 .with_help_message("Port your service listens on")
203 .prompt()
204 {
205 Ok(p) => p.parse::<u16>().unwrap_or(default_port),
206 Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
207 return ConfigFormResult::Cancelled;
208 }
209 Err(_) => return ConfigFormResult::Cancelled,
210 };
211
212 let branch = match Text::new("Git branch:")
214 .with_default(&default_branch)
215 .with_help_message("Branch to deploy from")
216 .prompt()
217 {
218 Ok(b) => b,
219 Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
220 return ConfigFormResult::Cancelled;
221 }
222 Err(_) => return ConfigFormResult::Cancelled,
223 };
224
225 let is_public = if target == DeploymentTarget::CloudRunner {
227 println!();
228 println!(
229 "{}",
230 "─── Access Configuration ────────────────────".dimmed()
231 );
232 match Confirm::new("Enable public access?")
233 .with_default(true)
234 .with_help_message("Make service accessible via public IP/URL")
235 .prompt()
236 {
237 Ok(v) => v,
238 Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
239 return ConfigFormResult::Cancelled;
240 }
241 Err(_) => return ConfigFormResult::Cancelled,
242 }
243 } else {
244 true };
246
247 let health_check_path = if target == DeploymentTarget::CloudRunner {
249 match Confirm::new("Configure health check endpoint?")
250 .with_default(false)
251 .with_help_message("Optional HTTP health probe for your service")
252 .prompt()
253 {
254 Ok(true) => {
255 match Text::new("Health check path:")
256 .with_default("/health")
257 .with_help_message("e.g., /health, /healthz, /api/health")
258 .prompt()
259 {
260 Ok(path) => Some(path),
261 Err(InquireError::OperationCanceled)
262 | Err(InquireError::OperationInterrupted) => {
263 return ConfigFormResult::Cancelled;
264 }
265 Err(_) => return ConfigFormResult::Cancelled,
266 }
267 }
268 Ok(false) => None,
269 Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
270 return ConfigFormResult::Cancelled;
271 }
272 Err(_) => return ConfigFormResult::Cancelled,
273 }
274 } else {
275 None
276 };
277
278 let auto_deploy = false;
280
281 let config = WizardDeploymentConfig {
283 service_name: Some(service_name.clone()),
284 dockerfile_path: Some(dockerfile_path.to_string()),
285 build_context: Some(build_context.to_string()),
286 port: Some(port),
287 branch: Some(branch),
288 target: Some(target),
289 provider: Some(provider),
290 cluster_id,
291 registry_id,
292 environment_id: Some(environment_id.to_string()),
293 auto_deploy,
294 region,
295 machine_type,
296 cpu,
297 memory,
298 is_public,
299 health_check_path,
300 secrets: Vec::new(), };
302
303 println!("\n{} Configuration complete: {}", "✓".green(), service_name);
304
305 ConfigFormResult::Completed(config)
306}
307
308pub fn collect_env_vars(project_path: &Path) -> Vec<DeploymentSecretInput> {
316 println!();
317 println!(
318 "{}",
319 "─── Environment Variables ──────────────────────".dimmed()
320 );
321
322 let wants_env_vars = match Confirm::new("Add environment variables?")
323 .with_default(false)
324 .with_help_message("Configure env vars / secrets for the deployment")
325 .prompt()
326 {
327 Ok(v) => v,
328 Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
329 return Vec::new();
330 }
331 Err(_) => return Vec::new(),
332 };
333
334 if !wants_env_vars {
335 return Vec::new();
336 }
337
338 let discovered = discover_env_files(project_path);
340
341 let mut options: Vec<String> = Vec::new();
343
344 if !discovered.is_empty() {
345 println!(
346 "\n Found {} .env file(s):\n",
347 discovered.len().to_string().cyan()
348 );
349 for f in &discovered {
350 let abs = project_path.join(f);
351 let count = count_env_vars_in_file(&abs);
352 let label = format!(" {:<30} {} vars", f.display(), count.to_string().cyan());
353 println!(" {}", label);
354 options.push(format!("{:<30} {} vars", f.display(), count));
355 }
356 println!();
357 }
358
359 options.push("Enter path manually...".to_string());
360 options.push("Manual entry (key/value)".to_string());
361
362 let method = match Select::new("How would you like to add env vars?", options.clone()).prompt()
363 {
364 Ok(m) => m,
365 Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
366 return Vec::new();
367 }
368 Err(_) => return Vec::new(),
369 };
370
371 if method == "Manual entry (key/value)" {
372 return collect_env_vars_manually();
373 }
374
375 if method == "Enter path manually..." {
376 return collect_env_vars_from_file(project_path, None);
377 }
378
379 let idx = options.iter().position(|o| o == &method).unwrap_or(0);
381 if idx < discovered.len() {
382 let rel = &discovered[idx];
383 let abs = project_path.join(rel);
384 collect_env_vars_from_file(project_path, Some(&abs))
385 } else {
386 Vec::new()
387 }
388}
389
390fn collect_env_vars_manually() -> Vec<DeploymentSecretInput> {
392 let mut secrets = Vec::new();
393
394 loop {
395 let key = match Text::new("Variable name:")
396 .with_help_message("e.g., DATABASE_URL, API_KEY, NODE_ENV")
397 .prompt()
398 {
399 Ok(k) if k.trim().is_empty() => break,
400 Ok(k) => k.trim().to_uppercase(),
401 Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
402 break;
403 }
404 Err(_) => break,
405 };
406
407 let value = match Text::new("Value:")
408 .with_help_message("The environment variable value")
409 .prompt()
410 {
411 Ok(v) => v,
412 Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
413 break;
414 }
415 Err(_) => break,
416 };
417
418 let is_secret = match Confirm::new("Is this a secret?")
419 .with_default(is_likely_secret(&key))
420 .with_help_message("Secrets are masked in UI and API responses")
421 .prompt()
422 {
423 Ok(v) => v,
424 Err(_) => is_likely_secret(&key),
425 };
426
427 println!(
428 " {} {} {}",
429 "✓".green(),
430 key.cyan(),
431 if is_secret {
432 "(secret)".dimmed().to_string()
433 } else {
434 "".to_string()
435 }
436 );
437
438 secrets.push(DeploymentSecretInput {
439 key,
440 value,
441 is_secret,
442 });
443
444 let add_another = Confirm::new("Add another?")
445 .with_default(false)
446 .prompt()
447 .unwrap_or_default();
448
449 if !add_another {
450 break;
451 }
452 }
453
454 secrets
455}
456
457fn collect_env_vars_from_file(
462 project_path: &Path,
463 resolved_path: Option<&Path>,
464) -> Vec<DeploymentSecretInput> {
465 let (abs_path, display_path) = if let Some(p) = resolved_path {
466 (p.to_path_buf(), p.display().to_string())
467 } else {
468 let file_path = match Text::new("Path to .env file:")
469 .with_default(".env")
470 .with_help_message("Relative or absolute path to your .env file")
471 .prompt()
472 {
473 Ok(p) => p,
474 Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
475 return Vec::new();
476 }
477 Err(_) => return Vec::new(),
478 };
479 let p = Path::new(&file_path);
480 let abs = if p.is_absolute() {
481 p.to_path_buf()
482 } else {
483 project_path.join(p)
484 };
485 (abs, file_path)
486 };
487
488 let content = match std::fs::read_to_string(&abs_path) {
489 Ok(c) => c,
490 Err(e) => {
491 println!("{} Failed to read file: {}", "✗".red(), e);
492 return Vec::new();
493 }
494 };
495
496 let secrets: Vec<DeploymentSecretInput> = content
497 .lines()
498 .filter_map(|line| {
499 let line = line.trim();
500 if line.is_empty() || line.starts_with('#') {
502 return None;
503 }
504 let (key, value) = line.split_once('=')?;
506 let key = key.trim().to_string();
507 let value = value.trim().to_string();
508 let value = value
510 .strip_prefix('"')
511 .and_then(|v| v.strip_suffix('"'))
512 .map(|v| v.to_string())
513 .or_else(|| {
514 value
515 .strip_prefix('\'')
516 .and_then(|v| v.strip_suffix('\''))
517 .map(|v| v.to_string())
518 })
519 .unwrap_or(value);
520
521 if key.is_empty() {
522 return None;
523 }
524
525 Some(DeploymentSecretInput {
526 is_secret: is_likely_secret(&key),
527 key,
528 value,
529 })
530 })
531 .collect();
532
533 if secrets.is_empty() {
534 println!("{} No variables found in file", "⚠".yellow());
535 return Vec::new();
536 }
537
538 println!();
540 println!(
541 " Loaded {} variable(s) from {}:",
542 secrets.len().to_string().cyan(),
543 display_path.dimmed()
544 );
545 for s in &secrets {
546 if s.is_secret {
547 println!(
548 " {} {} {}",
549 "•".dimmed(),
550 s.key.cyan(),
551 "(secret)".dimmed()
552 );
553 } else {
554 println!(" {} {}", "•".dimmed(), s.key.cyan());
555 }
556 }
557 println!();
558
559 let secret_count = secrets.iter().filter(|s| s.is_secret).count();
560 let plain_count = secrets.len() - secret_count;
561 if secret_count > 0 {
562 println!(
563 " {} {} secret(s), {} plain variable(s)",
564 "ℹ".blue(),
565 secret_count.to_string().yellow(),
566 plain_count.to_string().cyan()
567 );
568 }
569
570 let confirm = Confirm::new("Use these variables?")
571 .with_default(true)
572 .prompt()
573 .unwrap_or_default();
574
575 if confirm { secrets } else { Vec::new() }
576}
577
578fn is_likely_secret(key: &str) -> bool {
580 let key_upper = key.to_uppercase();
581 let secret_patterns = [
582 "_KEY",
583 "_SECRET",
584 "_TOKEN",
585 "_PASSWORD",
586 "_PASSWD",
587 "_PWD",
588 "DATABASE_URL",
589 "REDIS_URL",
590 "MONGODB_URI",
591 "CONNECTION_STRING",
592 "_CREDENTIALS",
593 "_AUTH",
594 "_PRIVATE",
595 "API_KEY",
596 "APIKEY",
597 ];
598 secret_patterns.iter().any(|p| key_upper.contains(p))
599}
600
601fn get_current_branch() -> Option<String> {
603 std::process::Command::new("git")
604 .args(["rev-parse", "--abbrev-ref", "HEAD"])
605 .output()
606 .ok()
607 .and_then(|output| {
608 if output.status.success() {
609 String::from_utf8(output.stdout)
610 .ok()
611 .map(|s| s.trim().to_string())
612 } else {
613 None
614 }
615 })
616}
617
618fn sanitize_service_name(name: &str) -> String {
620 name.to_lowercase()
621 .chars()
622 .map(|c| {
623 if c.is_alphanumeric() || c == '-' {
624 c
625 } else {
626 '-'
627 }
628 })
629 .collect::<String>()
630 .trim_matches('-')
631 .to_string()
632}
633
634#[cfg(test)]
635mod tests {
636 use super::*;
637
638 #[test]
639 fn test_sanitize_service_name() {
640 assert_eq!(sanitize_service_name("My Service"), "my-service");
641 assert_eq!(sanitize_service_name("foo_bar"), "foo-bar");
642 assert_eq!(sanitize_service_name("--test--"), "test");
643 assert_eq!(sanitize_service_name("API Server"), "api-server");
644 }
645
646 #[test]
647 fn test_config_form_result_variants() {
648 let config = WizardDeploymentConfig::default();
649 let _ = ConfigFormResult::Completed(config);
650 let _ = ConfigFormResult::Back;
651 let _ = ConfigFormResult::Cancelled;
652 }
653}