Skip to main content

agent_first_http/shared/
artifacts.rs

1//! Artifact tokens (`architecture.md §8`) + on-disk path resolution.
2//!
3//! Nine default tokens identify the artifacts captured by default, plus
4//! `Storage` (default-off, sensitive-data risk). Each maps to a fixed
5//! filename; the response JSON references them as absolute paths under
6//! `--out/<request_id>/`.
7
8use serde::{Deserialize, Serialize};
9
10use crate::shared::ids::RequestId;
11
12/// Artifact kinds. `Body` is the only one HTTP-only fetches produce;
13/// the others all require a browser. `Storage` is default-off due to
14/// sensitive-data risk — agents must request it explicitly via `--want`.
15/// Per-artifact warnings (§8 last ¶) are emitted when a backend lacks
16/// the capability rather than failing the fetch.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum Artifact {
20    Body,
21    RenderedHtml,
22    Text,
23    Content,
24    ContentJson,
25    Screenshot,
26    Network,
27    Console,
28    Observation,
29    /// localStorage + sessionStorage + IndexedDB names. Default off.
30    Storage,
31}
32
33impl Artifact {
34    /// The nine default artifacts captured on every browser fetch.
35    /// `Storage` is intentionally excluded — sensitive data risk means
36    /// agents must opt in with `--want storage`.
37    pub const ALL: [Self; 9] = [
38        Self::Body,
39        Self::RenderedHtml,
40        Self::Text,
41        Self::Content,
42        Self::ContentJson,
43        Self::Screenshot,
44        Self::Network,
45        Self::Console,
46        Self::Observation,
47    ];
48
49    /// Default filename portion (extension chosen from content-type for
50    /// `Body`; fixed for everything else). `body.<ext>` is filled in at
51    /// write time when the response headers are known.
52    #[must_use]
53    pub const fn filename_template(self) -> &'static str {
54        match self {
55            Self::Body => "body",
56            Self::RenderedHtml => "rendered.html",
57            Self::Text => "text.txt",
58            Self::Content => "content.md",
59            Self::ContentJson => "content.json",
60            Self::Screenshot => "page.png",
61            Self::Network => "network.json",
62            Self::Console => "console.json",
63            Self::Observation => "observation.json",
64            Self::Storage => "storage.json",
65        }
66    }
67
68    #[must_use]
69    pub const fn as_str(self) -> &'static str {
70        match self {
71            Self::Body => "body",
72            Self::RenderedHtml => "rendered_html",
73            Self::Text => "text",
74            Self::Content => "content",
75            Self::ContentJson => "content_json",
76            Self::Screenshot => "screenshot",
77            Self::Network => "network",
78            Self::Console => "console",
79            Self::Observation => "observation",
80            Self::Storage => "storage",
81        }
82    }
83}
84
85impl std::fmt::Display for Artifact {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        f.write_str(self.as_str())
88    }
89}
90
91/// Concrete on-disk locations for a single fetch. Built from `--out` (or
92/// the default system temp `afhttp-out/`) plus the request id.
93#[derive(Debug, Clone)]
94pub struct ArtifactPaths {
95    pub root: std::path::PathBuf,
96}
97
98impl ArtifactPaths {
99    /// Compose `<base>/<request_id>/`. Does not create the directory; the
100    /// fetch writer creates it just before producing the first file so a
101    /// fetch that fails before any artifact is captured leaves no
102    /// half-empty dirs behind.
103    #[must_use]
104    pub fn new(base: impl Into<std::path::PathBuf>, request_id: &RequestId) -> Self {
105        let base = crate::shared::path::absolute_lexical(base.into());
106        Self {
107            root: base.join(request_id.as_str()),
108        }
109    }
110
111    /// Path for a given artifact. `Body` returns `body` with no extension;
112    /// callers append the content-type-derived suffix.
113    #[must_use]
114    pub fn file_for(&self, artifact: Artifact) -> std::path::PathBuf {
115        self.root.join(artifact.filename_template())
116    }
117
118    /// `<root>/network-bodies/<request_id>.<ext>` per `§8`. The CDP
119    /// `request_id` (not the fetch request id) is what's interpolated by
120    /// callers; this helper just hands back the directory.
121    #[must_use]
122    pub fn network_bodies_dir(&self) -> std::path::PathBuf {
123        self.root.join("network-bodies")
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn all_default_tokens_present() {
133        assert_eq!(Artifact::ALL.len(), 9);
134        assert!(Artifact::ALL.contains(&Artifact::Content));
135        assert!(Artifact::ALL.contains(&Artifact::ContentJson));
136        // Storage is intentionally absent from ALL (default-off).
137        assert!(!Artifact::ALL.contains(&Artifact::Storage));
138    }
139
140    #[test]
141    fn filename_templates_match_spec_table() {
142        let table = [
143            (Artifact::Body, "body"),
144            (Artifact::RenderedHtml, "rendered.html"),
145            (Artifact::Text, "text.txt"),
146            (Artifact::Content, "content.md"),
147            (Artifact::ContentJson, "content.json"),
148            (Artifact::Screenshot, "page.png"),
149            (Artifact::Network, "network.json"),
150            (Artifact::Console, "console.json"),
151            (Artifact::Observation, "observation.json"),
152            (Artifact::Storage, "storage.json"),
153        ];
154        for (a, f) in table {
155            assert_eq!(a.filename_template(), f);
156        }
157    }
158
159    #[test]
160    fn artifact_paths_compose_under_request_id() {
161        let rid = RequestId("abc-123".into());
162        let paths = ArtifactPaths::new("/tmp/out", &rid);
163        assert_eq!(
164            paths.file_for(Artifact::Observation),
165            std::path::PathBuf::from("/tmp/out/abc-123/observation.json"),
166        );
167        assert_eq!(
168            paths.network_bodies_dir(),
169            std::path::PathBuf::from("/tmp/out/abc-123/network-bodies"),
170        );
171    }
172
173    #[test]
174    fn artifact_paths_absolutize_relative_out_dir() {
175        let rid = RequestId("abc-123".into());
176        let paths = ArtifactPaths::new("relative-out", &rid);
177        assert!(
178            paths.root.is_absolute(),
179            "artifact root must be absolute: {}",
180            paths.root.display()
181        );
182    }
183
184    #[test]
185    fn artifacts_serialize_to_snake_case() {
186        let all_including_storage: &[Artifact] = &[
187            Artifact::Body,
188            Artifact::RenderedHtml,
189            Artifact::Text,
190            Artifact::Content,
191            Artifact::ContentJson,
192            Artifact::Screenshot,
193            Artifact::Network,
194            Artifact::Console,
195            Artifact::Observation,
196            Artifact::Storage,
197        ];
198        for a in all_including_storage {
199            let s = serde_json::to_string(a).unwrap_or_default();
200            assert_eq!(s, format!("\"{}\"", a.as_str()));
201        }
202    }
203}