ferro-cli 0.2.24

CLI for scaffolding Ferro web applications
Documentation
//! Parse identity fields from an existing `.do/app.yaml` for preservation
//! across `do:init --force` re-renders.
//!
//! Uses a line scanner instead of `serde_yaml` — the field set is small
//! (four values) and the scaffolder emits a known, stable template shape.
//! If the preserved field set grows beyond four, migrate to `serde_yaml`.
//!
//! ## Disambiguation rules
//!
//! - `name:` and `region:` are only recognized at column 0 (no leading
//!   whitespace). Service-level or worker-level `name:` / `region:` keys are
//!   indented and therefore ignored.
//! - `repo:` and `branch:` accept any indentation. In the scaffolder-emitted
//!   template they only appear under `services[0].github:`, so an indented
//!   match is always the github binding.
//! - When a key appears multiple times at the recognized indentation level,
//!   the **first** match is used. This matches what the scaffolder writes.

use std::path::Path;

/// Identity fields from an existing `.do/app.yaml` that should be preserved
/// when re-rendering via `do:init --force`.
#[derive(Debug, Default)]
pub struct PreservedAppYamlIdentity {
    /// Top-level `name:` value (DO App Platform app name).
    pub name: Option<String>,
    /// Top-level `region:` value (DO region slug, e.g. `fra1`).
    pub region: Option<String>,
    /// `github.repo` under the web service (`owner/repo`).
    pub repo: Option<String>,
    /// `github.branch` under the web service.
    pub branch: Option<String>,
}

/// Read identity fields from an existing `.do/app.yaml`.
///
/// Returns `None` when the file does not exist or cannot be read.
/// Returns `Some(identity)` otherwise; individual fields within the struct
/// are `None` when the corresponding key was not found in the file.
pub fn parse_existing(path: &Path) -> Option<PreservedAppYamlIdentity> {
    let src = std::fs::read_to_string(path).ok()?;
    let mut out = PreservedAppYamlIdentity::default();

    for line in src.lines() {
        // Top-level name: — must start at column 0.
        if out.name.is_none() {
            if let Some(v) = line.strip_prefix("name: ") {
                out.name = Some(v.trim().to_string());
                continue;
            }
        }
        // Top-level region: — must start at column 0.
        if out.region.is_none() {
            if let Some(v) = line.strip_prefix("region: ") {
                out.region = Some(v.trim().to_string());
                continue;
            }
        }
        // repo: under services[0].github: — accept any indentation.
        if out.repo.is_none() {
            if let Some(v) = line.trim_start().strip_prefix("repo: ") {
                out.repo = Some(v.trim().to_string());
                continue;
            }
        }
        // branch: under services[0].github: — accept any indentation.
        if out.branch.is_none() {
            if let Some(v) = line.trim_start().strip_prefix("branch: ") {
                out.branch = Some(v.trim().to_string());
                continue;
            }
        }
    }

    Some(out)
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::TempDir;

    fn write_yaml(td: &TempDir, body: &str) -> std::path::PathBuf {
        let p = td.path().join("app.yaml");
        fs::write(&p, body).unwrap();
        p
    }

    #[test]
    fn returns_none_for_missing_file() {
        assert!(parse_existing(Path::new("/tmp/nonexistent-ferro-test-xyz/app.yaml")).is_none());
    }

    #[test]
    fn parses_all_four_fields() {
        let td = TempDir::new().unwrap();
        let yaml = "# Generated by ferro do:init — edit to your needs\n\
                    name: my-custom-app\n\
                    region: nyc3\n\
                    \n\
                    services:\n\
                      - name: web\n\
                        github:\n\
                          repo: myorg/my-repo\n\
                          branch: production\n\
                          deploy_on_push: true\n";
        let p = write_yaml(&td, yaml);
        let id = parse_existing(&p).unwrap();
        assert_eq!(id.name.as_deref(), Some("my-custom-app"));
        assert_eq!(id.region.as_deref(), Some("nyc3"));
        assert_eq!(id.repo.as_deref(), Some("myorg/my-repo"));
        assert_eq!(id.branch.as_deref(), Some("production"));
    }

    #[test]
    fn top_level_name_not_confused_with_service_name() {
        let td = TempDir::new().unwrap();
        // The service `name: web` is indented — must NOT overwrite top-level name.
        let yaml = "name: top-level-app\nregion: fra1\n\nservices:\n  - name: web\n    github:\n      repo: o/r\n      branch: main\n";
        let p = write_yaml(&td, yaml);
        let id = parse_existing(&p).unwrap();
        assert_eq!(id.name.as_deref(), Some("top-level-app"));
    }

    #[test]
    fn top_level_region_not_confused_with_indented_region() {
        let td = TempDir::new().unwrap();
        let yaml = "name: app\nregion: ams3\n\nservices:\n  - name: web\n    region: inner-ignored\n    github:\n      repo: o/r\n      branch: main\n";
        let p = write_yaml(&td, yaml);
        let id = parse_existing(&p).unwrap();
        assert_eq!(id.region.as_deref(), Some("ams3"));
    }

    #[test]
    fn missing_fields_return_none() {
        let td = TempDir::new().unwrap();
        // No name, no region in a minimal file.
        let yaml = "services:\n  - name: web\n    http_port: 8080\n";
        let p = write_yaml(&td, yaml);
        let id = parse_existing(&p).unwrap();
        assert!(id.name.is_none());
        assert!(id.region.is_none());
        assert!(id.repo.is_none());
        assert!(id.branch.is_none());
    }

    #[test]
    fn first_match_wins_for_repeated_top_level_name() {
        let td = TempDir::new().unwrap();
        let yaml = "name: first-name\nname: second-name\nregion: fra1\nservices: []\n";
        let p = write_yaml(&td, yaml);
        let id = parse_existing(&p).unwrap();
        assert_eq!(id.name.as_deref(), Some("first-name"));
    }
}