Skip to main content

ferro_cli/commands/
do_init.rs

1//! `ferro do:init` — generate `.do/app.yaml` starter (Phase 122.2 §5).
2//!
3//! Writes only `.do/app.yaml`. CI workflow generation lives in
4//! `ferro ci:init` (decoupled per SCOPE §7). Hard-errors when
5//! `.env.production` is missing.
6
7use 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
24/// Full entry point supporting `--dry-run`.
25pub 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
32/// Library-level entry point used by integration tests. Returns `Result`.
33pub 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    // Phase 127 D-06: derive envs from `.env.example` (the shape source of
55    // truth) rather than `.env.production` (which stays on the dev machine).
56    // Missing `.env.example` is a warning, not an error — the rendered envs:
57    // block will simply be empty.
58    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    // Phase 131 REQ-131-06: preserve identity fields from an existing
72    // .do/app.yaml when re-rendering with --force. The parsed fields override
73    // the scaffolder defaults (name from package, region fra1, repo from git
74    // remote, branch main).
75    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        // D-17: render every output to memory and print with headers.
99        // Render errors remain hard errors (D-19).
100        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    // D-13, D-15: cargo-style "Next steps" footer.
113    print!("{}", do_init_footer());
114    Ok(())
115}
116
117/// Build the cargo-style "Next steps" footer for `do:init`. Pure string —
118/// the `print!` call site exists so tests can assert on the content directly.
119fn 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        // D-19: --dry-run must not demote render errors to soft warnings.
202        // Running run_inner in an empty tempdir (no Cargo.toml) hits the
203        // hard error at find_project_root.
204        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    /// REQ-131-06: `do:init --force` preserves identity fields from an existing
216    /// `.do/app.yaml` with non-default name/region/repo/branch.
217    #[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        // Seed a minimal project.
226        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        // Seed an existing .do/app.yaml with non-default identity fields.
234        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        // Phase 127 D-06: missing .env.example is a warning, not an error.
289        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}