Skip to main content

ferro_cli/commands/
deploy_init.rs

1//! `ferro deploy:init` — interactive scaffolder for
2//! `[package.metadata.ferro.deploy]` (Phase 128 D-07..D-11).
3
4use crate::commands::docker_init::{print_dry_run, RenderedFile};
5use crate::deploy::bin_detect::detect_web_bin;
6use crate::project::find_project_root;
7use anyhow::{anyhow, Context};
8use console::style;
9use dialoguer::{theme::ColorfulTheme, Input, Select};
10use std::fs;
11use std::io::IsTerminal;
12use std::path::{Path, PathBuf};
13use toml_edit::{Array, DocumentMut, Item, Table, Value};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum OnExists {
17    Abort,
18    Overwrite,
19    Merge,
20}
21
22#[derive(Debug, Clone)]
23pub struct DeployInitOpts {
24    pub yes: bool,
25    pub dry_run: bool,
26    pub on_exists_override: Option<OnExists>,
27}
28
29#[derive(Debug, Clone)]
30pub struct DeployDefaults {
31    pub web_bin: String,
32    pub copy_dirs: Vec<String>,
33    pub runtime_apt: Vec<String>,
34}
35
36pub fn run(yes: bool, dry_run: bool) {
37    run_with(DeployInitOpts {
38        yes,
39        dry_run,
40        on_exists_override: None,
41    });
42}
43
44pub fn run_with(opts: DeployInitOpts) {
45    if let Err(e) = execute(opts) {
46        eprintln!("{} {e}", style("Error:").red().bold());
47        std::process::exit(1);
48    }
49}
50
51pub fn execute(opts: DeployInitOpts) -> anyhow::Result<()> {
52    let root = find_project_root(None)
53        .map_err(|_| anyhow!("Cargo.toml not found (searched upward from CWD)"))?;
54
55    let is_tty = std::io::stdin().is_terminal();
56    if !is_tty && !opts.yes {
57        return Err(anyhow!("ferro deploy:init requires a TTY or --yes"));
58    }
59
60    // --- compute defaults ---
61    let web_bin = detect_web_bin(&root)
62        .context("failed to auto-detect web binary — declare [[bin]] in Cargo.toml or pass --yes with a valid project")?;
63    let copy_dirs_candidates = ["migrations", "static"];
64    let copy_dirs: Vec<String> = copy_dirs_candidates
65        .iter()
66        .filter(|d| root.join(d).is_dir())
67        .map(|s| s.to_string())
68        .collect();
69    let mut defaults = DeployDefaults {
70        web_bin: web_bin.clone(),
71        copy_dirs,
72        runtime_apt: Vec::new(),
73    };
74
75    // --- interactive refinement ---
76    if !opts.yes {
77        let theme = ColorfulTheme::default();
78        defaults.web_bin = Input::<String>::with_theme(&theme)
79            .with_prompt("Web binary name")
80            .default(defaults.web_bin.clone())
81            .interact_text()?;
82        let copy_dirs_str: String = Input::with_theme(&theme)
83            .with_prompt("copy_dirs (comma-separated, blank for none)")
84            .default(defaults.copy_dirs.join(","))
85            .allow_empty(true)
86            .interact_text()?;
87        defaults.copy_dirs = copy_dirs_str
88            .split(',')
89            .map(|s| s.trim().to_string())
90            .filter(|s| !s.is_empty())
91            .collect();
92        let runtime_apt_str: String = Input::with_theme(&theme)
93            .with_prompt("runtime_apt packages (comma-separated, blank for none)")
94            .default(String::new())
95            .allow_empty(true)
96            .interact_text()?;
97        defaults.runtime_apt = runtime_apt_str
98            .split(',')
99            .map(|s| s.trim().to_string())
100            .filter(|s| !s.is_empty())
101            .collect();
102    }
103
104    let block = compute_deploy_toml_block(&defaults);
105
106    // --- dry run ---
107    if opts.dry_run {
108        let files = [RenderedFile {
109            relative_path: PathBuf::from("Cargo.toml ([package.metadata.ferro.deploy])"),
110            contents: block,
111        }];
112        print_dry_run(&files);
113        return Ok(());
114    }
115
116    // --- resolve on_exists ---
117    let cargo_toml = root.join("Cargo.toml");
118    let existing = fs::read_to_string(&cargo_toml)?;
119    let existing_doc: DocumentMut = existing
120        .parse()
121        .map_err(|e| anyhow!("failed to parse Cargo.toml: {e}"))?;
122    let has_block = existing_doc
123        .get("package")
124        .and_then(|p| p.as_table_like())
125        .and_then(|t| t.get("metadata"))
126        .and_then(|m| m.as_table_like())
127        .and_then(|t| t.get("ferro"))
128        .and_then(|f| f.as_table_like())
129        .and_then(|t| t.get("deploy"))
130        .is_some();
131
132    let on_exists = if has_block {
133        match opts.on_exists_override {
134            Some(choice) => choice,
135            None if opts.yes => OnExists::Abort,
136            None => prompt_on_exists()?,
137        }
138    } else {
139        OnExists::Overwrite // no collision — straight insert
140    };
141
142    persist_deploy_block(&cargo_toml, &defaults, on_exists)?;
143    println!("{} Updated {}", style("✓").green(), cargo_toml.display());
144    print!("{}", deploy_init_footer());
145    Ok(())
146}
147
148fn prompt_on_exists() -> anyhow::Result<OnExists> {
149    let items = &["abort", "overwrite", "merge"];
150    let selection = Select::with_theme(&ColorfulTheme::default())
151        .with_prompt("[package.metadata.ferro.deploy] already exists")
152        .default(0)
153        .items(items)
154        .interact()?;
155    Ok(match selection {
156        0 => OnExists::Abort,
157        1 => OnExists::Overwrite,
158        _ => OnExists::Merge,
159    })
160}
161
162/// Pure compute: format the deploy block as a standalone TOML snippet.
163pub fn compute_deploy_toml_block(d: &DeployDefaults) -> String {
164    let mut s = String::new();
165    s.push_str("[package.metadata.ferro.deploy]\n");
166    s.push_str(&format!(
167        "runtime_apt = {}\n",
168        format_string_array(&d.runtime_apt)
169    ));
170    s.push_str(&format!(
171        "copy_dirs = {}\n",
172        format_string_array(&d.copy_dirs)
173    ));
174    s.push_str(&format!("web_bin = \"{}\"\n", d.web_bin));
175    s
176}
177
178fn format_string_array(v: &[String]) -> String {
179    if v.is_empty() {
180        return "[]".to_string();
181    }
182    let items: Vec<String> = v.iter().map(|s| format!("\"{s}\"")).collect();
183    format!("[{}]", items.join(", "))
184}
185
186/// Persist the deploy block into the given Cargo.toml using toml_edit,
187/// honoring the `on_exists` policy. Aborts with Err on collision when
188/// policy is Abort. Preserves all unrelated keys byte-for-byte.
189pub fn persist_deploy_block(
190    cargo_toml: &Path,
191    d: &DeployDefaults,
192    on_exists: OnExists,
193) -> anyhow::Result<()> {
194    let source = fs::read_to_string(cargo_toml)?;
195    let mut doc: DocumentMut = source
196        .parse()
197        .map_err(|e| anyhow!("failed to parse Cargo.toml: {e}"))?;
198
199    let existed = doc
200        .get("package")
201        .and_then(|p| p.as_table_like())
202        .and_then(|t| t.get("metadata"))
203        .and_then(|m| m.as_table_like())
204        .and_then(|t| t.get("ferro"))
205        .and_then(|f| f.as_table_like())
206        .and_then(|t| t.get("deploy"))
207        .is_some();
208
209    if existed && on_exists == OnExists::Abort {
210        return Err(anyhow!(
211            "[package.metadata.ferro.deploy] already exists (use --yes with --overwrite policy or run interactively)"
212        ));
213    }
214
215    // Ensure parent tables exist (package.metadata.ferro.deploy).
216    if doc.get("package").is_none() {
217        doc["package"] = Item::Table(Table::new());
218    }
219    let pkg = doc["package"].as_table_mut().expect("package is a table");
220    if pkg.get("metadata").is_none() {
221        let mut t = Table::new();
222        t.set_implicit(true);
223        pkg["metadata"] = Item::Table(t);
224    }
225    let metadata = pkg["metadata"].as_table_mut().expect("metadata table");
226    if metadata.get("ferro").is_none() {
227        let mut t = Table::new();
228        t.set_implicit(true);
229        metadata["ferro"] = Item::Table(t);
230    }
231    let ferro = metadata["ferro"].as_table_mut().expect("ferro table");
232
233    if !existed || on_exists == OnExists::Overwrite {
234        let mut deploy = Table::new();
235        deploy["runtime_apt"] = toml_edit::value(string_array(&d.runtime_apt));
236        deploy["copy_dirs"] = toml_edit::value(string_array(&d.copy_dirs));
237        deploy["web_bin"] = toml_edit::value(d.web_bin.clone());
238        ferro["deploy"] = Item::Table(deploy);
239    } else {
240        // Merge: fill in only missing keys.
241        let deploy = ferro["deploy"]
242            .as_table_mut()
243            .ok_or_else(|| anyhow!("[package.metadata.ferro.deploy] is not a table"))?;
244        if deploy.get("runtime_apt").is_none() {
245            deploy["runtime_apt"] = toml_edit::value(string_array(&d.runtime_apt));
246        }
247        if deploy.get("copy_dirs").is_none() {
248            deploy["copy_dirs"] = toml_edit::value(string_array(&d.copy_dirs));
249        }
250        if deploy.get("web_bin").is_none() {
251            deploy["web_bin"] = toml_edit::value(d.web_bin.clone());
252        }
253    }
254
255    fs::write(cargo_toml, doc.to_string())?;
256    Ok(())
257}
258
259fn string_array(v: &[String]) -> Array {
260    let mut arr = Array::new();
261    for s in v {
262        arr.push(Value::from(s.clone()));
263    }
264    arr
265}
266
267fn deploy_init_footer() -> String {
268    "\nNext steps:\n  Review Cargo.toml [package.metadata.ferro.deploy].\n  ferro docker:init\n  ferro doctor --deploy\n".to_string()
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use tempfile::TempDir;
275
276    fn write_cargo(root: &Path, body: &str) -> PathBuf {
277        let p = root.join("Cargo.toml");
278        fs::write(&p, body).unwrap();
279        p
280    }
281
282    fn defaults() -> DeployDefaults {
283        DeployDefaults {
284            web_bin: "myapp".into(),
285            copy_dirs: vec!["migrations".into()],
286            runtime_apt: vec![],
287        }
288    }
289
290    #[test]
291    fn compute_block_formats_expected() {
292        let out = compute_deploy_toml_block(&defaults());
293        assert!(out.contains("[package.metadata.ferro.deploy]"));
294        assert!(out.contains("runtime_apt = []"));
295        assert!(out.contains("copy_dirs = [\"migrations\"]"));
296        assert!(out.contains("web_bin = \"myapp\""));
297    }
298
299    #[test]
300    fn persist_inserts_block_when_absent() {
301        let td = TempDir::new().unwrap();
302        let p = write_cargo(
303            td.path(),
304            "[package]\nname = \"x\"\nversion = \"0.1.0\"\n# keep this comment\n\n[dependencies]\nserde = \"1\"\n",
305        );
306        persist_deploy_block(&p, &defaults(), OnExists::Overwrite).unwrap();
307        let out = fs::read_to_string(&p).unwrap();
308        assert!(out.contains("[package.metadata.ferro.deploy]"));
309        assert!(out.contains("web_bin = \"myapp\""));
310        assert!(
311            out.contains("# keep this comment"),
312            "existing comments preserved"
313        );
314        assert!(out.contains("serde = \"1\""), "other deps preserved");
315    }
316
317    #[test]
318    fn persist_aborts_when_table_exists_and_policy_abort() {
319        let td = TempDir::new().unwrap();
320        let p = write_cargo(
321            td.path(),
322            "[package]\nname = \"x\"\nversion = \"0.1.0\"\n[package.metadata.ferro.deploy]\nweb_bin = \"old\"\n",
323        );
324        let r = persist_deploy_block(&p, &defaults(), OnExists::Abort);
325        assert!(r.is_err());
326        let out = fs::read_to_string(&p).unwrap();
327        assert!(out.contains("web_bin = \"old\""), "file untouched on abort");
328    }
329
330    #[test]
331    fn persist_merge_fills_missing_fields_only() {
332        let td = TempDir::new().unwrap();
333        let p = write_cargo(
334            td.path(),
335            "[package]\nname = \"x\"\nversion = \"0.1.0\"\n[package.metadata.ferro.deploy]\nweb_bin = \"userpick\"\n",
336        );
337        persist_deploy_block(&p, &defaults(), OnExists::Merge).unwrap();
338        let out = fs::read_to_string(&p).unwrap();
339        assert!(
340            out.contains("web_bin = \"userpick\""),
341            "existing field preserved"
342        );
343        assert!(out.contains("runtime_apt = []"));
344        assert!(out.contains("copy_dirs"));
345    }
346
347    #[test]
348    fn persist_overwrite_replaces_fields() {
349        let td = TempDir::new().unwrap();
350        let p = write_cargo(
351            td.path(),
352            "[package]\nname = \"x\"\nversion = \"0.1.0\"\n[package.metadata.ferro.deploy]\nweb_bin = \"old\"\n",
353        );
354        persist_deploy_block(&p, &defaults(), OnExists::Overwrite).unwrap();
355        let out = fs::read_to_string(&p).unwrap();
356        assert!(out.contains("web_bin = \"myapp\""));
357        assert!(!out.contains("web_bin = \"old\""));
358    }
359
360    #[test]
361    fn dry_run_writes_zero_files() {
362        let _guard = crate::commands::CWD_TEST_LOCK
363            .lock()
364            .unwrap_or_else(|e| e.into_inner());
365        let td = TempDir::new().unwrap();
366        let cargo_body =
367            "[package]\nname = \"x\"\nversion = \"0.1.0\"\n\n[[bin]]\nname = \"x\"\npath = \"src/main.rs\"\n";
368        write_cargo(td.path(), cargo_body);
369        fs::create_dir_all(td.path().join("src")).unwrap();
370        fs::write(td.path().join("src/main.rs"), "fn main() {}\n").unwrap();
371        let prev = std::env::current_dir().unwrap();
372        std::env::set_current_dir(td.path()).unwrap();
373        let r = execute(DeployInitOpts {
374            yes: true,
375            dry_run: true,
376            on_exists_override: None,
377        });
378        std::env::set_current_dir(prev).unwrap();
379        assert!(r.is_ok(), "dry-run execute failed: {r:?}");
380        let after = fs::read_to_string(td.path().join("Cargo.toml")).unwrap();
381        assert_eq!(after, cargo_body, "dry-run must not touch Cargo.toml");
382    }
383
384    #[test]
385    fn footer_mentions_next_steps() {
386        let s = deploy_init_footer();
387        assert!(s.contains("ferro docker:init"));
388        assert!(s.contains("ferro doctor --deploy"));
389    }
390}