1use std::io::Write;
2use std::path::{Path, PathBuf};
3
4use crate::{Result, cli_util, history};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum RequestCallHistoryAuth<'a> {
8 None,
9 HeaderOnly {
10 key: &'a str,
11 value: &'a str,
12 },
13 HeaderAndFlag {
14 header_key: &'a str,
15 header_value: &'a str,
16 flag_name: &'a str,
17 flag_value: &'a str,
18 },
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct RequestCallHistoryFlag<'a> {
23 pub name: &'a str,
24 pub value: Option<&'a str>,
25 pub quote_value: bool,
26}
27
28impl<'a> RequestCallHistoryFlag<'a> {
29 pub const fn option(name: &'a str, value: &'a str) -> Self {
30 Self {
31 name,
32 value: Some(value),
33 quote_value: true,
34 }
35 }
36
37 pub const fn raw(name: &'a str, value: &'a str) -> Self {
38 Self {
39 name,
40 value: Some(value),
41 quote_value: false,
42 }
43 }
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub struct RequestCallHistoryRecord<'a> {
48 pub stamp: &'a str,
49 pub exit_code: i32,
50 pub setup_dir: &'a Path,
51 pub invocation_dir: &'a Path,
52 pub command_name: &'a str,
53 pub endpoint_label_used: &'a str,
54 pub endpoint_value_used: &'a str,
55 pub log_url: bool,
56 pub auth: RequestCallHistoryAuth<'a>,
57 pub request_arg: &'a str,
58 pub extra_flags: &'a [RequestCallHistoryFlag<'a>],
59}
60
61pub fn resolve_history_file<F>(
62 cwd: &Path,
63 config_dir: Option<&Path>,
64 file_override_arg: Option<&str>,
65 env_override_var: &str,
66 resolve_setup_dir: F,
67 default_filename: &str,
68) -> Result<PathBuf>
69where
70 F: FnOnce(&Path, Option<&Path>) -> Result<PathBuf>,
71{
72 let setup_dir = resolve_setup_dir(cwd, config_dir)?;
73 let file_override = file_override_arg
74 .and_then(cli_util::trim_non_empty)
75 .or_else(|| {
76 std::env::var(env_override_var)
77 .ok()
78 .and_then(|s| cli_util::trim_non_empty(&s))
79 });
80 let file_override = file_override.as_deref().map(Path::new);
81
82 Ok(history::resolve_history_file(
83 &setup_dir,
84 file_override,
85 default_filename,
86 ))
87}
88
89pub fn run_history_command(
90 history_file: &Path,
91 tail: Option<u32>,
92 command_only: bool,
93 stdout: &mut dyn Write,
94 stderr: &mut dyn Write,
95) -> i32 {
96 if !history_file.is_file() {
97 let _ = writeln!(stderr, "History file not found: {}", history_file.display());
98 return 1;
99 }
100
101 let records = match history::read_records(history_file) {
102 Ok(v) => v,
103 Err(err) => {
104 let _ = writeln!(stderr, "{err}");
105 return 1;
106 }
107 };
108 if records.is_empty() {
109 return 3;
110 }
111
112 let n = tail.unwrap_or(1).max(1) as usize;
113 let start = records.len().saturating_sub(n);
114 for record in &records[start..] {
115 if command_only && record.starts_with('#') {
116 let trimmed = record
117 .split_once('\n')
118 .map(|(_first, rest)| rest)
119 .unwrap_or_default();
120 let _ = stdout.write_all(trimmed.as_bytes());
121 if trimmed.is_empty() {
122 let _ = stdout.write_all(b"\n\n");
123 }
124 } else {
125 let _ = stdout.write_all(record.as_bytes());
126 }
127 }
128
129 0
130}
131
132pub fn build_request_call_history_record(spec: RequestCallHistoryRecord<'_>) -> String {
133 let setup_rel = cli_util::maybe_relpath(spec.setup_dir, spec.invocation_dir);
134 let config_rel = cli_util::shell_quote(&setup_rel);
135 let request_rel = relative_cli_arg(spec.request_arg, spec.invocation_dir);
136
137 let mut record = String::new();
138 record.push_str(&format!(
139 "# {} exit={} setup_dir={setup_rel}",
140 spec.stamp, spec.exit_code
141 ));
142
143 if !spec.endpoint_label_used.is_empty() {
144 if spec.endpoint_label_used == "url" && !spec.log_url {
145 record.push_str(" url=<omitted>");
146 } else {
147 record.push_str(&format!(
148 " {}={}",
149 spec.endpoint_label_used, spec.endpoint_value_used
150 ));
151 }
152 }
153
154 match spec.auth {
155 RequestCallHistoryAuth::None => {}
156 RequestCallHistoryAuth::HeaderOnly { key, value } => {
157 if !value.is_empty() {
158 record.push_str(&format!(" {key}={value}"));
159 }
160 }
161 RequestCallHistoryAuth::HeaderAndFlag {
162 header_key,
163 header_value,
164 ..
165 } => {
166 if !header_value.is_empty() {
167 record.push_str(&format!(" {header_key}={header_value}"));
168 }
169 }
170 }
171
172 record.push('\n');
173 record.push_str(&format!("{} call \\\n", spec.command_name));
174 record.push_str(&format!(" --config-dir {config_rel} \\\n"));
175
176 if spec.endpoint_label_used == "env" && !spec.endpoint_value_used.is_empty() {
177 record.push_str(&format!(
178 " --env {} \\\n",
179 cli_util::shell_quote(spec.endpoint_value_used)
180 ));
181 } else if spec.endpoint_label_used == "url"
182 && !spec.endpoint_value_used.is_empty()
183 && spec.log_url
184 {
185 record.push_str(&format!(
186 " --url {} \\\n",
187 cli_util::shell_quote(spec.endpoint_value_used)
188 ));
189 }
190
191 if let RequestCallHistoryAuth::HeaderAndFlag {
192 flag_name,
193 flag_value,
194 ..
195 } = spec.auth
196 && !flag_value.is_empty()
197 {
198 record.push_str(&format!(
199 " --{flag_name} {} \\\n",
200 cli_util::shell_quote(flag_value)
201 ));
202 }
203
204 for flag in spec.extra_flags {
205 match flag.value {
206 Some(value) => {
207 let rendered_value = if flag.quote_value {
208 cli_util::shell_quote(value)
209 } else {
210 value.to_string()
211 };
212 record.push_str(&format!(" --{} {} \\\n", flag.name, rendered_value));
213 }
214 None => {
215 record.push_str(&format!(" --{} \\\n", flag.name));
216 }
217 }
218 }
219
220 record.push_str(&format!(" {} \\\n", cli_util::shell_quote(&request_rel)));
221 record.push_str("| jq .\n\n");
222 record
223}
224
225fn relative_cli_arg(arg: &str, invocation_dir: &Path) -> String {
226 let path = Path::new(arg);
227 if path.is_absolute() {
228 cli_util::maybe_relpath(path, invocation_dir)
229 } else {
230 arg.to_string()
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::{
237 RequestCallHistoryAuth, RequestCallHistoryFlag, RequestCallHistoryRecord,
238 build_request_call_history_record,
239 };
240 use pretty_assertions::assert_eq;
241 use std::path::Path;
242
243 #[test]
244 fn request_call_history_renders_env_token_command() {
245 let record = build_request_call_history_record(RequestCallHistoryRecord {
246 stamp: "2026-03-06T10:00:00Z",
247 exit_code: 0,
248 setup_dir: Path::new("/tmp/ws/setup/rest"),
249 invocation_dir: Path::new("/tmp/ws"),
250 command_name: "api-rest",
251 endpoint_label_used: "env",
252 endpoint_value_used: "local",
253 log_url: true,
254 auth: RequestCallHistoryAuth::HeaderAndFlag {
255 header_key: "token",
256 header_value: "default",
257 flag_name: "token",
258 flag_value: "default",
259 },
260 request_arg: "requests/health.request.json",
261 extra_flags: &[],
262 });
263
264 assert_eq!(
265 record,
266 concat!(
267 "# 2026-03-06T10:00:00Z exit=0 setup_dir=setup/rest env=local token=default\n",
268 "api-rest call \\\n",
269 " --config-dir 'setup/rest' \\\n",
270 " --env 'local' \\\n",
271 " --token 'default' \\\n",
272 " 'requests/health.request.json' \\\n",
273 "| jq .\n\n",
274 )
275 );
276 }
277
278 #[test]
279 fn request_call_history_omits_logged_url_and_rewrites_absolute_request_path() {
280 let record = build_request_call_history_record(RequestCallHistoryRecord {
281 stamp: "2026-03-06T10:00:00Z",
282 exit_code: 7,
283 setup_dir: Path::new("/tmp/ws/setup/grpc"),
284 invocation_dir: Path::new("/tmp/ws"),
285 command_name: "api-grpc",
286 endpoint_label_used: "url",
287 endpoint_value_used: "127.0.0.1:50051",
288 log_url: false,
289 auth: RequestCallHistoryAuth::HeaderOnly {
290 key: "auth",
291 value: "ACCESS_TOKEN",
292 },
293 request_arg: "/tmp/ws/requests/health.grpc.json",
294 extra_flags: &[],
295 });
296
297 assert_eq!(
298 record,
299 concat!(
300 "# 2026-03-06T10:00:00Z exit=7 setup_dir=setup/grpc url=<omitted> auth=ACCESS_TOKEN\n",
301 "api-grpc call \\\n",
302 " --config-dir 'setup/grpc' \\\n",
303 " 'requests/health.grpc.json' \\\n",
304 "| jq .\n\n",
305 )
306 );
307 }
308
309 #[test]
310 fn request_call_history_appends_extra_flags_before_request_arg() {
311 let extra_flags = [RequestCallHistoryFlag::raw("format", "json")];
312 let record = build_request_call_history_record(RequestCallHistoryRecord {
313 stamp: "2026-03-06T10:00:00Z",
314 exit_code: 0,
315 setup_dir: Path::new("/tmp/ws/setup/websocket"),
316 invocation_dir: Path::new("/tmp/ws"),
317 command_name: "api-websocket",
318 endpoint_label_used: "",
319 endpoint_value_used: "",
320 log_url: true,
321 auth: RequestCallHistoryAuth::None,
322 request_arg: "requests/health.ws.json",
323 extra_flags: &extra_flags,
324 });
325
326 assert_eq!(
327 record,
328 concat!(
329 "# 2026-03-06T10:00:00Z exit=0 setup_dir=setup/websocket\n",
330 "api-websocket call \\\n",
331 " --config-dir 'setup/websocket' \\\n",
332 " --format json \\\n",
333 " 'requests/health.ws.json' \\\n",
334 "| jq .\n\n",
335 )
336 );
337 }
338}