Skip to main content

agent_first_http/shared/
artifacts.rs

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