Skip to main content

syncable_cli/wizard/
config_form.rs

1//! Deployment configuration form for the wizard
2
3use 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
26/// Discover `.env` files in the project directory (max depth 3, skipping common build dirs).
27///
28/// Returns paths relative to `root`, sorted.
29pub 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/// Parsed entry from a `.env` file.
59#[derive(Debug, Clone)]
60pub struct EnvFileEntry {
61    pub key: String,
62    pub value: String,
63    pub is_secret: bool,
64}
65
66/// Parse a `.env` file into key/value entries.
67///
68/// Skips empty lines and comments (`#`). Strips surrounding quotes from values.
69/// Each entry is tagged with `is_secret` based on key patterns.
70pub 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
106/// Count non-empty, non-comment KEY=VALUE lines in a file.
107fn 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/// Result of config form step
121#[derive(Debug, Clone)]
122pub enum ConfigFormResult {
123    /// User completed the form
124    Completed(WizardDeploymentConfig),
125    /// User wants to go back
126    Back,
127    /// User cancelled the wizard
128    Cancelled,
129}
130
131/// Collect deployment configuration details from user
132///
133/// Region, machine type, Dockerfile path, and build context are already selected
134/// in previous steps. This form collects service name, port, branch, public access,
135/// health check, and auto-deploy settings.
136#[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    // Show previously selected options
159    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    // Pre-populate from discovery
179    let default_name = discovered_dockerfile.suggested_service_name.clone();
180    let default_port = discovered_dockerfile.suggested_port.unwrap_or(8080);
181
182    // Get current git branch for default
183    let default_branch = get_current_branch().unwrap_or_else(|| "main".to_string());
184
185    // Service name
186    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    // Port
199    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    // Branch
213    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    // Public access toggle (for Cloud Runner)
226    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 // Default to public for K8s
245    };
246
247    // Health check (optional)
248    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    // Auto-deploy disabled by default (CI/CD not ready yet)
279    let auto_deploy = false;
280
281    // Build the config
282    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(), // Populated by collect_env_vars() in orchestrator
301    };
302
303    println!("\n{} Configuration complete: {}", "✓".green(), service_name);
304
305    ConfigFormResult::Completed(config)
306}
307
308/// Collect environment variables interactively
309///
310/// Auto-discovers `.env` files in the project directory and presents them
311/// as selectable options alongside manual entry. Uses `is_likely_secret()`
312/// per-key instead of marking all values as secret.
313///
314/// Returns collected env vars, or empty vec if user skips.
315pub 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    // Auto-discover .env files
339    let discovered = discover_env_files(project_path);
340
341    // Build select options
342    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    // User picked a discovered file — extract the path portion (before the var count)
380    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
390/// Collect env vars via manual key/value entry
391fn 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
457/// Collect env vars by loading and parsing a .env file.
458///
459/// If `resolved_path` is `Some`, the file is read directly (user picked a discovered file).
460/// Otherwise the user is prompted for a path.
461fn 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            // Skip empty lines and comments
501            if line.is_empty() || line.starts_with('#') {
502                return None;
503            }
504            // Parse KEY=VALUE (handle quoted values)
505            let (key, value) = line.split_once('=')?;
506            let key = key.trim().to_string();
507            let value = value.trim().to_string();
508            // Strip surrounding quotes from value
509            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    // Show loaded keys (NOT values) for confirmation
539    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
578/// Check if a key name looks like it should be a secret
579fn 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
601/// Get current git branch name
602fn 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
618/// Sanitize service name for K8s compatibility
619fn 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}