use std::path::PathBuf;
use anyhow::{Context, Result, bail};
use greentic_distributor_client::{OciPackFetcher, PackFetchOptions};
use tokio::runtime::Runtime;
const ANSWERS_CREATE_MEDIA_TYPE: &str = "application/vnd.greentic.answers.create.v1+json";
const ANSWERS_SETUP_MEDIA_TYPE: &str = "application/vnd.greentic.answers.setup.v1+json";
const GHCR_DEFAULT_NAMESPACE: &str = "ghcr.io/greenticai";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AnswersSource {
Local(PathBuf),
Remote { reference: String, bytes: Vec<u8> },
}
pub trait AnswersArtifactClient {
fn fetch_json(&self, oci_reference: &str) -> Result<Vec<u8>>;
}
pub struct DistributorAnswersClient;
impl AnswersArtifactClient for DistributorAnswersClient {
fn fetch_json(&self, oci_reference: &str) -> Result<Vec<u8>> {
let answers_media_types = vec![
ANSWERS_CREATE_MEDIA_TYPE.to_string(),
ANSWERS_SETUP_MEDIA_TYPE.to_string(),
];
let fetcher: OciPackFetcher = OciPackFetcher::new(PackFetchOptions {
allow_tags: true,
offline: crate::runtime::offline(),
cache_dir: answers_cache_dir(),
accepted_layer_media_types: answers_media_types.clone(),
preferred_layer_media_types: answers_media_types,
..PackFetchOptions::default()
});
let runtime = Runtime::new().context("create tokio runtime for answers pull")?;
let bytes = runtime
.block_on(fetcher.fetch_pack(oci_reference))
.with_context(|| format!("failed to pull answers document from {oci_reference}"))?;
Ok(bytes)
}
}
fn answers_cache_dir() -> PathBuf {
std::env::temp_dir()
.join("greentic-bundle")
.join("answers-cache")
}
pub fn is_remote_reference(raw: &str) -> bool {
raw.starts_with("oci://") || raw.starts_with("ghcr://")
}
pub fn resolve_answers_source(
raw: &str,
client: &dyn AnswersArtifactClient,
) -> Result<AnswersSource> {
if !is_remote_reference(raw) {
return Ok(AnswersSource::Local(PathBuf::from(raw)));
}
if crate::runtime::offline() {
bail!("cannot pull answers document from {raw}: offline mode is enabled");
}
let oci_reference = map_answers_reference(raw)?;
let bytes = client.fetch_json(&oci_reference)?;
Ok(AnswersSource::Remote {
reference: raw.to_string(),
bytes,
})
}
fn map_answers_reference(raw: &str) -> Result<String> {
if let Some(rest) = raw.strip_prefix("oci://") {
if rest.is_empty() {
bail!("answers reference {raw} is missing an OCI path");
}
return Ok(rest.to_string());
}
if let Some(rest) = raw.strip_prefix("ghcr://") {
let trimmed = rest.trim_start_matches('/');
if trimmed.is_empty() {
bail!("answers reference {raw} is missing a GHCR path");
}
return Ok(ensure_tag_or_digest(&format!(
"{GHCR_DEFAULT_NAMESPACE}/{trimmed}"
)));
}
bail!("answers reference {raw} is not a supported remote reference; use oci:// or ghcr://")
}
fn ensure_tag_or_digest(reference: &str) -> String {
if reference.contains("@sha256:") {
return reference.to_string();
}
let last_segment = reference.rsplit('/').next().unwrap_or(reference);
if last_segment.contains(':') {
return reference.to_string();
}
format!("{reference}:latest")
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
struct FakeClient {
bytes: Vec<u8>,
seen: RefCell<Vec<String>>,
}
impl FakeClient {
fn new(bytes: &[u8]) -> Self {
Self {
bytes: bytes.to_vec(),
seen: RefCell::new(Vec::new()),
}
}
}
impl AnswersArtifactClient for FakeClient {
fn fetch_json(&self, oci_reference: &str) -> Result<Vec<u8>> {
self.seen.borrow_mut().push(oci_reference.to_string());
Ok(self.bytes.clone())
}
}
#[test]
fn detects_remote_and_local_references() {
assert!(is_remote_reference(
"oci://ghcr.io/greenticai/answers/x/create:latest"
));
assert!(is_remote_reference("ghcr://answers/x/create"));
assert!(!is_remote_reference("demos/x-create-answers.json"));
assert!(!is_remote_reference("/abs/path/create.json"));
assert!(!is_remote_reference("./relative.json"));
}
#[test]
fn oci_reference_passes_through_verbatim() {
assert_eq!(
map_answers_reference("oci://ghcr.io/greenticai/answers/quickstart/create:latest")
.unwrap(),
"ghcr.io/greenticai/answers/quickstart/create:latest"
);
}
#[test]
fn ghcr_shortcut_expands_namespace_and_defaults_latest() {
assert_eq!(
map_answers_reference("ghcr://answers/quickstart/create").unwrap(),
"ghcr.io/greenticai/answers/quickstart/create:latest"
);
}
#[test]
fn ghcr_shortcut_preserves_explicit_tag_and_digest() {
assert_eq!(
map_answers_reference("ghcr://answers/quickstart/create:1.2.3").unwrap(),
"ghcr.io/greenticai/answers/quickstart/create:1.2.3"
);
assert_eq!(
map_answers_reference("ghcr://answers/quickstart/create@sha256:abc123").unwrap(),
"ghcr.io/greenticai/answers/quickstart/create@sha256:abc123"
);
}
#[test]
fn rejects_unsupported_and_empty_references() {
assert!(map_answers_reference("https://example.com/x.json").is_err());
assert!(map_answers_reference("oci://").is_err());
assert!(map_answers_reference("ghcr://").is_err());
}
#[test]
fn local_path_resolves_without_touching_client() {
let client = FakeClient::new(b"unused");
let source = resolve_answers_source("demos/x-create-answers.json", &client).unwrap();
assert_eq!(
source,
AnswersSource::Local(PathBuf::from("demos/x-create-answers.json"))
);
assert!(client.seen.borrow().is_empty());
}
#[test]
fn remote_reference_pulls_bytes_via_mapped_reference() {
let client = FakeClient::new(br#"{"answers":{}}"#);
let source = resolve_answers_source("ghcr://answers/quickstart/create", &client).unwrap();
assert_eq!(
source,
AnswersSource::Remote {
reference: "ghcr://answers/quickstart/create".to_string(),
bytes: br#"{"answers":{}}"#.to_vec(),
}
);
assert_eq!(
client.seen.borrow().as_slice(),
["ghcr.io/greenticai/answers/quickstart/create:latest"]
);
}
}