greentic_setup/
bundle_source.rs1use std::path::{Path, PathBuf};
6
7use anyhow::{Context, anyhow};
8
9#[derive(Clone, Debug)]
11pub enum BundleSource {
12 LocalDir(PathBuf),
14 FileUri(PathBuf),
16 #[cfg(feature = "oci")]
18 Oci { reference: String },
19 #[cfg(feature = "oci")]
21 Repo { reference: String },
22 #[cfg(feature = "oci")]
24 Store { reference: String },
25}
26
27impl BundleSource {
28 pub fn parse(source: &str) -> anyhow::Result<Self> {
45 let trimmed = source.trim();
46
47 if trimmed.is_empty() {
48 return Err(anyhow!("bundle source cannot be empty"));
49 }
50
51 #[cfg(feature = "oci")]
53 if trimmed.starts_with("oci://") {
54 return Ok(Self::Oci {
55 reference: trimmed.to_string(),
56 });
57 }
58
59 #[cfg(feature = "oci")]
61 if trimmed.starts_with("repo://") {
62 return Ok(Self::Repo {
63 reference: trimmed.to_string(),
64 });
65 }
66
67 #[cfg(feature = "oci")]
69 if trimmed.starts_with("store://") {
70 return Ok(Self::Store {
71 reference: trimmed.to_string(),
72 });
73 }
74
75 if trimmed.starts_with("file://") {
77 let path = file_uri_to_path(trimmed)?;
78 return Ok(Self::FileUri(path));
79 }
80
81 #[cfg(not(feature = "oci"))]
83 if trimmed.starts_with("oci://")
84 || trimmed.starts_with("repo://")
85 || trimmed.starts_with("store://")
86 {
87 return Err(anyhow!(
88 "protocol not supported (compile with 'oci' feature): {}",
89 trimmed.split("://").next().unwrap_or("unknown")
90 ));
91 }
92
93 let path = PathBuf::from(trimmed);
95 Ok(Self::LocalDir(path))
96 }
97
98 pub fn resolve(&self) -> anyhow::Result<PathBuf> {
103 match self {
104 Self::LocalDir(path) => resolve_local_path(path),
105 Self::FileUri(path) => resolve_local_path(path),
106 #[cfg(feature = "oci")]
107 Self::Oci { reference } => resolve_oci_reference(reference),
108 #[cfg(feature = "oci")]
109 Self::Repo { reference } => resolve_oci_reference(reference),
110 #[cfg(feature = "oci")]
111 Self::Store { reference } => resolve_oci_reference(reference),
112 }
113 }
114
115 pub async fn resolve_async(&self) -> anyhow::Result<PathBuf> {
120 match self {
121 Self::LocalDir(path) => resolve_local_path(path),
122 Self::FileUri(path) => resolve_local_path(path),
123 #[cfg(feature = "oci")]
124 Self::Oci { reference } => resolve_oci_reference_async(reference).await,
125 #[cfg(feature = "oci")]
126 Self::Repo { reference } => resolve_oci_reference_async(reference).await,
127 #[cfg(feature = "oci")]
128 Self::Store { reference } => resolve_oci_reference_async(reference).await,
129 }
130 }
131
132 pub fn as_str(&self) -> String {
134 match self {
135 Self::LocalDir(path) => path.display().to_string(),
136 Self::FileUri(path) => format!("file://{}", path.display()),
137 #[cfg(feature = "oci")]
138 Self::Oci { reference } => reference.clone(),
139 #[cfg(feature = "oci")]
140 Self::Repo { reference } => reference.clone(),
141 #[cfg(feature = "oci")]
142 Self::Store { reference } => reference.clone(),
143 }
144 }
145
146 pub fn is_local(&self) -> bool {
148 matches!(self, Self::LocalDir(_) | Self::FileUri(_))
149 }
150
151 #[cfg(feature = "oci")]
153 pub fn is_remote(&self) -> bool {
154 matches!(
155 self,
156 Self::Oci { .. } | Self::Repo { .. } | Self::Store { .. }
157 )
158 }
159}
160
161fn file_uri_to_path(uri: &str) -> anyhow::Result<PathBuf> {
163 let path_str = uri
164 .strip_prefix("file://")
165 .ok_or_else(|| anyhow!("invalid file URI: {}", uri))?;
166
167 #[cfg(windows)]
169 let path_str = path_str.strip_prefix('/').unwrap_or(path_str);
170
171 let decoded = percent_decode(path_str);
172 Ok(PathBuf::from(decoded))
173}
174
175fn percent_decode(input: &str) -> String {
177 let mut result = String::with_capacity(input.len());
178 let mut chars = input.chars().peekable();
179
180 while let Some(ch) = chars.next() {
181 if ch == '%' {
182 let hex: String = chars.by_ref().take(2).collect();
183 if hex.len() == 2
184 && let Ok(byte) = u8::from_str_radix(&hex, 16)
185 {
186 result.push(byte as char);
187 continue;
188 }
189 result.push('%');
190 result.push_str(&hex);
191 } else {
192 result.push(ch);
193 }
194 }
195
196 result
197}
198
199fn resolve_local_path(path: &Path) -> anyhow::Result<PathBuf> {
201 let canonical = if path.is_absolute() {
202 path.to_path_buf()
203 } else {
204 std::env::current_dir()
205 .context("failed to get current directory")?
206 .join(path)
207 };
208
209 if !canonical.exists() {
210 return Err(anyhow!(
211 "bundle path does not exist: {}",
212 canonical.display()
213 ));
214 }
215
216 Ok(canonical)
217}
218
219#[cfg(feature = "oci")]
221fn resolve_oci_reference(reference: &str) -> anyhow::Result<PathBuf> {
222 use tokio::runtime::Runtime;
223
224 let rt = Runtime::new().context("failed to create tokio runtime")?;
225 rt.block_on(resolve_oci_reference_async(reference))
226}
227
228#[cfg(feature = "oci")]
230async fn resolve_oci_reference_async(reference: &str) -> anyhow::Result<PathBuf> {
231 use greentic_distributor_client::oci_packs::fetch_pack_to_cache;
232
233 let resolved = fetch_pack_to_cache(reference)
234 .await
235 .with_context(|| format!("failed to resolve bundle reference: {}", reference))?;
236
237 Ok(resolved.path)
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[test]
246 fn parse_local_path() {
247 let source = BundleSource::parse("./my-bundle").unwrap();
248 assert!(matches!(source, BundleSource::LocalDir(_)));
249 }
250
251 #[test]
252 fn parse_absolute_path() {
253 let source = BundleSource::parse("/home/user/bundle").unwrap();
254 assert!(matches!(source, BundleSource::LocalDir(_)));
255 }
256
257 #[test]
258 fn parse_file_uri() {
259 let source = BundleSource::parse("file:///home/user/bundle").unwrap();
260 assert!(matches!(source, BundleSource::FileUri(_)));
261 if let BundleSource::FileUri(path) = source {
262 assert_eq!(path, PathBuf::from("/home/user/bundle"));
263 }
264 }
265
266 #[cfg(feature = "oci")]
267 #[test]
268 fn parse_oci_reference() {
269 let source = BundleSource::parse("oci://ghcr.io/org/bundle:latest").unwrap();
270 assert!(matches!(source, BundleSource::Oci { .. }));
271 }
272
273 #[cfg(feature = "oci")]
274 #[test]
275 fn parse_repo_reference() {
276 let source = BundleSource::parse("repo://greentic/messaging-telegram").unwrap();
277 assert!(matches!(source, BundleSource::Repo { .. }));
278 }
279
280 #[cfg(feature = "oci")]
281 #[test]
282 fn parse_store_reference() {
283 let source = BundleSource::parse("store://bundle-abc123").unwrap();
284 assert!(matches!(source, BundleSource::Store { .. }));
285 }
286
287 #[test]
288 fn empty_source_fails() {
289 assert!(BundleSource::parse("").is_err());
290 assert!(BundleSource::parse(" ").is_err());
291 }
292
293 #[test]
294 fn file_uri_percent_decode() {
295 let decoded = percent_decode("path%20with%20spaces");
296 assert_eq!(decoded, "path with spaces");
297 }
298
299 #[test]
300 fn is_local_checks() {
301 let local = BundleSource::parse("./bundle").unwrap();
302 assert!(local.is_local());
303
304 let file_uri = BundleSource::parse("file:///path").unwrap();
305 assert!(file_uri.is_local());
306 }
307
308 #[cfg(feature = "oci")]
309 #[test]
310 fn is_remote_checks() {
311 let oci = BundleSource::parse("oci://ghcr.io/test").unwrap();
312 assert!(oci.is_remote());
313 assert!(!oci.is_local());
314 }
315}