Skip to main content

aacode_rs/
artifacts.rs

1// Copyright (c) 2026 xiefujin <490021684@qq.com>
2// Licensed under GPL-3.0, see LICENSE file for full license terms.
3
4//! Artifacts — the only place binary payloads live.
5//!
6//! Design invariant (see `observation.rs`): message content that reaches the
7//! model or the session must never contain an unbounded binary payload. Binary
8//! data is always written to an [`ArtifactStore`] file and represented in the
9//! conversation by a small [`ArtifactRef`] (path + mime + size + optional
10//! bounded preview). This module is self-contained and does **not** depend on
11//! fastshell or fastbrowser.
12
13use serde::{Deserialize, Serialize};
14use serde_json::{json, Value};
15use std::io::Cursor;
16use std::path::{Path, PathBuf};
17
18/// Keep at most this many artifact files on disk.
19pub const ARTIFACT_KEEP: usize = 50;
20
21/// Coarse type of an artifact (drives default preview policy).
22#[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    /// Default number of leading bytes shown as hex when this artifact is
36    /// first surfaced (images only need the magic header; unknown binaries a
37    /// bit more for eyeballing).
38    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/// Reference to a stored artifact. This is the *only* binary representation
66/// allowed inside messages/sessions.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct ArtifactRef {
69    pub id: String,
70    /// Project-relative path (e.g. `.aacode/artifacts/<id>.png`).
71    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    /// Small, model-facing JSON: metadata + path + an optional bounded hex
83    /// preview. Never contains the full payload.
84    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/// A directory-backed artifact store rooted at `<project>/.aacode/artifacts/`.
109#[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    /// Sniff magic bytes → (kind, mime). Falls back to `Binary`/octet-stream.
128    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    /// Write `bytes` to a new artifact file and return its reference.
213    /// `mime_hint` (e.g. from a response header) is honored when it looks sane,
214    /// otherwise magic sniffing wins.
215    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    /// Resolve an id or a path (relative or absolute) to a concrete file.
248    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        // Bare id (no extension): find first matching `<id>.*`.
256        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    /// Keep only the most recent `keep` artifact files.
273    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
291/// Render the first `n` bytes as a lowercase hex string with spaces, e.g.
292/// `89 50 4e 47`. Always bounded by `n`.
293pub 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        // Complete 1x1 PNG (so the image crate can read its dimensions).
315        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}