1use std::io::Write;
2use std::path::{Path, PathBuf};
3
4use crate::{Result, auth_env::CliAuthSource, 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
61#[derive(Debug, Clone, Copy)]
62pub struct RequestCallHistoryAppend<'a> {
63 pub enabled: bool,
64 pub history_writer: &'a history::HistoryWriter,
65 pub exit_code: i32,
66 pub setup_dir: &'a Path,
67 pub invocation_dir: &'a Path,
68 pub command_name: &'a str,
69 pub endpoint_label_used: &'a str,
70 pub endpoint_value_used: &'a str,
71 pub log_url: bool,
72 pub auth_source: &'a CliAuthSource,
73 pub token_name_for_log: &'a str,
74 pub request_arg: &'a str,
75 pub extra_flags: &'a [RequestCallHistoryFlag<'a>],
76 pub warning_label: &'a str,
77}
78
79pub fn resolve_history_file<F>(
80 cwd: &Path,
81 config_dir: Option<&Path>,
82 file_override_arg: Option<&str>,
83 env_override_var: &str,
84 resolve_setup_dir: F,
85 default_filename: &str,
86) -> Result<PathBuf>
87where
88 F: FnOnce(&Path, Option<&Path>) -> Result<PathBuf>,
89{
90 let setup_dir = resolve_setup_dir(cwd, config_dir)?;
91 let file_override = file_override_arg
92 .and_then(cli_util::trim_non_empty)
93 .or_else(|| {
94 std::env::var(env_override_var)
95 .ok()
96 .and_then(|s| cli_util::trim_non_empty(&s))
97 });
98 let file_override = file_override.as_deref().map(Path::new);
99
100 Ok(history::resolve_history_file(
101 &setup_dir,
102 file_override,
103 default_filename,
104 ))
105}
106
107pub fn run_history_command(
108 history_file: &Path,
109 tail: Option<u32>,
110 command_only: bool,
111 stdout: &mut dyn Write,
112 stderr: &mut dyn Write,
113) -> i32 {
114 if !history_file.is_file() {
115 let _ = writeln!(stderr, "History file not found: {}", history_file.display());
116 return 1;
117 }
118
119 let records = match history::read_records(history_file) {
120 Ok(v) => v,
121 Err(err) => {
122 let _ = writeln!(stderr, "{err}");
123 return 1;
124 }
125 };
126 if records.is_empty() {
127 return 3;
128 }
129
130 for record in select_history_records(&records, tail, command_only) {
131 let _ = stdout.write_all(record.as_bytes());
132 }
133
134 0
135}
136
137pub fn select_history_records(
138 records: &[String],
139 tail: Option<u32>,
140 command_only: bool,
141) -> Vec<String> {
142 let n = tail.unwrap_or(1).max(1) as usize;
143 let start = records.len().saturating_sub(n);
144 records[start..]
145 .iter()
146 .map(|record| render_history_record(record, command_only))
147 .collect()
148}
149
150pub fn render_history_record(record: &str, command_only: bool) -> String {
151 if !command_only || !record.starts_with('#') {
152 return record.to_string();
153 }
154
155 let trimmed = record
156 .split_once('\n')
157 .map(|(_first, rest)| rest)
158 .unwrap_or_default();
159
160 if trimmed.is_empty() {
161 "\n\n".to_string()
162 } else {
163 trimmed.to_string()
164 }
165}
166
167pub fn append_request_call_history_best_effort(
168 spec: RequestCallHistoryAppend<'_>,
169 stderr: &mut dyn Write,
170) {
171 if !spec.enabled {
172 return;
173 }
174
175 let stamp = cli_util::history_timestamp_now().unwrap_or_default();
176 let auth = match spec.auth_source {
177 CliAuthSource::TokenProfile => RequestCallHistoryAuth::HeaderAndFlag {
178 header_key: "token",
179 header_value: spec.token_name_for_log,
180 flag_name: "token",
181 flag_value: spec.token_name_for_log,
182 },
183 CliAuthSource::EnvFallback { env_name } => RequestCallHistoryAuth::HeaderOnly {
184 key: "auth",
185 value: env_name,
186 },
187 CliAuthSource::None => RequestCallHistoryAuth::None,
188 };
189
190 let record = build_request_call_history_record(RequestCallHistoryRecord {
191 stamp: &stamp,
192 exit_code: spec.exit_code,
193 setup_dir: spec.setup_dir,
194 invocation_dir: spec.invocation_dir,
195 command_name: spec.command_name,
196 endpoint_label_used: spec.endpoint_label_used,
197 endpoint_value_used: spec.endpoint_value_used,
198 log_url: spec.log_url,
199 auth,
200 request_arg: spec.request_arg,
201 extra_flags: spec.extra_flags,
202 });
203
204 if let Err(err) = spec.history_writer.append(&record) {
205 let warning_label = spec.warning_label.trim();
206 let warning_label = if warning_label.is_empty() {
207 spec.command_name
208 } else {
209 warning_label
210 };
211 let _ = writeln!(
212 stderr,
213 "warning: failed to append {warning_label} history: {err}"
214 );
215 }
216}
217
218pub fn build_request_call_history_record(spec: RequestCallHistoryRecord<'_>) -> String {
219 build_request_call_history_record_with_extra_args(spec, &[])
220}
221
222pub fn build_request_call_history_record_with_extra_args(
223 spec: RequestCallHistoryRecord<'_>,
224 extra_args: &[&str],
225) -> String {
226 let setup_rel = cli_util::maybe_relpath(spec.setup_dir, spec.invocation_dir);
227 let config_rel = cli_util::shell_quote(&setup_rel);
228 let request_rel = relative_cli_arg(spec.request_arg, spec.invocation_dir);
229
230 let mut record = String::new();
231 record.push_str(&format!(
232 "# {} exit={} setup_dir={setup_rel}",
233 spec.stamp, spec.exit_code
234 ));
235
236 if !spec.endpoint_label_used.is_empty() {
237 if spec.endpoint_label_used == "url" && !spec.log_url {
238 record.push_str(" url=<omitted>");
239 } else {
240 record.push_str(&format!(
241 " {}={}",
242 spec.endpoint_label_used, spec.endpoint_value_used
243 ));
244 }
245 }
246
247 match spec.auth {
248 RequestCallHistoryAuth::None => {}
249 RequestCallHistoryAuth::HeaderOnly { key, value } => {
250 if !value.is_empty() {
251 record.push_str(&format!(" {key}={value}"));
252 }
253 }
254 RequestCallHistoryAuth::HeaderAndFlag {
255 header_key,
256 header_value,
257 ..
258 } => {
259 if !header_value.is_empty() {
260 record.push_str(&format!(" {header_key}={header_value}"));
261 }
262 }
263 }
264
265 record.push('\n');
266 record.push_str(&format!("{} call \\\n", spec.command_name));
267 record.push_str(&format!(" --config-dir {config_rel} \\\n"));
268
269 if spec.endpoint_label_used == "env" && !spec.endpoint_value_used.is_empty() {
270 record.push_str(&format!(
271 " --env {} \\\n",
272 cli_util::shell_quote(spec.endpoint_value_used)
273 ));
274 } else if spec.endpoint_label_used == "url"
275 && !spec.endpoint_value_used.is_empty()
276 && spec.log_url
277 {
278 record.push_str(&format!(
279 " --url {} \\\n",
280 cli_util::shell_quote(spec.endpoint_value_used)
281 ));
282 }
283
284 if let RequestCallHistoryAuth::HeaderAndFlag {
285 flag_name,
286 flag_value,
287 ..
288 } = spec.auth
289 && !flag_value.is_empty()
290 {
291 record.push_str(&format!(
292 " --{flag_name} {} \\\n",
293 cli_util::shell_quote(flag_value)
294 ));
295 }
296
297 for flag in spec.extra_flags {
298 match flag.value {
299 Some(value) => {
300 let rendered_value = if flag.quote_value {
301 cli_util::shell_quote(value)
302 } else {
303 value.to_string()
304 };
305 record.push_str(&format!(" --{} {} \\\n", flag.name, rendered_value));
306 }
307 None => {
308 record.push_str(&format!(" --{} \\\n", flag.name));
309 }
310 }
311 }
312
313 record.push_str(&format!(" {} \\\n", cli_util::shell_quote(&request_rel)));
314 for arg in extra_args {
315 let rel = relative_cli_arg(arg, spec.invocation_dir);
316 record.push_str(&format!(" {} \\\n", cli_util::shell_quote(&rel)));
317 }
318 record.push_str("| jq .\n\n");
319 record
320}
321
322fn relative_cli_arg(arg: &str, invocation_dir: &Path) -> String {
323 let path = Path::new(arg);
324 if path.is_absolute() {
325 cli_util::maybe_relpath(path, invocation_dir)
326 } else {
327 arg.to_string()
328 }
329}
330
331#[cfg(test)]
332mod tests {
333 use super::{
334 RequestCallHistoryAuth, RequestCallHistoryFlag, RequestCallHistoryRecord,
335 build_request_call_history_record, build_request_call_history_record_with_extra_args,
336 };
337 use pretty_assertions::assert_eq;
338 use std::path::Path;
339
340 #[test]
341 fn request_call_history_renders_env_token_command() {
342 let record = build_request_call_history_record(RequestCallHistoryRecord {
343 stamp: "2026-03-06T10:00:00Z",
344 exit_code: 0,
345 setup_dir: Path::new("/tmp/ws/setup/rest"),
346 invocation_dir: Path::new("/tmp/ws"),
347 command_name: "api-rest",
348 endpoint_label_used: "env",
349 endpoint_value_used: "local",
350 log_url: true,
351 auth: RequestCallHistoryAuth::HeaderAndFlag {
352 header_key: "token",
353 header_value: "default",
354 flag_name: "token",
355 flag_value: "default",
356 },
357 request_arg: "requests/health.request.json",
358 extra_flags: &[],
359 });
360
361 assert_eq!(
362 record,
363 concat!(
364 "# 2026-03-06T10:00:00Z exit=0 setup_dir=setup/rest env=local token=default\n",
365 "api-rest call \\\n",
366 " --config-dir 'setup/rest' \\\n",
367 " --env 'local' \\\n",
368 " --token 'default' \\\n",
369 " 'requests/health.request.json' \\\n",
370 "| jq .\n\n",
371 )
372 );
373 }
374
375 #[test]
376 fn request_call_history_omits_logged_url_and_rewrites_absolute_request_path() {
377 let record = build_request_call_history_record(RequestCallHistoryRecord {
378 stamp: "2026-03-06T10:00:00Z",
379 exit_code: 7,
380 setup_dir: Path::new("/tmp/ws/setup/grpc"),
381 invocation_dir: Path::new("/tmp/ws"),
382 command_name: "api-grpc",
383 endpoint_label_used: "url",
384 endpoint_value_used: "127.0.0.1:50051",
385 log_url: false,
386 auth: RequestCallHistoryAuth::HeaderOnly {
387 key: "auth",
388 value: "ACCESS_TOKEN",
389 },
390 request_arg: "/tmp/ws/requests/health.grpc.json",
391 extra_flags: &[],
392 });
393
394 assert_eq!(
395 record,
396 concat!(
397 "# 2026-03-06T10:00:00Z exit=7 setup_dir=setup/grpc url=<omitted> auth=ACCESS_TOKEN\n",
398 "api-grpc call \\\n",
399 " --config-dir 'setup/grpc' \\\n",
400 " 'requests/health.grpc.json' \\\n",
401 "| jq .\n\n",
402 )
403 );
404 }
405
406 #[test]
407 fn request_call_history_appends_extra_flags_before_request_arg() {
408 let extra_flags = [RequestCallHistoryFlag::raw("format", "json")];
409 let record = build_request_call_history_record(RequestCallHistoryRecord {
410 stamp: "2026-03-06T10:00:00Z",
411 exit_code: 0,
412 setup_dir: Path::new("/tmp/ws/setup/websocket"),
413 invocation_dir: Path::new("/tmp/ws"),
414 command_name: "api-websocket",
415 endpoint_label_used: "",
416 endpoint_value_used: "",
417 log_url: true,
418 auth: RequestCallHistoryAuth::None,
419 request_arg: "requests/health.ws.json",
420 extra_flags: &extra_flags,
421 });
422
423 assert_eq!(
424 record,
425 concat!(
426 "# 2026-03-06T10:00:00Z exit=0 setup_dir=setup/websocket\n",
427 "api-websocket call \\\n",
428 " --config-dir 'setup/websocket' \\\n",
429 " --format json \\\n",
430 " 'requests/health.ws.json' \\\n",
431 "| jq .\n\n",
432 )
433 );
434 }
435
436 #[test]
437 fn request_call_history_appends_extra_positional_args_after_request_arg() {
438 let extra_args = ["vars.json"];
439 let record = build_request_call_history_record_with_extra_args(
440 RequestCallHistoryRecord {
441 stamp: "2026-03-06T10:00:00Z",
442 exit_code: 0,
443 setup_dir: Path::new("/tmp/ws/setup/graphql"),
444 invocation_dir: Path::new("/tmp/ws"),
445 command_name: "api-gql",
446 endpoint_label_used: "url",
447 endpoint_value_used: "https://api.example/graphql",
448 log_url: true,
449 auth: RequestCallHistoryAuth::HeaderAndFlag {
450 header_key: "jwt",
451 header_value: "admin",
452 flag_name: "jwt",
453 flag_value: "admin",
454 },
455 request_arg: "q.graphql",
456 extra_flags: &[],
457 },
458 &extra_args,
459 );
460
461 assert_eq!(
462 record,
463 concat!(
464 "# 2026-03-06T10:00:00Z exit=0 setup_dir=setup/graphql url=https://api.example/graphql jwt=admin\n",
465 "api-gql call \\\n",
466 " --config-dir 'setup/graphql' \\\n",
467 " --url 'https://api.example/graphql' \\\n",
468 " --jwt 'admin' \\\n",
469 " 'q.graphql' \\\n",
470 " 'vars.json' \\\n",
471 "| jq .\n\n",
472 )
473 );
474 }
475}