use std::ffi::OsStr;
use std::path::Path;
use indexmap::IndexMap;
use yah_qed_gha::{ArtifactCall, ArtifactStore, StepConclusion, ToolkitOutcome, Value};
const STORE_DIR: &str = ".qed-artifacts";
#[derive(Debug, Clone, Copy, Default)]
pub struct LocalArtifactStore;
impl LocalArtifactStore {
pub fn new() -> Self {
Self
}
}
impl ArtifactStore for LocalArtifactStore {
fn upload(&self, call: &ArtifactCall<'_>) -> Result<ToolkitOutcome, String> {
let with = call.with;
let name = with_string(with, "name")
.ok_or_else(|| "actions/upload-artifact: missing `name`".to_string())?;
let path_input = with_string(with, "path")
.ok_or_else(|| "actions/upload-artifact: missing `path`".to_string())?;
let dest_root = call.workspace.join(STORE_DIR).join(&name);
if dest_root.exists() {
std::fs::remove_dir_all(&dest_root)
.map_err(|e| format!("clean {}: {e}", dest_root.display()))?;
}
std::fs::create_dir_all(&dest_root)
.map_err(|e| format!("mkdir {}: {e}", dest_root.display()))?;
let mut count = 0usize;
for raw in path_input.lines() {
let p = raw.trim();
if p.is_empty() {
continue;
}
let src = call.workspace.join(p);
if !src.exists() {
return Ok(failure(format!(
"actions/upload-artifact: path `{p}` does not exist (artifact `{name}`); \
the step that was supposed to produce it did not run or produced nothing.",
)));
}
let dst = dest_root.join(src.file_name().unwrap_or_else(|| OsStr::new("artifact")));
copy_tree(&src, &dst).map_err(|e| format!("copy {p}: {e}"))?;
count += 1;
}
let mut outputs = IndexMap::new();
outputs.insert("artifact-id".into(), Value::String(name.clone()));
outputs.insert(
"artifact-url".into(),
Value::String(format!("qed-artifact://{name}")),
);
Ok(ToolkitOutcome {
outputs,
log: format!("actions/upload-artifact: stored {count} path(s) under `{name}`"),
conclusion: StepConclusion::Success,
})
}
fn download(&self, call: &ArtifactCall<'_>) -> Result<ToolkitOutcome, String> {
let with = call.with;
let name = with_string(with, "name");
let dest = with_string(with, "path").unwrap_or_else(|| ".".into());
let dest_dir = call.workspace.join(&dest);
std::fs::create_dir_all(&dest_dir)
.map_err(|e| format!("mkdir {}: {e}", dest_dir.display()))?;
let root = call.workspace.join(STORE_DIR);
if !root.exists() {
let want = name.as_deref().unwrap_or("<all>");
return Ok(failure(format!(
"actions/download-artifact: cannot fetch artifact `{want}` — the artifact store \
(`{STORE_DIR}/`) is empty; no `actions/upload-artifact` step has run \
successfully yet in this workflow run.",
)));
}
let mut count = 0usize;
if let Some(name) = name {
let src = root.join(&name);
if !src.exists() {
return Ok(failure(format!(
"actions/download-artifact: artifact `{name}` not found. Artifacts present: \
[{}]. The job whose `actions/upload-artifact` step produces `{name}` did \
not complete successfully.",
list_stored_artifacts(&root).join(", "),
)));
}
copy_tree_contents(&src, &dest_dir).map_err(|e| format!("restore {name}: {e}"))?;
count = 1;
} else {
for entry in std::fs::read_dir(&root).map_err(|e| format!("scan artifacts: {e}"))? {
let entry = entry.map_err(|e| format!("scan entry: {e}"))?;
let entry_name = entry.file_name();
let dst = dest_dir.join(&entry_name);
std::fs::create_dir_all(&dst).map_err(|e| format!("mkdir {}: {e}", dst.display()))?;
copy_tree_contents(&entry.path(), &dst)
.map_err(|e| format!("restore {}: {e}", entry_name.to_string_lossy()))?;
count += 1;
}
}
Ok(ToolkitOutcome {
outputs: IndexMap::new(),
log: format!(
"actions/download-artifact: restored {count} artifact(s) into {}",
dest_dir.display()
),
conclusion: StepConclusion::Success,
})
}
}
fn failure(log: String) -> ToolkitOutcome {
ToolkitOutcome {
outputs: IndexMap::new(),
log,
conclusion: StepConclusion::Failure,
}
}
fn list_stored_artifacts(root: &Path) -> Vec<String> {
let Ok(rd) = std::fs::read_dir(root) else {
return Vec::new();
};
let mut names: Vec<String> = rd
.flatten()
.filter(|e| e.path().is_dir())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
names.sort();
names
}
fn with_string(with: &IndexMap<String, Value>, key: &str) -> Option<String> {
with.get(key).map(|v| v.as_str_lossy())
}
fn copy_tree(src: &Path, dst: &Path) -> std::io::Result<()> {
let meta = std::fs::symlink_metadata(src)?;
if meta.file_type().is_dir() {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
copy_tree(&entry.path(), &dst.join(entry.file_name()))?;
}
Ok(())
} else if meta.file_type().is_symlink() {
let target = std::fs::read_link(src)?;
#[cfg(unix)]
std::os::unix::fs::symlink(&target, dst)?;
#[cfg(not(unix))]
std::fs::copy(src, dst).map(|_| ())?;
Ok(())
} else {
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(src, dst).map(|_| ())
}
}
fn copy_tree_contents(src: &Path, dst: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
copy_tree(&entry.path(), &dst.join(entry.file_name()))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn with(pairs: &[(&str, &str)]) -> IndexMap<String, Value> {
let mut m = IndexMap::new();
for (k, v) in pairs {
m.insert((*k).into(), Value::String((*v).into()));
}
m
}
#[test]
fn upload_then_download_roundtrips_a_file() {
let ws = TempDir::new().unwrap();
std::fs::write(ws.path().join("bin"), b"hello").unwrap();
let store = LocalArtifactStore::new();
let up = with(&[("name", "mybin"), ("path", "bin")]);
let out = store
.upload(&ArtifactCall { with: &up, workspace: ws.path() })
.unwrap();
assert_eq!(out.conclusion, StepConclusion::Success);
let dl = with(&[("name", "mybin"), ("path", "out")]);
let out = store
.download(&ArtifactCall { with: &dl, workspace: ws.path() })
.unwrap();
assert_eq!(out.conclusion, StepConclusion::Success);
let restored = std::fs::read(ws.path().join("out").join("bin")).unwrap();
assert_eq!(restored, b"hello");
}
#[test]
fn upload_missing_path_is_step_failure_not_error() {
let ws = TempDir::new().unwrap();
let store = LocalArtifactStore::new();
let up = with(&[("name", "x"), ("path", "does-not-exist")]);
let out = store
.upload(&ArtifactCall { with: &up, workspace: ws.path() })
.expect("step failure, not Err");
assert_eq!(out.conclusion, StepConclusion::Failure);
}
#[test]
fn download_missing_artifact_is_step_failure() {
let ws = TempDir::new().unwrap();
std::fs::create_dir_all(ws.path().join(STORE_DIR).join("other")).unwrap();
let store = LocalArtifactStore::new();
let dl = with(&[("name", "absent"), ("path", "out")]);
let out = store
.download(&ArtifactCall { with: &dl, workspace: ws.path() })
.unwrap();
assert_eq!(out.conclusion, StepConclusion::Failure);
assert!(out.log.contains("other"), "diagnostic lists present artifacts");
}
}