yah_qed/
artifact_local.rs1use std::ffi::OsStr;
14use std::path::Path;
15
16use indexmap::IndexMap;
17use yah_qed_gha::{ArtifactCall, ArtifactStore, StepConclusion, ToolkitOutcome, Value};
18
19const STORE_DIR: &str = ".qed-artifacts";
21
22#[derive(Debug, Clone, Copy, Default)]
25pub struct LocalArtifactStore;
26
27impl LocalArtifactStore {
28 pub fn new() -> Self {
29 Self
30 }
31}
32
33impl ArtifactStore for LocalArtifactStore {
34 fn upload(&self, call: &ArtifactCall<'_>) -> Result<ToolkitOutcome, String> {
39 let with = call.with;
40 let name = with_string(with, "name")
41 .ok_or_else(|| "actions/upload-artifact: missing `name`".to_string())?;
42 let path_input = with_string(with, "path")
43 .ok_or_else(|| "actions/upload-artifact: missing `path`".to_string())?;
44
45 let dest_root = call.workspace.join(STORE_DIR).join(&name);
46 if dest_root.exists() {
47 std::fs::remove_dir_all(&dest_root)
48 .map_err(|e| format!("clean {}: {e}", dest_root.display()))?;
49 }
50 std::fs::create_dir_all(&dest_root)
51 .map_err(|e| format!("mkdir {}: {e}", dest_root.display()))?;
52
53 let mut count = 0usize;
54 for raw in path_input.lines() {
55 let p = raw.trim();
56 if p.is_empty() {
57 continue;
58 }
59 let src = call.workspace.join(p);
60 if !src.exists() {
61 return Ok(failure(format!(
62 "actions/upload-artifact: path `{p}` does not exist (artifact `{name}`); \
63 the step that was supposed to produce it did not run or produced nothing.",
64 )));
65 }
66 let dst = dest_root.join(src.file_name().unwrap_or_else(|| OsStr::new("artifact")));
67 copy_tree(&src, &dst).map_err(|e| format!("copy {p}: {e}"))?;
68 count += 1;
69 }
70
71 let mut outputs = IndexMap::new();
72 outputs.insert("artifact-id".into(), Value::String(name.clone()));
73 outputs.insert(
74 "artifact-url".into(),
75 Value::String(format!("qed-artifact://{name}")),
76 );
77 Ok(ToolkitOutcome {
78 outputs,
79 log: format!("actions/upload-artifact: stored {count} path(s) under `{name}`"),
80 conclusion: StepConclusion::Success,
81 })
82 }
83
84 fn download(&self, call: &ArtifactCall<'_>) -> Result<ToolkitOutcome, String> {
89 let with = call.with;
90 let name = with_string(with, "name");
91 let dest = with_string(with, "path").unwrap_or_else(|| ".".into());
92 let dest_dir = call.workspace.join(&dest);
93 std::fs::create_dir_all(&dest_dir)
94 .map_err(|e| format!("mkdir {}: {e}", dest_dir.display()))?;
95
96 let root = call.workspace.join(STORE_DIR);
97 if !root.exists() {
98 let want = name.as_deref().unwrap_or("<all>");
99 return Ok(failure(format!(
100 "actions/download-artifact: cannot fetch artifact `{want}` — the artifact store \
101 (`{STORE_DIR}/`) is empty; no `actions/upload-artifact` step has run \
102 successfully yet in this workflow run.",
103 )));
104 }
105
106 let mut count = 0usize;
107 if let Some(name) = name {
108 let src = root.join(&name);
109 if !src.exists() {
110 return Ok(failure(format!(
111 "actions/download-artifact: artifact `{name}` not found. Artifacts present: \
112 [{}]. The job whose `actions/upload-artifact` step produces `{name}` did \
113 not complete successfully.",
114 list_stored_artifacts(&root).join(", "),
115 )));
116 }
117 copy_tree_contents(&src, &dest_dir).map_err(|e| format!("restore {name}: {e}"))?;
118 count = 1;
119 } else {
120 for entry in std::fs::read_dir(&root).map_err(|e| format!("scan artifacts: {e}"))? {
121 let entry = entry.map_err(|e| format!("scan entry: {e}"))?;
122 let entry_name = entry.file_name();
123 let dst = dest_dir.join(&entry_name);
124 std::fs::create_dir_all(&dst).map_err(|e| format!("mkdir {}: {e}", dst.display()))?;
125 copy_tree_contents(&entry.path(), &dst)
126 .map_err(|e| format!("restore {}: {e}", entry_name.to_string_lossy()))?;
127 count += 1;
128 }
129 }
130
131 Ok(ToolkitOutcome {
132 outputs: IndexMap::new(),
133 log: format!(
134 "actions/download-artifact: restored {count} artifact(s) into {}",
135 dest_dir.display()
136 ),
137 conclusion: StepConclusion::Success,
138 })
139 }
140}
141
142fn failure(log: String) -> ToolkitOutcome {
143 ToolkitOutcome {
144 outputs: IndexMap::new(),
145 log,
146 conclusion: StepConclusion::Failure,
147 }
148}
149
150fn list_stored_artifacts(root: &Path) -> Vec<String> {
151 let Ok(rd) = std::fs::read_dir(root) else {
152 return Vec::new();
153 };
154 let mut names: Vec<String> = rd
155 .flatten()
156 .filter(|e| e.path().is_dir())
157 .map(|e| e.file_name().to_string_lossy().into_owned())
158 .collect();
159 names.sort();
160 names
161}
162
163fn with_string(with: &IndexMap<String, Value>, key: &str) -> Option<String> {
164 with.get(key).map(|v| v.as_str_lossy())
165}
166
167fn copy_tree(src: &Path, dst: &Path) -> std::io::Result<()> {
168 let meta = std::fs::symlink_metadata(src)?;
169 if meta.file_type().is_dir() {
170 std::fs::create_dir_all(dst)?;
171 for entry in std::fs::read_dir(src)? {
172 let entry = entry?;
173 copy_tree(&entry.path(), &dst.join(entry.file_name()))?;
174 }
175 Ok(())
176 } else if meta.file_type().is_symlink() {
177 let target = std::fs::read_link(src)?;
178 #[cfg(unix)]
179 std::os::unix::fs::symlink(&target, dst)?;
180 #[cfg(not(unix))]
181 std::fs::copy(src, dst).map(|_| ())?;
182 Ok(())
183 } else {
184 if let Some(parent) = dst.parent() {
185 std::fs::create_dir_all(parent)?;
186 }
187 std::fs::copy(src, dst).map(|_| ())
188 }
189}
190
191fn copy_tree_contents(src: &Path, dst: &Path) -> std::io::Result<()> {
192 std::fs::create_dir_all(dst)?;
193 for entry in std::fs::read_dir(src)? {
194 let entry = entry?;
195 copy_tree(&entry.path(), &dst.join(entry.file_name()))?;
196 }
197 Ok(())
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use tempfile::TempDir;
204
205 fn with(pairs: &[(&str, &str)]) -> IndexMap<String, Value> {
206 let mut m = IndexMap::new();
207 for (k, v) in pairs {
208 m.insert((*k).into(), Value::String((*v).into()));
209 }
210 m
211 }
212
213 #[test]
214 fn upload_then_download_roundtrips_a_file() {
215 let ws = TempDir::new().unwrap();
216 std::fs::write(ws.path().join("bin"), b"hello").unwrap();
217 let store = LocalArtifactStore::new();
218
219 let up = with(&[("name", "mybin"), ("path", "bin")]);
220 let out = store
221 .upload(&ArtifactCall { with: &up, workspace: ws.path() })
222 .unwrap();
223 assert_eq!(out.conclusion, StepConclusion::Success);
224
225 let dl = with(&[("name", "mybin"), ("path", "out")]);
226 let out = store
227 .download(&ArtifactCall { with: &dl, workspace: ws.path() })
228 .unwrap();
229 assert_eq!(out.conclusion, StepConclusion::Success);
230 let restored = std::fs::read(ws.path().join("out").join("bin")).unwrap();
231 assert_eq!(restored, b"hello");
232 }
233
234 #[test]
235 fn upload_missing_path_is_step_failure_not_error() {
236 let ws = TempDir::new().unwrap();
237 let store = LocalArtifactStore::new();
238 let up = with(&[("name", "x"), ("path", "does-not-exist")]);
239 let out = store
240 .upload(&ArtifactCall { with: &up, workspace: ws.path() })
241 .expect("step failure, not Err");
242 assert_eq!(out.conclusion, StepConclusion::Failure);
243 }
244
245 #[test]
246 fn download_missing_artifact_is_step_failure() {
247 let ws = TempDir::new().unwrap();
248 std::fs::create_dir_all(ws.path().join(STORE_DIR).join("other")).unwrap();
249 let store = LocalArtifactStore::new();
250 let dl = with(&[("name", "absent"), ("path", "out")]);
251 let out = store
252 .download(&ArtifactCall { with: &dl, workspace: ws.path() })
253 .unwrap();
254 assert_eq!(out.conclusion, StepConclusion::Failure);
255 assert!(out.log.contains("other"), "diagnostic lists present artifacts");
256 }
257}