use serde::Serialize;
use crate::client::GatewayApi;
use crate::client::resources::{
ResourceEntry, read_member, remove_member, replace_member, resource_members,
};
use crate::error::CoreError;
const BINARY_SNIFF_WINDOW: usize = 8 * 1024;
#[derive(Debug, Clone, PartialEq)]
pub enum ContentKind {
Json(serde_json::Value),
Text(String),
Binary,
}
pub fn classify_content(bytes: &[u8]) -> ContentKind {
let head = &bytes[..bytes.len().min(BINARY_SNIFF_WINDOW)];
if head.contains(&0) {
return ContentKind::Binary;
}
match std::str::from_utf8(bytes) {
Ok(text) => match serde_json::from_str::<serde_json::Value>(text) {
Ok(value) => ContentKind::Json(value),
Err(_) => ContentKind::Text(text.to_string()),
},
Err(_) => ContentKind::Binary,
}
}
impl ContentKind {
fn label(&self) -> &'static str {
match self {
Self::Json(_) => "json",
Self::Text(_) => "text",
Self::Binary => "binary",
}
}
}
#[derive(Debug, Serialize)]
pub struct ResourcesResult {
pub resources: Vec<ResourceEntry>,
}
#[derive(Debug, Serialize)]
pub struct ResourceGetResult {
pub project: String,
pub path: String,
pub content_kind: String,
pub content: serde_json::Value,
}
#[derive(Debug, Serialize)]
pub struct ResourcePutResult {
pub project: String,
pub path: String,
pub content_kind: String,
}
#[derive(Debug, Serialize)]
pub struct ResourceDeleteResult {
pub deleted: String,
}
pub async fn export_zip_bytes(api: &dyn GatewayApi, project: &str) -> Result<Vec<u8>, CoreError> {
let temp = tempfile::NamedTempFile::new()
.map_err(|err| CoreError::Internal(format!("cannot create temp export file: {err}")))?;
api.project_export_to_file(project, temp.path()).await?;
tokio::fs::read(temp.path()).await.map_err(|err| {
CoreError::Internal(format!(
"cannot read back export {}: {err}",
temp.path().display()
))
})
}
pub async fn resources_list(
api: &dyn GatewayApi,
project: &str,
prefix: Option<&str>,
) -> Result<ResourcesResult, CoreError> {
let zip = export_zip_bytes(api, project).await?;
let resources = resource_members(&zip)?
.into_iter()
.filter(|path| prefix.is_none_or(|prefix| path.starts_with(prefix)))
.map(|path| ResourceEntry {
path: Some(path),
extra: Default::default(),
})
.collect();
Ok(ResourcesResult { resources })
}
pub async fn resource_get(
api: &dyn GatewayApi,
project: &str,
path: &str,
) -> Result<ResourceGetResult, CoreError> {
let zip = export_zip_bytes(api, project).await?;
let bytes = read_member(&zip, path)?;
match classify_content(&bytes) {
ContentKind::Json(value) => Ok(ResourceGetResult {
project: project.to_string(),
path: path.to_string(),
content_kind: "json".to_string(),
content: value,
}),
ContentKind::Text(text) => Ok(ResourceGetResult {
project: project.to_string(),
path: path.to_string(),
content_kind: "text".to_string(),
content: serde_json::Value::String(text),
}),
ContentKind::Binary => Err(CoreError::ResourceBinary {
path: path.to_string(),
endpoint: None,
}),
}
}
pub async fn resource_put(
api: &dyn GatewayApi,
project: &str,
path: &str,
input: Vec<u8>,
) -> Result<ResourcePutResult, CoreError> {
let kind = classify_content(&input);
if matches!(kind, ContentKind::Binary) {
return Err(CoreError::ResourceBinary {
path: path.to_string(),
endpoint: None,
});
}
let zip = export_zip_bytes(api, project).await?;
let surgical = replace_member(&zip, path, &input)?;
api.project_import(project, surgical, true).await?;
Ok(ResourcePutResult {
project: project.to_string(),
path: path.to_string(),
content_kind: kind.label().to_string(),
})
}
pub async fn resource_delete(
api: &dyn GatewayApi,
project: &str,
path: &str,
) -> Result<ResourceDeleteResult, CoreError> {
let zip = export_zip_bytes(api, project).await?;
let surgical = remove_member(&zip, path)?;
api.project_import(project, surgical, true).await?;
Ok(ResourceDeleteResult {
deleted: path.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::{ContentKind, classify_content};
#[test]
fn classify_content_sniffs_all_three_kinds() {
assert_eq!(
classify_content(br#"{"scope":"G","code":"print('hi')"}"#),
ContentKind::Json(serde_json::json!({"scope":"G","code":"print('hi')"})),
"UTF-8 that JSON-parses → Json (value preserved)"
);
assert_eq!(
classify_content(b"print('just a script')\n"),
ContentKind::Text("print('just a script')\n".to_string()),
"UTF-8 that does not parse → Text"
);
assert_eq!(
classify_content(&[0x00, 0x50, 0x4B, 0x03]),
ContentKind::Binary,
"NUL in the head → Binary (data.bin class)"
);
assert_eq!(
classify_content(&[0xFF, 0xFE, 0x00, 0x01]),
ContentKind::Binary,
"non-UTF-8 → Binary too (no honest textual form)"
);
}
#[test]
fn classify_content_nul_past_window_is_text() {
let mut bytes = vec![b'x'; super::BINARY_SNIFF_WINDOW + 64];
bytes[super::BINARY_SNIFF_WINDOW + 32] = 0;
assert_eq!(
classify_content(&bytes),
ContentKind::Text(String::from_utf8(bytes.clone()).expect("NUL is valid UTF-8")),
"a lone NUL past the 8 KiB window in UTF-8 input classifies Text \
(the heuristic's documented boundary)"
);
bytes[16] = 0;
assert_eq!(classify_content(&bytes), ContentKind::Binary);
}
#[test]
fn content_kind_labels() {
assert_eq!(ContentKind::Json(serde_json::json!(1)).label(), "json");
assert_eq!(ContentKind::Text(String::new()).label(), "text");
assert_eq!(ContentKind::Binary.label(), "binary");
}
}