const TEMPLATE: &str = include_str!("files/do/app.yaml.tpl");
pub struct AppYamlContext {
pub name: String,
pub repo: String,
pub web_bin: String,
pub workers: Vec<String>,
pub env_keys: Vec<String>,
}
pub fn render_app_yaml(ctx: &AppYamlContext) -> String {
let workers_block = render_workers_block(&ctx.workers);
let env_comments = render_env_comments(&ctx.env_keys);
TEMPLATE
.replace("{{NAME}}", &ctx.name)
.replace("{{REPO}}", &ctx.repo)
.replace("{{WORKERS_BLOCK}}", &workers_block)
.replace("{{ENV_COMMENTS}}", &env_comments)
}
fn render_workers_block(workers: &[String]) -> String {
if workers.is_empty() {
return "\
# workers: (one entry per non-test/dev/debug [[bin]] other than the service)
# workers:
# - name: example-worker
# dockerfile_path: Dockerfile
# source_dir: /
# run_command: /usr/local/bin/example-worker
# instance_size_slug: apps-s-1vcpu-0.5gb
# instance_count: 1
"
.to_string();
}
let mut out = String::from(
"# workers: (one entry per non-test/dev/debug [[bin]] other than the service)\nworkers:\n",
);
for name in workers {
out.push_str(&format!(
" - 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"
));
}
out
}
fn render_env_comments(keys: &[String]) -> String {
if keys.is_empty() {
return " # (no keys discovered in .env.production)".to_string();
}
keys.iter()
.map(|k| format!(" # - {k}"))
.collect::<Vec<_>>()
.join("\n")
}
pub fn sanitize_do_app_name(package_name: &str) -> String {
let lowered = package_name.to_lowercase();
let mut out = String::with_capacity(lowered.len());
for c in lowered.chars() {
if c.is_ascii_lowercase() || c.is_ascii_digit() {
out.push(c);
} else if c == '-' || c == '_' || c == ' ' {
out.push('-');
}
}
let mut collapsed = String::with_capacity(out.len());
let mut prev_dash = false;
for c in out.chars() {
if c == '-' {
if !prev_dash {
collapsed.push(c);
}
prev_dash = true;
} else {
collapsed.push(c);
prev_dash = false;
}
}
collapsed.trim_matches('-').to_string()
}
pub fn parse_git_remote(remote_url: &str) -> Option<String> {
let url = remote_url.trim();
let tail = if let Some(rest) = url.strip_prefix("https://github.com/") {
rest
} else if let Some(rest) = url.strip_prefix("git@github.com:") {
rest
} else {
return None;
};
let tail = tail.strip_suffix(".git").unwrap_or(tail);
let mut parts = tail.splitn(3, '/');
let owner = parts.next()?;
let repo = parts.next()?;
if owner.is_empty() || repo.is_empty() {
return None;
}
Some(format!("{owner}/{repo}"))
}
pub fn is_test_like_bin(name: &str) -> bool {
const PREFIXES: &[&str] = &["test_", "test-", "dev_", "dev-", "debug_", "debug-"];
PREFIXES.iter().any(|p| name.starts_with(p))
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx(name: &str, repo: &str, workers: Vec<&str>, envs: Vec<&str>) -> AppYamlContext {
AppYamlContext {
name: name.to_string(),
repo: repo.to_string(),
web_bin: name.to_string(),
workers: workers.into_iter().map(String::from).collect(),
env_keys: envs.into_iter().map(String::from).collect(),
}
}
#[test]
fn sanitize_simple_passthrough() {
assert_eq!(sanitize_do_app_name("gestiscilo"), "gestiscilo");
}
#[test]
fn sanitize_lowercases_and_replaces_underscores_and_spaces() {
assert_eq!(sanitize_do_app_name("My_Cool App"), "my-cool-app");
}
#[test]
fn sanitize_collapses_dashes() {
assert_eq!(sanitize_do_app_name("foo__bar"), "foo-bar");
assert_eq!(sanitize_do_app_name("foo---bar"), "foo-bar");
}
#[test]
fn sanitize_strips_non_alphanum() {
assert_eq!(sanitize_do_app_name("X!@#"), "x");
}
#[test]
fn parse_git_remote_https_with_dot_git() {
assert_eq!(
parse_git_remote("https://github.com/owner/repo.git"),
Some("owner/repo".to_string())
);
}
#[test]
fn parse_git_remote_https_no_dot_git() {
assert_eq!(
parse_git_remote("https://github.com/owner/repo"),
Some("owner/repo".to_string())
);
}
#[test]
fn parse_git_remote_ssh_with_dot_git() {
assert_eq!(
parse_git_remote("git@github.com:owner/repo.git"),
Some("owner/repo".to_string())
);
}
#[test]
fn parse_git_remote_ssh_no_dot_git() {
assert_eq!(
parse_git_remote("git@github.com:owner/repo"),
Some("owner/repo".to_string())
);
}
#[test]
fn parse_git_remote_rejects_non_github() {
assert_eq!(parse_git_remote("https://gitlab.com/x/y"), None);
}
#[test]
fn is_test_like_bin_matches_prefixes() {
for n in [
"test_foo",
"test-foo",
"dev_foo",
"dev-foo",
"debug_foo",
"debug-foo",
] {
assert!(is_test_like_bin(n), "expected {n} to be test-like");
}
}
#[test]
fn is_test_like_bin_rejects_normal_names() {
for n in ["web", "worker", "screenshot-worker", "api"] {
assert!(!is_test_like_bin(n));
}
}
#[test]
fn render_app_yaml_contains_static_fields() {
let c = ctx("myapp", "owner/repo", vec![], vec![]);
let out = render_app_yaml(&c);
assert!(out.starts_with("# Generated by ferro do:init — edit to your needs"));
assert!(out.contains("name: myapp"));
assert!(out.contains("region: fra1"));
assert!(out.contains("repo: owner/repo"));
assert!(out.contains("branch: main"));
assert!(out.contains("services:"));
assert!(out.contains("name: web"));
assert!(out.contains("envs:"));
assert!(!out.contains("databases:"));
}
#[test]
fn render_app_yaml_with_empty_workers_emits_commented_example() {
let c = ctx("a", "o/r", vec![], vec![]);
let out = render_app_yaml(&c);
assert!(out.contains("# workers:"));
assert!(out.contains("# workers: (one entry per"));
}
#[test]
fn render_app_yaml_emits_each_worker() {
let c = ctx(
"a",
"o/r",
vec!["screenshot-worker", "queue-worker"],
vec![],
);
let out = render_app_yaml(&c);
assert!(out.contains("workers:\n"));
assert!(out.contains("- name: screenshot-worker"));
assert!(out.contains("run_command: /usr/local/bin/screenshot-worker"));
assert!(out.contains("- name: queue-worker"));
assert!(out.contains("run_command: /usr/local/bin/queue-worker"));
}
#[test]
fn render_app_yaml_emits_one_commented_env_per_key() {
let c = ctx(
"a",
"o/r",
vec![],
vec!["APP_ENV", "APP_URL", "DATABASE_URL"],
);
let out = render_app_yaml(&c);
assert!(out.contains(" # - APP_ENV"));
assert!(out.contains(" # - APP_URL"));
assert!(out.contains(" # - DATABASE_URL"));
}
}