Skip to main content

ciab_core/
resolve.rs

1use std::path::{Path, PathBuf};
2
3use tracing::info;
4
5use crate::error::{CiabError, CiabResult};
6
7#[derive(Debug, Clone)]
8pub enum ResourceSource {
9    FilePath(PathBuf),
10    Url(String),
11    Git {
12        url: String,
13        path: String,
14        ref_: Option<String>,
15    },
16    Builtin(String),
17}
18
19pub fn parse_source_string(s: &str) -> ResourceSource {
20    if let Some(rest) = s.strip_prefix("git::") {
21        parse_git_source(rest)
22    } else if s.starts_with("http://") || s.starts_with("https://") {
23        ResourceSource::Url(s.to_string())
24    } else if let Some(name) = s.strip_prefix("builtin://") {
25        ResourceSource::Builtin(name.to_string())
26    } else {
27        ResourceSource::FilePath(PathBuf::from(s))
28    }
29}
30
31fn parse_git_source(s: &str) -> ResourceSource {
32    let (url_and_path, ref_) = if let Some(idx) = s.find("?ref=") {
33        (&s[..idx], Some(s[idx + 5..].to_string()))
34    } else {
35        (s, None)
36    };
37
38    // Skip past the protocol's "://" when searching for the "//" path separator.
39    let search_start = if let Some(proto_end) = url_and_path.find("://") {
40        proto_end + 3
41    } else {
42        0
43    };
44
45    let (url, path) = if let Some(rel_idx) = url_and_path[search_start..].find("//") {
46        let idx = search_start + rel_idx;
47        (
48            url_and_path[..idx].to_string(),
49            url_and_path[idx + 2..].to_string(),
50        )
51    } else {
52        (url_and_path.to_string(), String::new())
53    };
54
55    ResourceSource::Git { url, path, ref_ }
56}
57
58pub async fn resolve_resource(source: &ResourceSource) -> CiabResult<String> {
59    match source {
60        ResourceSource::FilePath(path) => resolve_file(path).await,
61        ResourceSource::Url(url) => resolve_url(url).await,
62        ResourceSource::Git { url, path, ref_ } => {
63            resolve_git(url, path, ref_.as_deref()).await
64        }
65        ResourceSource::Builtin(name) => resolve_builtin(name),
66    }
67}
68
69async fn resolve_file(path: &Path) -> CiabResult<String> {
70    tokio::fs::read_to_string(path).await.map_err(|e| {
71        CiabError::ResourceResolutionError(format!(
72            "Failed to read file {}: {}",
73            path.display(),
74            e
75        ))
76    })
77}
78
79async fn resolve_url(url: &str) -> CiabResult<String> {
80    info!(url = url, "Fetching resource from URL");
81    let response = reqwest::get(url).await.map_err(|e| {
82        CiabError::ResourceResolutionError(format!("Failed to fetch {}: {}", url, e))
83    })?;
84
85    if !response.status().is_success() {
86        return Err(CiabError::ResourceResolutionError(format!(
87            "HTTP {} fetching {}",
88            response.status(),
89            url
90        )));
91    }
92
93    response.text().await.map_err(|e| {
94        CiabError::ResourceResolutionError(format!(
95            "Failed to read response from {}: {}",
96            url, e
97        ))
98    })
99}
100
101async fn resolve_git(url: &str, subpath: &str, ref_: Option<&str>) -> CiabResult<String> {
102    let url = url.to_string();
103    let subpath = subpath.to_string();
104    let ref_ = ref_.map(|s| s.to_string());
105
106    tokio::task::spawn_blocking(move || {
107        let tmp = tempfile::tempdir().map_err(|e| {
108            CiabError::ResourceResolutionError(format!("Failed to create temp dir: {}", e))
109        })?;
110
111        info!(url = %url, subpath = %subpath, ref_ = ?ref_, "Cloning git resource");
112
113        let mut builder = git2::build::RepoBuilder::new();
114        let mut fetch_opts = git2::FetchOptions::new();
115        fetch_opts.depth(1);
116        builder.fetch_options(fetch_opts);
117
118        if let Some(ref branch) = ref_ {
119            builder.branch(branch);
120        }
121
122        let repo = builder.clone(&url, tmp.path()).map_err(|e| {
123            CiabError::ResourceResolutionError(format!("Git clone failed for {}: {}", url, e))
124        })?;
125
126        let file_path = if subpath.is_empty() {
127            repo.workdir()
128                .ok_or_else(|| {
129                    CiabError::ResourceResolutionError("Bare repository".to_string())
130                })?
131                .to_path_buf()
132        } else {
133            repo.workdir()
134                .ok_or_else(|| {
135                    CiabError::ResourceResolutionError("Bare repository".to_string())
136                })?
137                .join(&subpath)
138        };
139
140        std::fs::read_to_string(&file_path).map_err(|e| {
141            CiabError::ResourceResolutionError(format!(
142                "Failed to read {} from cloned repo: {}",
143                file_path.display(),
144                e
145            ))
146        })
147    })
148    .await
149    .map_err(|e| CiabError::ResourceResolutionError(format!("Git task panicked: {}", e)))?
150}
151
152fn resolve_builtin(name: &str) -> CiabResult<String> {
153    match name {
154        "default-ec2" => Ok(
155            include_str!("../templates/default-ec2.pkr.hcl").to_string(),
156        ),
157        "default-config" => Ok(include_str!("../config.default.toml").to_string()),
158        _ => Err(CiabError::ResourceResolutionError(format!(
159            "Unknown builtin resource: {}",
160            name
161        ))),
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn test_parse_file_path() {
171        let source = parse_source_string("/path/to/file.toml");
172        assert!(
173            matches!(source, ResourceSource::FilePath(p) if p == PathBuf::from("/path/to/file.toml"))
174        );
175    }
176
177    #[test]
178    fn test_parse_url() {
179        let source = parse_source_string("https://example.com/config.toml");
180        assert!(
181            matches!(source, ResourceSource::Url(u) if u == "https://example.com/config.toml")
182        );
183    }
184
185    #[test]
186    fn test_parse_builtin() {
187        let source = parse_source_string("builtin://default-ec2");
188        assert!(matches!(source, ResourceSource::Builtin(n) if n == "default-ec2"));
189    }
190
191    #[test]
192    fn test_parse_git_full() {
193        let source = parse_source_string(
194            "git::https://github.com/org/repo.git//path/to/file.hcl?ref=main",
195        );
196        match source {
197            ResourceSource::Git { url, path, ref_ } => {
198                assert_eq!(url, "https://github.com/org/repo.git");
199                assert_eq!(path, "path/to/file.hcl");
200                assert_eq!(ref_, Some("main".to_string()));
201            }
202            _ => panic!("Expected Git source"),
203        }
204    }
205
206    #[test]
207    fn test_parse_git_no_ref() {
208        let source =
209            parse_source_string("git::https://github.com/org/repo.git//template.hcl");
210        match source {
211            ResourceSource::Git { url, path, ref_ } => {
212                assert_eq!(url, "https://github.com/org/repo.git");
213                assert_eq!(path, "template.hcl");
214                assert_eq!(ref_, None);
215            }
216            _ => panic!("Expected Git source"),
217        }
218    }
219
220    #[test]
221    fn test_parse_git_no_subpath() {
222        let source =
223            parse_source_string("git::https://github.com/org/repo.git?ref=v1.0");
224        match source {
225            ResourceSource::Git { url, path, ref_ } => {
226                assert_eq!(url, "https://github.com/org/repo.git");
227                assert_eq!(path, "");
228                assert_eq!(ref_, Some("v1.0".to_string()));
229            }
230            _ => panic!("Expected Git source"),
231        }
232    }
233}