Skip to main content

ferro_cli/deploy/
app_yaml_existing.rs

1//! Parse identity fields from an existing `.do/app.yaml` for preservation
2//! across `do:init --force` re-renders.
3//!
4//! Uses a line scanner instead of `serde_yaml` — the field set is small
5//! (four values) and the scaffolder emits a known, stable template shape.
6//! If the preserved field set grows beyond four, migrate to `serde_yaml`.
7//!
8//! ## Disambiguation rules
9//!
10//! - `name:` and `region:` are only recognized at column 0 (no leading
11//!   whitespace). Service-level or worker-level `name:` / `region:` keys are
12//!   indented and therefore ignored.
13//! - `repo:` and `branch:` accept any indentation. In the scaffolder-emitted
14//!   template they only appear under `services[0].github:`, so an indented
15//!   match is always the github binding.
16//! - When a key appears multiple times at the recognized indentation level,
17//!   the **first** match is used. This matches what the scaffolder writes.
18
19use std::path::Path;
20
21/// Identity fields from an existing `.do/app.yaml` that should be preserved
22/// when re-rendering via `do:init --force`.
23#[derive(Debug, Default)]
24pub struct PreservedAppYamlIdentity {
25    /// Top-level `name:` value (DO App Platform app name).
26    pub name: Option<String>,
27    /// Top-level `region:` value (DO region slug, e.g. `fra1`).
28    pub region: Option<String>,
29    /// `github.repo` under the web service (`owner/repo`).
30    pub repo: Option<String>,
31    /// `github.branch` under the web service.
32    pub branch: Option<String>,
33}
34
35/// Read identity fields from an existing `.do/app.yaml`.
36///
37/// Returns `None` when the file does not exist or cannot be read.
38/// Returns `Some(identity)` otherwise; individual fields within the struct
39/// are `None` when the corresponding key was not found in the file.
40pub fn parse_existing(path: &Path) -> Option<PreservedAppYamlIdentity> {
41    let src = std::fs::read_to_string(path).ok()?;
42    let mut out = PreservedAppYamlIdentity::default();
43
44    for line in src.lines() {
45        // Top-level name: — must start at column 0.
46        if out.name.is_none() {
47            if let Some(v) = line.strip_prefix("name: ") {
48                out.name = Some(v.trim().to_string());
49                continue;
50            }
51        }
52        // Top-level region: — must start at column 0.
53        if out.region.is_none() {
54            if let Some(v) = line.strip_prefix("region: ") {
55                out.region = Some(v.trim().to_string());
56                continue;
57            }
58        }
59        // repo: under services[0].github: — accept any indentation.
60        if out.repo.is_none() {
61            if let Some(v) = line.trim_start().strip_prefix("repo: ") {
62                out.repo = Some(v.trim().to_string());
63                continue;
64            }
65        }
66        // branch: under services[0].github: — accept any indentation.
67        if out.branch.is_none() {
68            if let Some(v) = line.trim_start().strip_prefix("branch: ") {
69                out.branch = Some(v.trim().to_string());
70                continue;
71            }
72        }
73    }
74
75    Some(out)
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81    use std::fs;
82    use tempfile::TempDir;
83
84    fn write_yaml(td: &TempDir, body: &str) -> std::path::PathBuf {
85        let p = td.path().join("app.yaml");
86        fs::write(&p, body).unwrap();
87        p
88    }
89
90    #[test]
91    fn returns_none_for_missing_file() {
92        assert!(parse_existing(Path::new("/tmp/nonexistent-ferro-test-xyz/app.yaml")).is_none());
93    }
94
95    #[test]
96    fn parses_all_four_fields() {
97        let td = TempDir::new().unwrap();
98        let yaml = "# Generated by ferro do:init — edit to your needs\n\
99                    name: my-custom-app\n\
100                    region: nyc3\n\
101                    \n\
102                    services:\n\
103                      - name: web\n\
104                        github:\n\
105                          repo: myorg/my-repo\n\
106                          branch: production\n\
107                          deploy_on_push: true\n";
108        let p = write_yaml(&td, yaml);
109        let id = parse_existing(&p).unwrap();
110        assert_eq!(id.name.as_deref(), Some("my-custom-app"));
111        assert_eq!(id.region.as_deref(), Some("nyc3"));
112        assert_eq!(id.repo.as_deref(), Some("myorg/my-repo"));
113        assert_eq!(id.branch.as_deref(), Some("production"));
114    }
115
116    #[test]
117    fn top_level_name_not_confused_with_service_name() {
118        let td = TempDir::new().unwrap();
119        // The service `name: web` is indented — must NOT overwrite top-level name.
120        let yaml = "name: top-level-app\nregion: fra1\n\nservices:\n  - name: web\n    github:\n      repo: o/r\n      branch: main\n";
121        let p = write_yaml(&td, yaml);
122        let id = parse_existing(&p).unwrap();
123        assert_eq!(id.name.as_deref(), Some("top-level-app"));
124    }
125
126    #[test]
127    fn top_level_region_not_confused_with_indented_region() {
128        let td = TempDir::new().unwrap();
129        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";
130        let p = write_yaml(&td, yaml);
131        let id = parse_existing(&p).unwrap();
132        assert_eq!(id.region.as_deref(), Some("ams3"));
133    }
134
135    #[test]
136    fn missing_fields_return_none() {
137        let td = TempDir::new().unwrap();
138        // No name, no region in a minimal file.
139        let yaml = "services:\n  - name: web\n    http_port: 8080\n";
140        let p = write_yaml(&td, yaml);
141        let id = parse_existing(&p).unwrap();
142        assert!(id.name.is_none());
143        assert!(id.region.is_none());
144        assert!(id.repo.is_none());
145        assert!(id.branch.is_none());
146    }
147
148    #[test]
149    fn first_match_wins_for_repeated_top_level_name() {
150        let td = TempDir::new().unwrap();
151        let yaml = "name: first-name\nname: second-name\nregion: fra1\nservices: []\n";
152        let p = write_yaml(&td, yaml);
153        let id = parse_existing(&p).unwrap();
154        assert_eq!(id.name.as_deref(), Some("first-name"));
155    }
156}