use std::path::{Path, PathBuf};
use crate::error::ZigError;
use crate::paths::expand_path;
use crate::workflow::model::{StorageKind, StorageSpec};
#[derive(Debug, Clone)]
pub struct StorageEntry {
pub name: String,
pub size: Option<u64>,
}
#[derive(Debug, Clone, Default)]
pub struct StorageListing {
pub entries: Vec<StorageEntry>,
}
pub trait StorageBackend: std::fmt::Debug {
fn ensure(&self, spec: &StorageSpec) -> Result<(), ZigError>;
fn listing(&self, spec: &StorageSpec) -> Result<StorageListing, ZigError>;
fn abs_path(&self, spec: &StorageSpec) -> PathBuf;
}
#[derive(Debug, Clone)]
pub struct FilesystemBackend {
zig_root: PathBuf,
}
impl FilesystemBackend {
pub fn new(zig_root: PathBuf) -> Self {
Self { zig_root }
}
pub fn from_cwd() -> Result<Self, ZigError> {
let cwd = std::env::current_dir()
.map_err(|e| ZigError::Io(format!("failed to resolve cwd for storage: {e}")))?;
Ok(Self::new(cwd.join(".zig")))
}
fn resolve(&self, raw_path: &str) -> PathBuf {
let expanded = PathBuf::from(expand_path(raw_path));
if expanded.is_absolute() {
expanded
} else {
self.zig_root.join(expanded)
}
}
}
impl StorageBackend for FilesystemBackend {
fn ensure(&self, spec: &StorageSpec) -> Result<(), ZigError> {
let target = self.resolve(&spec.path);
match spec.kind {
StorageKind::Folder => {
std::fs::create_dir_all(&target).map_err(|e| {
ZigError::Io(format!(
"failed to create storage folder {}: {e}",
target.display()
))
})?;
}
StorageKind::File => {
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
ZigError::Io(format!(
"failed to create parent for storage file {}: {e}",
target.display()
))
})?;
}
if !target.exists() {
std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&target)
.map_err(|e| {
ZigError::Io(format!(
"failed to create storage file {}: {e}",
target.display()
))
})?;
}
}
}
Ok(())
}
fn listing(&self, spec: &StorageSpec) -> Result<StorageListing, ZigError> {
let target = self.resolve(&spec.path);
let mut entries = Vec::new();
match spec.kind {
StorageKind::Folder => {
let read_dir = match std::fs::read_dir(&target) {
Ok(r) => r,
Err(_) => return Ok(StorageListing::default()),
};
for entry in read_dir.flatten() {
let path = entry.path();
let meta = match std::fs::metadata(&path) {
Ok(m) => m,
Err(_) => continue,
};
if !meta.is_file() {
continue;
}
let name = match path.file_name() {
Some(n) => n.to_string_lossy().into_owned(),
None => continue,
};
entries.push(StorageEntry {
name,
size: Some(meta.len()),
});
}
entries.sort_by(|a, b| a.name.cmp(&b.name));
}
StorageKind::File => {
if let Ok(meta) = std::fs::metadata(&target) {
if meta.is_file() {
let name = target
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| target.display().to_string());
entries.push(StorageEntry {
name,
size: Some(meta.len()),
});
}
}
}
}
Ok(StorageListing { entries })
}
fn abs_path(&self, spec: &StorageSpec) -> PathBuf {
self.resolve(&spec.path)
}
}
#[derive(Debug)]
pub struct StorageManager {
items: Vec<StorageItem>,
}
#[derive(Debug)]
pub struct StorageItem {
pub name: String,
pub spec: StorageSpec,
pub backend: Box<dyn StorageBackend + Send + Sync>,
}
impl StorageManager {
pub fn build(
storage: &std::collections::HashMap<String, StorageSpec>,
backend: FilesystemBackend,
) -> Result<Self, ZigError> {
let mut items = Vec::with_capacity(storage.len());
let mut names: Vec<&String> = storage.keys().collect();
names.sort();
for name in names {
let spec = storage[name].clone();
backend.ensure(&spec)?;
items.push(StorageItem {
name: name.clone(),
spec,
backend: Box::new(backend.clone()),
});
}
Ok(Self { items })
}
pub fn empty() -> Self {
Self { items: Vec::new() }
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = &StorageItem> {
self.items.iter()
}
pub fn items_for_step(&self, scope: Option<&[String]>) -> Vec<&StorageItem> {
match scope {
None => self.items.iter().collect(),
Some([]) => Vec::new(),
Some(names) => self
.items
.iter()
.filter(|item| names.iter().any(|n| n == &item.name))
.collect(),
}
}
pub fn render_block(&self, scope: Option<&[String]>) -> Result<Option<String>, ZigError> {
let items = self.items_for_step(scope);
if items.is_empty() {
return Ok(None);
}
let mut out = String::from("<storage>\n");
for item in items {
let abs = item.backend.abs_path(&item.spec);
out.push_str(&format!(
" <item name=\"{}\" type=\"{}\" path=\"{}\">\n",
escape_xml(&item.name),
item.spec.kind,
escape_xml(&abs.display().to_string()),
));
if let Some(desc) = item.spec.description.as_deref() {
out.push_str(&format!(
" <description>{}</description>\n",
escape_xml(desc)
));
}
if let Some(hint) = item.spec.hint.as_deref() {
out.push_str(&format!(" <hint>{}</hint>\n", escape_xml(hint)));
}
if !item.spec.files.is_empty() {
out.push_str(" <expected>\n");
for file in &item.spec.files {
match file.description.as_deref() {
Some(d) => out.push_str(&format!(
" - {}: {}\n",
escape_xml(&file.name),
escape_xml(d)
)),
None => out.push_str(&format!(" - {}\n", escape_xml(&file.name))),
}
}
out.push_str(" </expected>\n");
}
let listing = item.backend.listing(&item.spec)?;
if !listing.entries.is_empty() {
out.push_str(" <contents>\n");
for entry in listing.entries {
out.push_str(&format!(" - {}\n", escape_xml(&entry.name)));
}
out.push_str(" </contents>\n");
}
out.push_str(" </item>\n");
}
out.push_str("</storage>");
Ok(Some(out))
}
pub fn add_dirs_for_step(&self, scope: Option<&[String]>) -> Vec<PathBuf> {
self.items_for_step(scope)
.into_iter()
.map(|item| {
let mut path = item.backend.abs_path(&item.spec);
if matches!(item.spec.kind, StorageKind::File) {
if let Some(parent) = path.parent() {
path = parent.to_path_buf();
}
}
path
})
.collect()
}
}
fn escape_xml(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for ch in input.chars() {
match ch {
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'&' => out.push_str("&"),
'"' => out.push_str("""),
_ => out.push(ch),
}
}
out
}
pub fn resolve_against(root: &Path, raw_path: &str) -> PathBuf {
let expanded = PathBuf::from(expand_path(raw_path));
if expanded.is_absolute() {
expanded
} else {
root.join(expanded)
}
}
#[cfg(test)]
#[path = "storage_tests.rs"]
mod tests;