use std::path::{Path, PathBuf};
use anyhow::{Context, anyhow};
#[derive(Clone, Debug)]
pub enum BundleSource {
LocalDir(PathBuf),
FileUri(PathBuf),
#[cfg(feature = "oci")]
Oci { reference: String },
#[cfg(feature = "oci")]
Repo { reference: String },
#[cfg(feature = "oci")]
Store { reference: String },
}
impl BundleSource {
pub fn parse(source: &str) -> anyhow::Result<Self> {
let trimmed = source.trim();
if trimmed.is_empty() {
return Err(anyhow!("bundle source cannot be empty"));
}
#[cfg(feature = "oci")]
if trimmed.starts_with("oci://") {
return Ok(Self::Oci {
reference: trimmed.to_string(),
});
}
#[cfg(feature = "oci")]
if trimmed.starts_with("repo://") {
return Ok(Self::Repo {
reference: trimmed.to_string(),
});
}
#[cfg(feature = "oci")]
if trimmed.starts_with("store://") {
return Ok(Self::Store {
reference: trimmed.to_string(),
});
}
if trimmed.starts_with("file://") {
let path = file_uri_to_path(trimmed)?;
return Ok(Self::FileUri(path));
}
#[cfg(not(feature = "oci"))]
if trimmed.starts_with("oci://")
|| trimmed.starts_with("repo://")
|| trimmed.starts_with("store://")
{
return Err(anyhow!(
"protocol not supported (compile with 'oci' feature): {}",
trimmed.split("://").next().unwrap_or("unknown")
));
}
let path = PathBuf::from(trimmed);
Ok(Self::LocalDir(path))
}
pub fn resolve(&self) -> anyhow::Result<PathBuf> {
match self {
Self::LocalDir(path) => resolve_local_path(path),
Self::FileUri(path) => resolve_local_path(path),
#[cfg(feature = "oci")]
Self::Oci { reference } => resolve_oci_reference(reference),
#[cfg(feature = "oci")]
Self::Repo { reference } => resolve_oci_reference(reference),
#[cfg(feature = "oci")]
Self::Store { reference } => resolve_oci_reference(reference),
}
}
pub async fn resolve_async(&self) -> anyhow::Result<PathBuf> {
match self {
Self::LocalDir(path) => resolve_local_path(path),
Self::FileUri(path) => resolve_local_path(path),
#[cfg(feature = "oci")]
Self::Oci { reference } => resolve_oci_reference_async(reference).await,
#[cfg(feature = "oci")]
Self::Repo { reference } => resolve_oci_reference_async(reference).await,
#[cfg(feature = "oci")]
Self::Store { reference } => resolve_oci_reference_async(reference).await,
}
}
pub fn as_str(&self) -> String {
match self {
Self::LocalDir(path) => path.display().to_string(),
Self::FileUri(path) => format!("file://{}", path.display()),
#[cfg(feature = "oci")]
Self::Oci { reference } => reference.clone(),
#[cfg(feature = "oci")]
Self::Repo { reference } => reference.clone(),
#[cfg(feature = "oci")]
Self::Store { reference } => reference.clone(),
}
}
pub fn is_local(&self) -> bool {
matches!(self, Self::LocalDir(_) | Self::FileUri(_))
}
#[cfg(feature = "oci")]
pub fn is_remote(&self) -> bool {
matches!(
self,
Self::Oci { .. } | Self::Repo { .. } | Self::Store { .. }
)
}
}
fn file_uri_to_path(uri: &str) -> anyhow::Result<PathBuf> {
let path_str = uri
.strip_prefix("file://")
.ok_or_else(|| anyhow!("invalid file URI: {}", uri))?;
#[cfg(windows)]
let path_str = path_str.strip_prefix('/').unwrap_or(path_str);
let decoded = percent_decode(path_str);
Ok(PathBuf::from(decoded))
}
fn percent_decode(input: &str) -> String {
let mut result = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '%' {
let hex: String = chars.by_ref().take(2).collect();
if hex.len() == 2
&& let Ok(byte) = u8::from_str_radix(&hex, 16)
{
result.push(byte as char);
continue;
}
result.push('%');
result.push_str(&hex);
} else {
result.push(ch);
}
}
result
}
fn resolve_local_path(path: &Path) -> anyhow::Result<PathBuf> {
let canonical = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.context("failed to get current directory")?
.join(path)
};
if !canonical.exists() {
return Err(anyhow!(
"bundle path does not exist: {}",
canonical.display()
));
}
Ok(canonical)
}
#[cfg(feature = "oci")]
fn resolve_oci_reference(reference: &str) -> anyhow::Result<PathBuf> {
use tokio::runtime::Runtime;
let rt = Runtime::new().context("failed to create tokio runtime")?;
rt.block_on(resolve_oci_reference_async(reference))
}
#[cfg(feature = "oci")]
async fn resolve_oci_reference_async(reference: &str) -> anyhow::Result<PathBuf> {
use greentic_distributor_client::oci_packs::fetch_pack_to_cache;
let resolved = fetch_pack_to_cache(reference)
.await
.with_context(|| format!("failed to resolve bundle reference: {}", reference))?;
Ok(resolved.path)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_local_path() {
let source = BundleSource::parse("./my-bundle").unwrap();
assert!(matches!(source, BundleSource::LocalDir(_)));
}
#[test]
fn parse_absolute_path() {
let source = BundleSource::parse("/home/user/bundle").unwrap();
assert!(matches!(source, BundleSource::LocalDir(_)));
}
#[test]
fn parse_file_uri() {
let source = BundleSource::parse("file:///home/user/bundle").unwrap();
assert!(matches!(source, BundleSource::FileUri(_)));
if let BundleSource::FileUri(path) = source {
assert_eq!(path, PathBuf::from("/home/user/bundle"));
}
}
#[cfg(feature = "oci")]
#[test]
fn parse_oci_reference() {
let source = BundleSource::parse("oci://ghcr.io/org/bundle:latest").unwrap();
assert!(matches!(source, BundleSource::Oci { .. }));
}
#[cfg(feature = "oci")]
#[test]
fn parse_repo_reference() {
let source = BundleSource::parse("repo://greentic/messaging-telegram").unwrap();
assert!(matches!(source, BundleSource::Repo { .. }));
}
#[cfg(feature = "oci")]
#[test]
fn parse_store_reference() {
let source = BundleSource::parse("store://bundle-abc123").unwrap();
assert!(matches!(source, BundleSource::Store { .. }));
}
#[test]
fn empty_source_fails() {
assert!(BundleSource::parse("").is_err());
assert!(BundleSource::parse(" ").is_err());
}
#[test]
fn file_uri_percent_decode() {
let decoded = percent_decode("path%20with%20spaces");
assert_eq!(decoded, "path with spaces");
}
#[test]
fn is_local_checks() {
let local = BundleSource::parse("./bundle").unwrap();
assert!(local.is_local());
let file_uri = BundleSource::parse("file:///path").unwrap();
assert!(file_uri.is_local());
}
#[cfg(feature = "oci")]
#[test]
fn is_remote_checks() {
let oci = BundleSource::parse("oci://ghcr.io/test").unwrap();
assert!(oci.is_remote());
assert!(!oci.is_local());
}
}