Skip to main content

ferro_cli/templates/
do.rs

1//! Phase 122.2 §4: `.do/app.yaml` starter renderer.
2//!
3//! Pure string substitution over `files/do/app.yaml.tpl`. Caller is
4//! responsible for I/O (reading Cargo.toml, .env.production, git remote).
5
6// Renderer is pure and I/O-free. Web-bin resolution uses
7// `crate::deploy::bin_detect::detect_web_bin` at the caller
8// (`commands/do_init.rs`) so that the Dockerfile ENTRYPOINT and the DO web
9// service stay in sync by construction (ferro 127, D-02).
10#[cfg(test)]
11use crate::deploy::env_production::parse_env_example_structured;
12use crate::deploy::env_production::EnvLine;
13use crate::deploy::secret_keys::is_secret_key;
14
15const TEMPLATE: &str = include_str!("files/do/app.yaml.tpl");
16
17/// Inputs for [`render_app_yaml`]. All fields are pre-resolved by the caller.
18pub struct AppYamlContext {
19    /// Sanitized DO app name (output of [`sanitize_do_app_name`]).
20    pub name: String,
21    /// `owner/repo` for `github.repo`, or fallback placeholder.
22    pub repo: String,
23    /// The bin matching the package name — kept for symmetry, not currently
24    /// emitted (the `web` service is hardcoded in the template).
25    pub web_bin: String,
26    /// Non-web, non-test-like bins to emit as workers.
27    pub workers: Vec<String>,
28    /// Structured `.env.example` lines (keys + blank separators). `None` when
29    /// `.env.example` is missing — the renderer emits an empty envs block and
30    /// the caller logs a warning (D-06 graceful missing-file path).
31    pub env_lines: Option<Vec<EnvLine>>,
32    /// Identity override: DO App Platform app name from an existing file.
33    /// When `Some`, takes precedence over `name` (derived from package name).
34    pub preserved_name: Option<String>,
35    /// Identity override: DO region slug from an existing file.
36    /// When `Some`, takes precedence over the default (`fra1`).
37    pub preserved_region: Option<String>,
38    /// Identity override: `github.repo` from an existing file.
39    /// When `Some`, takes precedence over the git-remote-derived `repo`.
40    pub preserved_github_repo: Option<String>,
41    /// Identity override: `github.branch` from an existing file.
42    /// When `Some`, takes precedence over the default (`main`).
43    pub preserved_github_branch: Option<String>,
44}
45
46/// Render `.do/app.yaml` from a fully-resolved context.
47pub fn render_app_yaml(ctx: &AppYamlContext) -> String {
48    let workers_block = render_workers_block(&ctx.workers);
49    let envs_block = match &ctx.env_lines {
50        Some(lines) => render_envs_block_from_lines(lines),
51        None => String::new(),
52    };
53
54    // Preserved identity fields take precedence over derived defaults.
55    let name = ctx.preserved_name.as_deref().unwrap_or(ctx.name.as_str());
56    let region = ctx.preserved_region.as_deref().unwrap_or("fra1");
57    let repo = ctx
58        .preserved_github_repo
59        .as_deref()
60        .unwrap_or(ctx.repo.as_str());
61    let branch = ctx.preserved_github_branch.as_deref().unwrap_or("main");
62
63    let rendered = TEMPLATE
64        .replace("{{NAME}}", name)
65        .replace("{{REGION}}", region)
66        .replace("{{REPO}}", repo)
67        .replace("{{GITHUB_BRANCH}}", branch)
68        .replace("{{WORKERS_BLOCK}}", &workers_block)
69        .replace("{{ENVS_BLOCK}}", &envs_block);
70    debug_assert!(
71        !rendered.contains("{{"),
72        "unresolved template token in rendered .do/app.yaml"
73    );
74    rendered
75}
76
77/// Test helper: render an envs block from raw `.env.example` contents.
78#[cfg(test)]
79fn render_envs_block(env_example_contents: &str) -> String {
80    let lines = parse_env_example_structured(env_example_contents);
81    render_envs_block_from_lines(&lines)
82}
83
84fn render_envs_block_from_lines(lines: &[EnvLine]) -> String {
85    let mut out = String::new();
86    let indent = "  "; // child of `envs:` — matches template indent level
87    for line in lines {
88        match line {
89            EnvLine::Key(key) => {
90                out.push_str(indent);
91                out.push_str("- key: ");
92                out.push_str(key);
93                out.push('\n');
94                out.push_str(indent);
95                out.push_str("  value: \"\"\n");
96                if is_secret_key(key) {
97                    out.push_str(indent);
98                    out.push_str("  type: SECRET\n");
99                    out.push_str(indent);
100                    out.push_str("  scope: RUN_AND_BUILD_TIME\n");
101                } else {
102                    out.push_str(indent);
103                    out.push_str("  scope: RUN_TIME\n");
104                }
105            }
106            EnvLine::Blank => {
107                out.push('\n');
108            }
109            EnvLine::Comment => {
110                // Comments in .env.example are dropped; blank-line separators
111                // carry the grouping into the generated YAML.
112            }
113        }
114    }
115    while out.ends_with('\n') {
116        out.pop();
117    }
118    out
119}
120
121fn render_workers_block(workers: &[String]) -> String {
122    if workers.is_empty() {
123        // SCOPE §4: emit a commented example so users see the shape.
124        return "\
125# workers: (one entry per non-test/dev/debug [[bin]] other than the service)
126# workers:
127#   - name: example-worker
128#     dockerfile_path: Dockerfile
129#     source_dir: /
130#     run_command: /usr/local/bin/example-worker
131#     instance_size_slug: apps-s-1vcpu-0.5gb
132#     instance_count: 1
133"
134        .to_string();
135    }
136
137    let mut out = String::from(
138        "# workers: (one entry per non-test/dev/debug [[bin]] other than the service)\nworkers:\n",
139    );
140    for name in workers {
141        out.push_str(&format!(
142            "  - name: {name}\n    dockerfile_path: Dockerfile\n    source_dir: /\n    run_command: /usr/local/bin/{name}\n    instance_size_slug: apps-s-1vcpu-0.5gb\n    instance_count: 1\n"
143        ));
144    }
145    out
146}
147
148/// Sanitize a package name to a DigitalOcean-compatible app name.
149///
150/// Kept because DO rejects otherwise-valid names with a cryptic remote
151/// error; local sanitization gives a fast-fail with a clear reason. This is
152/// the only sanitizer surviving Phase 122.2.
153pub fn sanitize_do_app_name(package_name: &str) -> String {
154    let lowered = package_name.to_lowercase();
155    let mut out = String::with_capacity(lowered.len());
156    for c in lowered.chars() {
157        if c.is_ascii_lowercase() || c.is_ascii_digit() {
158            out.push(c);
159        } else if c == '-' || c == '_' || c == ' ' {
160            out.push('-');
161        }
162        // Other chars stripped.
163    }
164    // Collapse runs of dashes.
165    let mut collapsed = String::with_capacity(out.len());
166    let mut prev_dash = false;
167    for c in out.chars() {
168        if c == '-' {
169            if !prev_dash {
170                collapsed.push(c);
171            }
172            prev_dash = true;
173        } else {
174            collapsed.push(c);
175            prev_dash = false;
176        }
177    }
178    collapsed.trim_matches('-').to_string()
179}
180
181/// Parse a git remote URL (HTTPS or SSH form) to `"owner/repo"`. GitHub only.
182pub fn parse_git_remote(remote_url: &str) -> Option<String> {
183    let url = remote_url.trim();
184    let tail = if let Some(rest) = url.strip_prefix("https://github.com/") {
185        rest
186    } else if let Some(rest) = url.strip_prefix("git@github.com:") {
187        rest
188    } else {
189        return None;
190    };
191    let tail = tail.strip_suffix(".git").unwrap_or(tail);
192    let mut parts = tail.splitn(3, '/');
193    let owner = parts.next()?;
194    let repo = parts.next()?;
195    if owner.is_empty() || repo.is_empty() {
196        return None;
197    }
198    Some(format!("{owner}/{repo}"))
199}
200
201/// Heuristic: filter test/dev/debug bins from the workers block.
202///
203/// This is the ONLY surviving heuristic after the Phase 122.2 simplification.
204/// Kept because the alternative (explicit workers list in
205/// `[package.metadata.ferro.deploy]`) would duplicate the `[[bin]]` entries
206/// the user already wrote in Cargo.toml. See Phase 122.2 SCOPE §4.
207pub fn is_test_like_bin(name: &str) -> bool {
208    const PREFIXES: &[&str] = &["test_", "test-", "dev_", "dev-", "debug_", "debug-"];
209    PREFIXES.iter().any(|p| name.starts_with(p))
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    fn ctx(name: &str, repo: &str, workers: Vec<&str>, envs: Vec<&str>) -> AppYamlContext {
217        AppYamlContext {
218            name: name.to_string(),
219            repo: repo.to_string(),
220            web_bin: name.to_string(),
221            workers: workers.into_iter().map(String::from).collect(),
222            env_lines: Some(
223                envs.into_iter()
224                    .map(|k| EnvLine::Key(k.to_string()))
225                    .collect(),
226            ),
227            preserved_name: None,
228            preserved_region: None,
229            preserved_github_repo: None,
230            preserved_github_branch: None,
231        }
232    }
233
234    pub(super) fn ctx_without_env(name: &str, repo: &str) -> AppYamlContext {
235        AppYamlContext {
236            name: name.to_string(),
237            repo: repo.to_string(),
238            web_bin: name.to_string(),
239            workers: Vec::new(),
240            env_lines: None,
241            preserved_name: None,
242            preserved_region: None,
243            preserved_github_repo: None,
244            preserved_github_branch: None,
245        }
246    }
247
248    #[test]
249    fn sanitize_simple_passthrough() {
250        assert_eq!(sanitize_do_app_name("gestiscilo"), "gestiscilo");
251    }
252
253    #[test]
254    fn sanitize_lowercases_and_replaces_underscores_and_spaces() {
255        assert_eq!(sanitize_do_app_name("My_Cool App"), "my-cool-app");
256    }
257
258    #[test]
259    fn sanitize_collapses_dashes() {
260        assert_eq!(sanitize_do_app_name("foo__bar"), "foo-bar");
261        assert_eq!(sanitize_do_app_name("foo---bar"), "foo-bar");
262    }
263
264    #[test]
265    fn sanitize_strips_non_alphanum() {
266        assert_eq!(sanitize_do_app_name("X!@#"), "x");
267    }
268
269    #[test]
270    fn parse_git_remote_https_with_dot_git() {
271        assert_eq!(
272            parse_git_remote("https://github.com/owner/repo.git"),
273            Some("owner/repo".to_string())
274        );
275    }
276
277    #[test]
278    fn parse_git_remote_https_no_dot_git() {
279        assert_eq!(
280            parse_git_remote("https://github.com/owner/repo"),
281            Some("owner/repo".to_string())
282        );
283    }
284
285    #[test]
286    fn parse_git_remote_ssh_with_dot_git() {
287        assert_eq!(
288            parse_git_remote("git@github.com:owner/repo.git"),
289            Some("owner/repo".to_string())
290        );
291    }
292
293    #[test]
294    fn parse_git_remote_ssh_no_dot_git() {
295        assert_eq!(
296            parse_git_remote("git@github.com:owner/repo"),
297            Some("owner/repo".to_string())
298        );
299    }
300
301    #[test]
302    fn parse_git_remote_rejects_non_github() {
303        assert_eq!(parse_git_remote("https://gitlab.com/x/y"), None);
304    }
305
306    #[test]
307    fn is_test_like_bin_matches_prefixes() {
308        for n in [
309            "test_foo",
310            "test-foo",
311            "dev_foo",
312            "dev-foo",
313            "debug_foo",
314            "debug-foo",
315        ] {
316            assert!(is_test_like_bin(n), "expected {n} to be test-like");
317        }
318    }
319
320    #[test]
321    fn is_test_like_bin_rejects_normal_names() {
322        for n in ["web", "worker", "screenshot-worker", "api"] {
323            assert!(!is_test_like_bin(n));
324        }
325    }
326
327    #[test]
328    fn render_app_yaml_contains_static_fields() {
329        let c = ctx("myapp", "owner/repo", vec![], vec![]);
330        let out = render_app_yaml(&c);
331        assert!(out.starts_with("# Generated by ferro do:init — edit to your needs"));
332        assert!(out.contains("name: myapp"));
333        assert!(out.contains("region: fra1"));
334        assert!(out.contains("repo: owner/repo"));
335        assert!(out.contains("branch: main"));
336        assert!(out.contains("services:"));
337        assert!(out.contains("name: web"));
338        assert!(out.contains("envs:"));
339        assert!(!out.contains("databases:"));
340    }
341
342    #[test]
343    fn render_app_yaml_with_empty_workers_emits_commented_example() {
344        let c = ctx("a", "o/r", vec![], vec![]);
345        let out = render_app_yaml(&c);
346        assert!(out.contains("# workers:"));
347        assert!(out.contains("# workers: (one entry per"));
348    }
349
350    #[test]
351    fn render_app_yaml_emits_each_worker() {
352        let c = ctx(
353            "a",
354            "o/r",
355            vec!["screenshot-worker", "queue-worker"],
356            vec![],
357        );
358        let out = render_app_yaml(&c);
359        assert!(out.contains("workers:\n"));
360        assert!(out.contains("- name: screenshot-worker"));
361        assert!(out.contains("run_command: /usr/local/bin/screenshot-worker"));
362        assert!(out.contains("- name: queue-worker"));
363        assert!(out.contains("run_command: /usr/local/bin/queue-worker"));
364    }
365
366    #[test]
367    fn render_app_yaml_emits_real_envs_entries() {
368        let c = ctx(
369            "a",
370            "o/r",
371            vec![],
372            vec!["APP_ENV", "APP_URL", "DATABASE_URL"],
373        );
374        let out = render_app_yaml(&c);
375        assert!(out.contains("- key: APP_ENV"));
376        assert!(out.contains("- key: APP_URL"));
377        assert!(out.contains("- key: DATABASE_URL"));
378        assert!(!out.contains("# - APP_ENV"));
379    }
380}
381
382#[cfg(test)]
383mod envs_block_tests {
384    use super::*;
385
386    #[test]
387    fn envs_block_from_env_example() {
388        let src = "DATABASE_URL=\nSTRIPE_SECRET_KEY=\nAPP_NAME=\n";
389        let out = render_envs_block(src);
390        assert!(out.contains("- key: DATABASE_URL"));
391        assert!(out.contains("- key: STRIPE_SECRET_KEY"));
392        assert!(out.contains("- key: APP_NAME"));
393    }
394
395    #[test]
396    fn secret_scope_and_type() {
397        let src = "DATABASE_URL=\nSTRIPE_SECRET_KEY=\nAPP_NAME=\n";
398        let out = render_envs_block(src);
399
400        let stripe_idx = out.find("- key: STRIPE_SECRET_KEY").unwrap();
401        let stripe_rest = &out[stripe_idx..];
402        // Slice up to the next `- key: ` (or end of string).
403        let stripe_end = stripe_rest[1..]
404            .find("- key: ")
405            .map(|i| i + 1)
406            .unwrap_or(stripe_rest.len());
407        let stripe_slice = &stripe_rest[..stripe_end];
408        assert!(
409            stripe_slice.contains("type: SECRET"),
410            "STRIPE_SECRET_KEY must have type: SECRET, got: {stripe_slice}"
411        );
412        assert!(
413            stripe_slice.contains("scope: RUN_AND_BUILD_TIME"),
414            "STRIPE_SECRET_KEY must have scope: RUN_AND_BUILD_TIME"
415        );
416
417        let db_idx = out.find("- key: DATABASE_URL").unwrap();
418        let db_rest = &out[db_idx..];
419        let db_end = db_rest[1..]
420            .find("- key: ")
421            .map(|i| i + 1)
422            .unwrap_or(db_rest.len());
423        let db_slice = &db_rest[..db_end];
424        assert!(
425            !db_slice.contains("type: SECRET"),
426            "DATABASE_URL must NOT have type: SECRET"
427        );
428        assert!(
429            db_slice.contains("scope: RUN_TIME"),
430            "DATABASE_URL must have scope: RUN_TIME"
431        );
432    }
433
434    #[test]
435    fn envs_preserve_source_order() {
436        let src = "Z_NAME=\nA_NAME=\nM_NAME=\n";
437        let out = render_envs_block(src);
438        let z = out.find("Z_NAME").unwrap();
439        let a = out.find("A_NAME").unwrap();
440        let m = out.find("M_NAME").unwrap();
441        assert!(z < a && a < m);
442    }
443
444    #[test]
445    fn envs_preserve_blank_separators() {
446        let src = "A_NAME=\n\nB_NAME=\n";
447        let out = render_envs_block(src);
448        let a = out.find("- key: A_NAME").unwrap();
449        let b = out.find("- key: B_NAME").unwrap();
450        assert!(
451            out[a..b].contains("\n\n"),
452            "expected blank line separator between A_NAME and B_NAME"
453        );
454    }
455}
456
457#[cfg(test)]
458mod app_yaml_structure_tests {
459    use super::tests::ctx_without_env;
460    use super::*;
461
462    #[test]
463    fn web_service_has_no_run_command() {
464        let c = AppYamlContext {
465            name: "x".into(),
466            repo: "o/r".into(),
467            web_bin: "x".into(),
468            workers: Vec::new(),
469            env_lines: Some(vec![EnvLine::Key("APP_NAME".into())]),
470            preserved_name: None,
471            preserved_region: None,
472            preserved_github_repo: None,
473            preserved_github_branch: None,
474        };
475        let out = render_app_yaml(&c);
476        // Locate the web service block (between `services:` and the workers block).
477        let services_idx = out.find("services:").expect("services: block");
478        let workers_idx = out[services_idx..]
479            .find("# workers:")
480            .map(|i| services_idx + i)
481            .unwrap_or(out.len());
482        let web_block = &out[services_idx..workers_idx];
483        assert!(
484            !web_block.contains("run_command:"),
485            "web service must not set run_command (D-05), got: {web_block}"
486        );
487    }
488
489    #[test]
490    fn web_service_has_entrypoint_comment() {
491        let c = AppYamlContext {
492            name: "x".into(),
493            repo: "o/r".into(),
494            web_bin: "x".into(),
495            workers: Vec::new(),
496            env_lines: Some(vec![EnvLine::Key("APP_NAME".into())]),
497            preserved_name: None,
498            preserved_region: None,
499            preserved_github_repo: None,
500            preserved_github_branch: None,
501        };
502        let out = render_app_yaml(&c);
503        assert!(
504            out.contains("Dockerfile ENTRYPOINT"),
505            "expected inline comment pointing at Dockerfile ENTRYPOINT"
506        );
507    }
508
509    #[test]
510    fn envs_missing_env_example_emits_empty_block() {
511        let c = ctx_without_env("x", "o/r");
512        let out = render_app_yaml(&c);
513        assert!(
514            !out.contains("- key: "),
515            "expected empty envs block when .env.example missing"
516        );
517        // The `envs:` header itself still renders.
518        assert!(out.contains("envs:"));
519    }
520}