1use console::style;
8use std::fs;
9use std::path::Path;
10
11use crate::commands::docker_init::{print_dry_run, RenderedFile};
12use crate::deploy::app_yaml_existing::parse_existing;
13use crate::deploy::bin_detect::detect_web_bin;
14use crate::deploy::env_production::parse_env_example_structured;
15use crate::project::{find_project_root, package_name, read_bins};
16use crate::templates::do_::{
17 is_test_like_bin, parse_git_remote, render_app_yaml, sanitize_do_app_name, AppYamlContext,
18};
19
20pub fn run(force: bool) {
21 run_with(force, false);
22}
23
24pub fn run_with(force: bool, dry_run: bool) {
26 if let Err(e) = run_inner(force, dry_run) {
27 eprintln!("{} {e}", style("Error:").red().bold());
28 std::process::exit(1);
29 }
30}
31
32pub fn execute(force: bool, dry_run: bool) -> anyhow::Result<()> {
34 run_inner(force, dry_run)
35}
36
37fn run_inner(force: bool, dry_run: bool) -> anyhow::Result<()> {
38 let root = find_project_root(None)
39 .map_err(|_| anyhow::anyhow!("Cargo.toml not found (searched upward from CWD)"))?;
40 let pkg = package_name(&root);
41 let name = sanitize_do_app_name(&pkg);
42
43 let repo = detect_github_repo(&root).unwrap_or_else(|| "owner/your-repo".to_string());
44
45 let bins: Vec<String> = read_bins(&root).into_iter().map(|b| b.name).collect();
46 let web_bin = detect_web_bin(&root)?;
47 let workers: Vec<String> = bins
48 .iter()
49 .filter(|b| **b != web_bin)
50 .filter(|b| !is_test_like_bin(b))
51 .cloned()
52 .collect();
53
54 let env_example_path = root.join(".env.example");
59 let env_lines = match fs::read_to_string(&env_example_path) {
60 Ok(contents) => Some(parse_env_example_structured(&contents)),
61 Err(_) => {
62 eprintln!(
63 "{} .env.example not found; rendering empty envs: block. \
64 Populate envs in .do/app.yaml before `doctl apps create`.",
65 style("warning:").yellow().bold()
66 );
67 None
68 }
69 };
70
71 let existing_app_yaml = root.join(".do/app.yaml");
76 let preserved = parse_existing(&existing_app_yaml);
77
78 let (preserved_name, preserved_region, preserved_github_repo, preserved_github_branch) =
79 match preserved {
80 Some(id) => (id.name, id.region, id.repo, id.branch),
81 None => (None, None, None, None),
82 };
83
84 let ctx = AppYamlContext {
85 name,
86 repo,
87 web_bin,
88 workers,
89 env_lines,
90 preserved_name,
91 preserved_region,
92 preserved_github_repo,
93 preserved_github_branch,
94 };
95 let yaml = render_app_yaml(&ctx);
96
97 if dry_run {
98 let files = [RenderedFile {
101 relative_path: ".do/app.yaml".into(),
102 contents: yaml,
103 }];
104 print_dry_run(&files);
105 return Ok(());
106 }
107
108 let target = root.join(".do/app.yaml");
109 write_with_force(&target, &yaml, force)?;
110 println!("{} Generated {}", style("✓").green(), target.display());
111
112 print!("{}", do_init_footer());
114 Ok(())
115}
116
117fn do_init_footer() -> String {
120 "\nNext steps:\n Review .do/app.yaml and populate envs.\n doctl apps create --spec .do/app.yaml\n"
121 .to_string()
122}
123
124fn detect_github_repo(root: &Path) -> Option<String> {
125 let out = std::process::Command::new("git")
126 .args(["remote", "get-url", "origin"])
127 .current_dir(root)
128 .output()
129 .ok()?;
130 if !out.status.success() {
131 return None;
132 }
133 let s = String::from_utf8(out.stdout).ok()?;
134 parse_git_remote(s.trim())
135}
136
137fn write_with_force(path: &Path, content: &str, force: bool) -> anyhow::Result<()> {
138 if path.exists() && !force {
139 anyhow::bail!("{} already exists (use --force)", path.display());
140 }
141 if let Some(parent) = path.parent() {
142 fs::create_dir_all(parent)?;
143 }
144 fs::write(path, content)?;
145 Ok(())
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151 use tempfile::TempDir;
152
153 fn write(root: &Path, rel: &str, body: &str) {
154 let p = root.join(rel);
155 if let Some(parent) = p.parent() {
156 fs::create_dir_all(parent).unwrap();
157 }
158 fs::write(p, body).unwrap();
159 }
160
161 #[test]
162 fn write_with_force_refuses_existing() {
163 let td = TempDir::new().unwrap();
164 let p = td.path().join(".do/app.yaml");
165 fs::create_dir_all(p.parent().unwrap()).unwrap();
166 fs::write(&p, "old").unwrap();
167 assert!(write_with_force(&p, "new", false).is_err());
168 assert_eq!(fs::read_to_string(&p).unwrap(), "old");
169 }
170
171 #[test]
172 fn write_with_force_overwrites_with_force() {
173 let td = TempDir::new().unwrap();
174 let p = td.path().join(".do/app.yaml");
175 fs::create_dir_all(p.parent().unwrap()).unwrap();
176 fs::write(&p, "old").unwrap();
177 write_with_force(&p, "new", true).unwrap();
178 assert_eq!(fs::read_to_string(&p).unwrap(), "new");
179 }
180
181 #[test]
182 fn do_init_footer_contents() {
183 let s = do_init_footer();
184 assert!(s.contains("doctl apps create --spec"));
185 assert!(s.contains(".do/app.yaml"));
186 }
187
188 #[test]
189 fn do_init_footer_line_count() {
190 let s = do_init_footer();
191 let n = s.lines().filter(|l| !l.trim().is_empty()).count();
192 assert!((3..=5).contains(&n), "footer has {n} non-empty lines: {s}");
193 assert!(s.is_ascii(), "footer must be ASCII-only");
194 }
195
196 #[test]
197 fn dry_run_propagates_render_error() {
198 let _guard = crate::commands::CWD_TEST_LOCK
199 .lock()
200 .unwrap_or_else(|e| e.into_inner());
201 let td = TempDir::new().unwrap();
205 let prev = std::env::current_dir().unwrap();
206 std::env::set_current_dir(td.path()).unwrap();
207 let result = run_inner(true, true);
208 std::env::set_current_dir(prev).unwrap();
209 assert!(
210 result.is_err(),
211 "dry-run must propagate render errors as Err"
212 );
213 }
214
215 #[test]
218 fn do_init_preserves_identity() {
219 let _guard = crate::commands::CWD_TEST_LOCK
220 .lock()
221 .unwrap_or_else(|e| e.into_inner());
222 let td = TempDir::new().unwrap();
223 let root = td.path();
224
225 write(
227 root,
228 "Cargo.toml",
229 "[package]\nname = \"myapp\"\nversion = \"0.1.0\"\n\n[[bin]]\nname = \"myapp\"\npath = \"src/main.rs\"\n",
230 );
231 write(root, "src/main.rs", "fn main() {}\n");
232
233 let existing_yaml = concat!(
235 "# Generated by ferro do:init — edit to your needs\n",
236 "name: custom-app-name\n",
237 "region: nyc3\n",
238 "\n",
239 "services:\n",
240 " - name: web\n",
241 " dockerfile_path: Dockerfile\n",
242 " source_dir: /\n",
243 " github:\n",
244 " repo: myorg/my-repo\n",
245 " branch: production\n",
246 " deploy_on_push: true\n",
247 " http_port: 8080\n",
248 " instance_size_slug: apps-s-1vcpu-0.5gb\n",
249 " instance_count: 1\n",
250 "\n",
251 "# workers:\n",
252 "envs:\n",
253 );
254 write(root, ".do/app.yaml", existing_yaml);
255
256 let prev = std::env::current_dir().unwrap();
257 std::env::set_current_dir(root).unwrap();
258 let result = run_inner(true, false);
259 std::env::set_current_dir(prev).unwrap();
260
261 assert!(result.is_ok(), "do:init --force should succeed: {result:?}");
262
263 let written = fs::read_to_string(root.join(".do/app.yaml")).expect("app.yaml should exist");
264
265 assert!(
266 written.contains("name: custom-app-name"),
267 "preserved name must survive --force\ngot:\n{written}"
268 );
269 assert!(
270 written.contains("region: nyc3"),
271 "preserved region must survive --force\ngot:\n{written}"
272 );
273 assert!(
274 written.contains("repo: myorg/my-repo"),
275 "preserved repo must survive --force\ngot:\n{written}"
276 );
277 assert!(
278 written.contains("branch: production"),
279 "preserved branch must survive --force\ngot:\n{written}"
280 );
281 }
282
283 #[test]
284 fn run_inner_succeeds_with_missing_env_example() {
285 let _guard = crate::commands::CWD_TEST_LOCK
286 .lock()
287 .unwrap_or_else(|e| e.into_inner());
288 let td = TempDir::new().unwrap();
290 write(
291 td.path(),
292 "Cargo.toml",
293 "[package]\nname = \"sample\"\nversion = \"0.1.0\"\n\n[[bin]]\nname = \"sample\"\npath = \"src/main.rs\"\n",
294 );
295 write(td.path(), "src/main.rs", "fn main() {}\n");
296 let prev = std::env::current_dir().unwrap();
297 std::env::set_current_dir(td.path()).unwrap();
298 let result = run_inner(true, false);
299 std::env::set_current_dir(prev).unwrap();
300 assert!(
301 result.is_ok(),
302 "run_inner must succeed without .env.example: {result:?}"
303 );
304 let yaml = fs::read_to_string(td.path().join(".do/app.yaml")).expect("app.yaml written");
305 assert!(!yaml.contains("- key: "), "envs block must be empty");
306 assert!(yaml.contains("envs:"));
307 }
308}