1use 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 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 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 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 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 };
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
162pub 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
186pub 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 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 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}