Skip to main content

api_testing_core/
cli_history.rs

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}