Skip to main content

api_testing_core/
cli_history.rs

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}