Skip to main content

ferro_cli/commands/
ci_init.rs

1//! ferro ci:init — drop `.github/workflows/ci.yml` (D-13, D-17, D-21).
2//!
3//! Standalone CI scaffold for projects not on DigitalOcean. Idempotent:
4//! refuses to overwrite an existing workflow unless `--force` is passed.
5
6use console::style;
7use std::fs;
8use std::path::Path;
9
10use crate::project::{find_project_root, package_name};
11use crate::templates::ci_workflow::{render_ci_workflow, CiWorkflowContext};
12
13pub fn run(force: bool) {
14    let root = match find_project_root(None) {
15        Ok(r) => r,
16        Err(_) => {
17            eprintln!(
18                "{} Cargo.toml not found (searched upward from CWD)",
19                style("Error:").red().bold()
20            );
21            std::process::exit(1);
22        }
23    };
24
25    match generate_in(&root, force) {
26        Ok(path) => {
27            println!("{} Generated {}", style("✓").green(), path.display());
28        }
29        Err(GenerateError::Exists(path)) => {
30            eprintln!(
31                "{} {} already exists (use --force)",
32                style("Error:").red().bold(),
33                path.display()
34            );
35            std::process::exit(1);
36        }
37        Err(GenerateError::Io(e)) => {
38            eprintln!("{} {}", style("Error:").red().bold(), e);
39            std::process::exit(1);
40        }
41    }
42}
43
44#[derive(Debug)]
45enum GenerateError {
46    Exists(std::path::PathBuf),
47    Io(std::io::Error),
48}
49
50impl From<std::io::Error> for GenerateError {
51    fn from(e: std::io::Error) -> Self {
52        GenerateError::Io(e)
53    }
54}
55
56fn generate_in(root: &Path, force: bool) -> Result<std::path::PathBuf, GenerateError> {
57    let workflows_dir = root.join(".github").join("workflows");
58    let ci_yml = workflows_dir.join("ci.yml");
59
60    if ci_yml.exists() && !force {
61        return Err(GenerateError::Exists(ci_yml));
62    }
63
64    fs::create_dir_all(&workflows_dir)?;
65
66    let pkg = package_name(root);
67    let content = render_ci_workflow(&CiWorkflowContext { package_name: &pkg });
68    fs::write(&ci_yml, content)?;
69    Ok(ci_yml)
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75    use tempfile::TempDir;
76
77    fn write_min_project(td: &TempDir) {
78        fs::write(
79            td.path().join("Cargo.toml"),
80            "[package]\nname = \"sample\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
81        )
82        .unwrap();
83    }
84
85    #[test]
86    fn writes_ci_yml_under_github_workflows() {
87        let td = TempDir::new().unwrap();
88        write_min_project(&td);
89        let path = generate_in(td.path(), false).unwrap();
90        assert!(path.ends_with(".github/workflows/ci.yml"));
91        let body = fs::read_to_string(&path).unwrap();
92        assert!(body.contains("Swatinem/rust-cache@v2"));
93        assert!(body.contains("cargo fmt --all -- --check"));
94    }
95
96    #[test]
97    fn refuses_to_overwrite_without_force() {
98        let td = TempDir::new().unwrap();
99        write_min_project(&td);
100        generate_in(td.path(), false).unwrap();
101        match generate_in(td.path(), false) {
102            Err(GenerateError::Exists(_)) => {}
103            other => panic!("expected Exists error, got {other:?}"),
104        }
105    }
106
107    #[test]
108    fn force_overwrites_existing_with_identical_content() {
109        let td = TempDir::new().unwrap();
110        write_min_project(&td);
111        let path = generate_in(td.path(), false).unwrap();
112        let first = fs::read_to_string(&path).unwrap();
113        // Mutate file to confirm overwrite happens.
114        fs::write(&path, "stale\n").unwrap();
115        let path2 = generate_in(td.path(), true).unwrap();
116        let second = fs::read_to_string(&path2).unwrap();
117        assert_eq!(first, second);
118    }
119}