1#![cfg(feature = "async")]
14
15use http::HeaderMap;
16
17#[non_exhaustive]
24#[derive(Clone)]
25pub struct DryRun {
26 pub method: reqwest::Method,
28 pub url: String,
30 pub headers: HeaderMap,
32 pub body: serde_json::Value,
34}
35
36impl DryRun {
37 #[must_use]
39 pub fn body(&self) -> &serde_json::Value {
40 &self.body
41 }
42
43 #[must_use]
45 pub fn body_pretty(&self) -> String {
46 serde_json::to_string_pretty(&self.body).unwrap_or_default()
48 }
49
50 #[must_use]
54 pub fn to_curl(&self) -> String {
55 self.to_curl_inner(None)
56 }
57
58 #[must_use]
62 pub fn to_curl_with_key(&self, api_key: &str) -> String {
63 self.to_curl_inner(Some(api_key))
64 }
65
66 fn to_curl_inner(&self, api_key: Option<&str>) -> String {
67 let mut out = String::with_capacity(256);
68 out.push_str("curl -X ");
69 out.push_str(self.method.as_str());
70 out.push(' ');
71 push_shell_quoted(&mut out, &self.url);
72 for (name, value) in &self.headers {
73 let name_str = name.as_str();
74 let value_str = match name_str {
75 "x-api-key" => api_key.unwrap_or("<REDACTED>"),
76 "authorization" => "<REDACTED>",
77 _ => value.to_str().unwrap_or("<binary>"),
78 };
79 out.push_str(" \\\n -H ");
80 push_shell_quoted(&mut out, &format!("{name_str}: {value_str}"));
81 }
82 out.push_str(" \\\n -d ");
83 push_shell_quoted(&mut out, &self.body_pretty());
84 out
85 }
86}
87
88impl std::fmt::Debug for DryRun {
89 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90 let mut redacted = self.headers.clone();
91 for name in ["x-api-key", "authorization"] {
92 if redacted.contains_key(name) {
93 redacted.insert(name, "<REDACTED>".parse().unwrap());
94 }
95 }
96 f.debug_struct("DryRun")
97 .field("method", &self.method)
98 .field("url", &self.url)
99 .field("headers", &redacted)
100 .field("body", &self.body)
101 .finish()
102 }
103}
104
105fn push_shell_quoted(out: &mut String, s: &str) {
107 out.push('\'');
108 for ch in s.chars() {
109 if ch == '\'' {
110 out.push_str("'\\''");
112 } else {
113 out.push(ch);
114 }
115 }
116 out.push('\'');
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122 use pretty_assertions::assert_eq;
123 use serde_json::json;
124
125 fn sample() -> DryRun {
126 let mut headers = HeaderMap::new();
127 headers.insert("x-api-key", "sk-ant-secret".parse().unwrap());
128 headers.insert("anthropic-version", "2023-06-01".parse().unwrap());
129 headers.insert("content-type", "application/json".parse().unwrap());
130 DryRun {
131 method: reqwest::Method::POST,
132 url: "https://api.anthropic.com/v1/messages".into(),
133 headers,
134 body: json!({"model": "claude-sonnet-4-6", "max_tokens": 8}),
135 }
136 }
137
138 #[test]
139 fn body_pretty_returns_indented_json() {
140 let dr = sample();
141 let p = dr.body_pretty();
142 assert!(p.contains("\"model\": \"claude-sonnet-4-6\""));
143 assert!(p.contains('\n'));
144 }
145
146 #[test]
147 fn to_curl_redacts_api_key_by_default() {
148 let dr = sample();
149 let curl = dr.to_curl();
150 assert!(curl.contains("x-api-key: <REDACTED>"));
151 assert!(!curl.contains("sk-ant-secret"));
152 assert!(curl.contains("anthropic-version: 2023-06-01"));
153 assert!(curl.starts_with("curl -X POST 'https://api.anthropic.com/v1/messages'"));
154 }
155
156 #[test]
157 fn to_curl_with_key_inlines_key() {
158 let dr = sample();
159 let curl = dr.to_curl_with_key("sk-ant-real");
160 assert!(curl.contains("x-api-key: sk-ant-real"));
161 }
162
163 #[test]
164 fn debug_redacts_auth_headers() {
165 let dr = sample();
166 let s = format!("{dr:?}");
167 assert!(!s.contains("sk-ant-secret"));
168 assert!(s.contains("<REDACTED>"));
169 }
170
171 #[test]
172 fn debug_passes_through_non_auth_headers() {
173 let dr = sample();
174 let s = format!("{dr:?}");
175 assert!(s.contains("anthropic-version"));
176 }
177
178 #[test]
179 fn shell_quoting_escapes_single_quotes() {
180 let mut out = String::new();
181 push_shell_quoted(&mut out, "it's");
182 assert_eq!(out, "'it'\\''s'");
183 }
184}