1use serde::{Deserialize, Serialize};
14use serde_json::{json, Value};
15use std::io::Cursor;
16use std::path::{Path, PathBuf};
17
18pub const ARTIFACT_KEEP: usize = 50;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "lowercase")]
24pub enum ArtifactKind {
25 Image,
26 Pdf,
27 Audio,
28 Video,
29 Archive,
30 Binary,
31 Text,
32}
33
34impl ArtifactKind {
35 pub fn default_preview_bytes(self) -> usize {
39 match self {
40 ArtifactKind::Image => 16,
41 ArtifactKind::Pdf
42 | ArtifactKind::Archive
43 | ArtifactKind::Audio
44 | ArtifactKind::Video
45 | ArtifactKind::Binary => 64,
46 ArtifactKind::Text => 0,
47 }
48 }
49}
50
51#[derive(Debug, Clone, Default, Serialize, Deserialize)]
52pub struct ArtifactMeta {
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub width: Option<u32>,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub height: Option<u32>,
57}
58
59impl ArtifactMeta {
60 pub fn is_empty(&self) -> bool {
61 self.width.is_none() && self.height.is_none()
62 }
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct ArtifactRef {
69 pub id: String,
70 pub path: String,
72 pub mime: String,
73 pub bytes: usize,
74 pub kind: ArtifactKind,
75 #[serde(default, skip_serializing_if = "ArtifactMeta::is_empty")]
76 pub meta: ArtifactMeta,
77 #[serde(default, skip_serializing_if = "String::is_empty")]
78 pub sha256: String,
79}
80
81impl ArtifactRef {
82 pub fn to_model_json(&self, preview_hex: Option<&str>) -> Value {
85 let mut v = json!({
86 "artifact": self.path,
87 "mime": self.mime,
88 "bytes": self.bytes,
89 "kind": self.kind,
90 });
91 if let Some(w) = self.meta.width {
92 v["width"] = json!(w);
93 }
94 if let Some(h) = self.meta.height {
95 v["height"] = json!(h);
96 }
97 if !self.sha256.is_empty() {
98 v["sha256"] = json!(self.sha256);
99 }
100 if let Some(p) = preview_hex {
101 v["preview_hex"] = json!(p);
102 v["preview_truncated"] = json!(self.bytes > p.len() / 2);
103 }
104 v
105 }
106}
107
108#[derive(Debug, Clone)]
110pub struct ArtifactStore {
111 root: PathBuf,
112 rel_root: String,
113}
114
115impl ArtifactStore {
116 pub fn new(project_path: &Path) -> Self {
117 ArtifactStore {
118 root: project_path.join(".aacode").join("artifacts"),
119 rel_root: ".aacode/artifacts".to_string(),
120 }
121 }
122
123 pub fn root(&self) -> &Path {
124 &self.root
125 }
126
127 pub fn sniff(bytes: &[u8]) -> (ArtifactKind, &'static str) {
129 let b = bytes;
130 let starts = |m: &[u8]| b.len() >= m.len() && &b[..m.len()] == m;
131 if starts(b"\x89PNG\r\n\x1a\n") {
132 return (ArtifactKind::Image, "image/png");
133 }
134 if starts(b"\xff\xd8\xff") {
135 return (ArtifactKind::Image, "image/jpeg");
136 }
137 if starts(b"GIF87a") || starts(b"GIF89a") {
138 return (ArtifactKind::Image, "image/gif");
139 }
140 if b.len() >= 12 && starts(b"RIFF") && &b[8..12] == b"WEBP" {
141 return (ArtifactKind::Image, "image/webp");
142 }
143 if starts(b"BM") {
144 return (ArtifactKind::Image, "image/bmp");
145 }
146 if starts(b"%PDF") {
147 return (ArtifactKind::Pdf, "application/pdf");
148 }
149 if starts(b"PK\x03\x04") || starts(b"PK\x05\x06") {
150 return (ArtifactKind::Archive, "application/zip");
151 }
152 if starts(b"\x1f\x8b") {
153 return (ArtifactKind::Archive, "application/gzip");
154 }
155 if starts(b"ID3") || starts(b"\xff\xfb") {
156 return (ArtifactKind::Audio, "audio/mpeg");
157 }
158 if b.len() >= 12 && &b[4..8] == b"ftyp" {
159 return (ArtifactKind::Video, "video/mp4");
160 }
161 if starts(b"OggS") {
162 return (ArtifactKind::Audio, "audio/ogg");
163 }
164 (ArtifactKind::Binary, "application/octet-stream")
165 }
166
167 fn extension_for(kind: ArtifactKind, mime: &str) -> &'static str {
168 match mime {
169 "image/png" => "png",
170 "image/jpeg" => "jpg",
171 "image/gif" => "gif",
172 "image/webp" => "webp",
173 "image/bmp" => "bmp",
174 "application/pdf" => "pdf",
175 "application/zip" => "zip",
176 "application/gzip" => "gz",
177 "audio/mpeg" => "mp3",
178 "audio/ogg" => "ogg",
179 "video/mp4" => "mp4",
180 _ => match kind {
181 ArtifactKind::Image => "img",
182 ArtifactKind::Pdf => "pdf",
183 ArtifactKind::Audio => "audio",
184 ArtifactKind::Video => "video",
185 ArtifactKind::Archive => "bin",
186 _ => "bin",
187 },
188 }
189 }
190
191 fn sha256_hex(bytes: &[u8]) -> String {
192 use sha2::{Digest, Sha256};
193 let mut h = Sha256::new();
194 h.update(bytes);
195 h.finalize().iter().map(|b| format!("{b:02x}")).collect()
196 }
197
198 fn image_dims(bytes: &[u8]) -> ArtifactMeta {
199 let dims = image::ImageReader::new(Cursor::new(bytes))
200 .with_guessed_format()
201 .ok()
202 .and_then(|r| r.into_dimensions().ok());
203 match dims {
204 Some((w, h)) => ArtifactMeta {
205 width: Some(w),
206 height: Some(h),
207 },
208 None => ArtifactMeta::default(),
209 }
210 }
211
212 pub fn put_bytes(
216 &self,
217 bytes: &[u8],
218 mime_hint: Option<&str>,
219 _name_hint: &str,
220 ) -> std::io::Result<ArtifactRef> {
221 let (sniffed_kind, sniffed_mime) = Self::sniff(bytes);
222 let (kind, mime) = match mime_hint {
223 Some(m) if !m.is_empty() && m != "application/octet-stream" => (sniffed_kind, m),
224 _ => (sniffed_kind, sniffed_mime),
225 };
226 let id = uuid::Uuid::new_v4().simple().to_string();
227 let ext = Self::extension_for(kind, mime);
228 std::fs::create_dir_all(&self.root)?;
229 let file = format!("{id}.{ext}");
230 std::fs::write(self.root.join(&file), bytes)?;
231 let meta = match kind {
232 ArtifactKind::Image => Self::image_dims(bytes),
233 _ => ArtifactMeta::default(),
234 };
235 let _ = self.prune(ARTIFACT_KEEP);
236 Ok(ArtifactRef {
237 id,
238 path: format!("{}/{}", self.rel_root, file),
239 mime: mime.to_string(),
240 bytes: bytes.len(),
241 kind,
242 meta,
243 sha256: Self::sha256_hex(bytes),
244 })
245 }
246
247 pub fn path_for(&self, id_or_path: &str) -> Option<PathBuf> {
249 let p = PathBuf::from(id_or_path);
250 for cand in [p.clone(), self.root.join(id_or_path)] {
251 if cand.is_file() {
252 return Some(cand);
253 }
254 }
255 if let Ok(rd) = std::fs::read_dir(&self.root) {
257 for e in rd.flatten() {
258 let name = e.file_name().to_string_lossy().to_string();
259 if name == id_or_path || name.starts_with(&format!("{id_or_path}.")) {
260 return Some(e.path());
261 }
262 }
263 }
264 None
265 }
266
267 pub fn read(&self, id_or_path: &str) -> Option<Vec<u8>> {
268 let p = self.path_for(id_or_path)?;
269 std::fs::read(p).ok()
270 }
271
272 pub fn prune(&self, keep: usize) -> std::io::Result<()> {
274 let mut files: Vec<(std::time::SystemTime, PathBuf)> = Vec::new();
275 for e in std::fs::read_dir(&self.root)?.flatten() {
276 if let Ok(md) = e.metadata() {
277 let t = md.modified().unwrap_or(std::time::UNIX_EPOCH);
278 files.push((t, e.path()));
279 }
280 }
281 files.sort_by_key(|(t, _)| *t);
282 if files.len() > keep {
283 for (_, p) in files.iter().take(files.len() - keep) {
284 let _ = std::fs::remove_file(p);
285 }
286 }
287 Ok(())
288 }
289}
290
291pub fn hex_preview(bytes: &[u8], n: usize) -> String {
294 bytes
295 .iter()
296 .take(n)
297 .map(|b| format!("{b:02x}"))
298 .collect::<Vec<_>>()
299 .join(" ")
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305
306 fn tmp() -> (ArtifactStore, PathBuf) {
307 let dir = std::env::temp_dir().join(format!("aacode_art_{}", uuid::Uuid::new_v4()));
308 std::fs::create_dir_all(&dir).unwrap();
309 (ArtifactStore::new(&dir), dir)
310 }
311
312 #[test]
313 fn sniffs_png_and_records_dims() {
314 use base64::Engine;
316 let png: Vec<u8> = base64::engine::general_purpose::STANDARD
317 .decode(
318 "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
319 )
320 .unwrap();
321 let (kind, mime) = ArtifactStore::sniff(&png);
322 assert_eq!(kind, ArtifactKind::Image);
323 assert_eq!(mime, "image/png");
324 let (store, _d) = tmp();
325 let r = store.put_bytes(&png, None, "shot").unwrap();
326 assert_eq!(r.kind, ArtifactKind::Image);
327 assert_eq!(r.meta.width, Some(1));
328 assert!(r.path.ends_with(".png"));
329 assert_eq!(store.read(&r.id).unwrap(), png);
330 }
331
332 #[test]
333 fn hex_preview_is_bounded() {
334 let b: Vec<u8> = (0..100).collect();
335 let p = hex_preview(&b, 4);
336 assert_eq!(p, "00 01 02 03");
337 }
338}