use std::io::Write;
use std::path::{Path, PathBuf};
use crate::{Result, cli_util, history};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RequestCallHistoryAuth<'a> {
None,
HeaderOnly {
key: &'a str,
value: &'a str,
},
HeaderAndFlag {
header_key: &'a str,
header_value: &'a str,
flag_name: &'a str,
flag_value: &'a str,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RequestCallHistoryFlag<'a> {
pub name: &'a str,
pub value: Option<&'a str>,
pub quote_value: bool,
}
impl<'a> RequestCallHistoryFlag<'a> {
pub const fn option(name: &'a str, value: &'a str) -> Self {
Self {
name,
value: Some(value),
quote_value: true,
}
}
pub const fn raw(name: &'a str, value: &'a str) -> Self {
Self {
name,
value: Some(value),
quote_value: false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RequestCallHistoryRecord<'a> {
pub stamp: &'a str,
pub exit_code: i32,
pub setup_dir: &'a Path,
pub invocation_dir: &'a Path,
pub command_name: &'a str,
pub endpoint_label_used: &'a str,
pub endpoint_value_used: &'a str,
pub log_url: bool,
pub auth: RequestCallHistoryAuth<'a>,
pub request_arg: &'a str,
pub extra_flags: &'a [RequestCallHistoryFlag<'a>],
}
pub fn resolve_history_file<F>(
cwd: &Path,
config_dir: Option<&Path>,
file_override_arg: Option<&str>,
env_override_var: &str,
resolve_setup_dir: F,
default_filename: &str,
) -> Result<PathBuf>
where
F: FnOnce(&Path, Option<&Path>) -> Result<PathBuf>,
{
let setup_dir = resolve_setup_dir(cwd, config_dir)?;
let file_override = file_override_arg
.and_then(cli_util::trim_non_empty)
.or_else(|| {
std::env::var(env_override_var)
.ok()
.and_then(|s| cli_util::trim_non_empty(&s))
});
let file_override = file_override.as_deref().map(Path::new);
Ok(history::resolve_history_file(
&setup_dir,
file_override,
default_filename,
))
}
pub fn run_history_command(
history_file: &Path,
tail: Option<u32>,
command_only: bool,
stdout: &mut dyn Write,
stderr: &mut dyn Write,
) -> i32 {
if !history_file.is_file() {
let _ = writeln!(stderr, "History file not found: {}", history_file.display());
return 1;
}
let records = match history::read_records(history_file) {
Ok(v) => v,
Err(err) => {
let _ = writeln!(stderr, "{err}");
return 1;
}
};
if records.is_empty() {
return 3;
}
let n = tail.unwrap_or(1).max(1) as usize;
let start = records.len().saturating_sub(n);
for record in &records[start..] {
if command_only && record.starts_with('#') {
let trimmed = record
.split_once('\n')
.map(|(_first, rest)| rest)
.unwrap_or_default();
let _ = stdout.write_all(trimmed.as_bytes());
if trimmed.is_empty() {
let _ = stdout.write_all(b"\n\n");
}
} else {
let _ = stdout.write_all(record.as_bytes());
}
}
0
}
pub fn build_request_call_history_record(spec: RequestCallHistoryRecord<'_>) -> String {
let setup_rel = cli_util::maybe_relpath(spec.setup_dir, spec.invocation_dir);
let config_rel = cli_util::shell_quote(&setup_rel);
let request_rel = relative_cli_arg(spec.request_arg, spec.invocation_dir);
let mut record = String::new();
record.push_str(&format!(
"# {} exit={} setup_dir={setup_rel}",
spec.stamp, spec.exit_code
));
if !spec.endpoint_label_used.is_empty() {
if spec.endpoint_label_used == "url" && !spec.log_url {
record.push_str(" url=<omitted>");
} else {
record.push_str(&format!(
" {}={}",
spec.endpoint_label_used, spec.endpoint_value_used
));
}
}
match spec.auth {
RequestCallHistoryAuth::None => {}
RequestCallHistoryAuth::HeaderOnly { key, value } => {
if !value.is_empty() {
record.push_str(&format!(" {key}={value}"));
}
}
RequestCallHistoryAuth::HeaderAndFlag {
header_key,
header_value,
..
} => {
if !header_value.is_empty() {
record.push_str(&format!(" {header_key}={header_value}"));
}
}
}
record.push('\n');
record.push_str(&format!("{} call \\\n", spec.command_name));
record.push_str(&format!(" --config-dir {config_rel} \\\n"));
if spec.endpoint_label_used == "env" && !spec.endpoint_value_used.is_empty() {
record.push_str(&format!(
" --env {} \\\n",
cli_util::shell_quote(spec.endpoint_value_used)
));
} else if spec.endpoint_label_used == "url"
&& !spec.endpoint_value_used.is_empty()
&& spec.log_url
{
record.push_str(&format!(
" --url {} \\\n",
cli_util::shell_quote(spec.endpoint_value_used)
));
}
if let RequestCallHistoryAuth::HeaderAndFlag {
flag_name,
flag_value,
..
} = spec.auth
&& !flag_value.is_empty()
{
record.push_str(&format!(
" --{flag_name} {} \\\n",
cli_util::shell_quote(flag_value)
));
}
for flag in spec.extra_flags {
match flag.value {
Some(value) => {
let rendered_value = if flag.quote_value {
cli_util::shell_quote(value)
} else {
value.to_string()
};
record.push_str(&format!(" --{} {} \\\n", flag.name, rendered_value));
}
None => {
record.push_str(&format!(" --{} \\\n", flag.name));
}
}
}
record.push_str(&format!(" {} \\\n", cli_util::shell_quote(&request_rel)));
record.push_str("| jq .\n\n");
record
}
fn relative_cli_arg(arg: &str, invocation_dir: &Path) -> String {
let path = Path::new(arg);
if path.is_absolute() {
cli_util::maybe_relpath(path, invocation_dir)
} else {
arg.to_string()
}
}
#[cfg(test)]
mod tests {
use super::{
RequestCallHistoryAuth, RequestCallHistoryFlag, RequestCallHistoryRecord,
build_request_call_history_record,
};
use pretty_assertions::assert_eq;
use std::path::Path;
#[test]
fn request_call_history_renders_env_token_command() {
let record = build_request_call_history_record(RequestCallHistoryRecord {
stamp: "2026-03-06T10:00:00Z",
exit_code: 0,
setup_dir: Path::new("/tmp/ws/setup/rest"),
invocation_dir: Path::new("/tmp/ws"),
command_name: "api-rest",
endpoint_label_used: "env",
endpoint_value_used: "local",
log_url: true,
auth: RequestCallHistoryAuth::HeaderAndFlag {
header_key: "token",
header_value: "default",
flag_name: "token",
flag_value: "default",
},
request_arg: "requests/health.request.json",
extra_flags: &[],
});
assert_eq!(
record,
concat!(
"# 2026-03-06T10:00:00Z exit=0 setup_dir=setup/rest env=local token=default\n",
"api-rest call \\\n",
" --config-dir 'setup/rest' \\\n",
" --env 'local' \\\n",
" --token 'default' \\\n",
" 'requests/health.request.json' \\\n",
"| jq .\n\n",
)
);
}
#[test]
fn request_call_history_omits_logged_url_and_rewrites_absolute_request_path() {
let record = build_request_call_history_record(RequestCallHistoryRecord {
stamp: "2026-03-06T10:00:00Z",
exit_code: 7,
setup_dir: Path::new("/tmp/ws/setup/grpc"),
invocation_dir: Path::new("/tmp/ws"),
command_name: "api-grpc",
endpoint_label_used: "url",
endpoint_value_used: "127.0.0.1:50051",
log_url: false,
auth: RequestCallHistoryAuth::HeaderOnly {
key: "auth",
value: "ACCESS_TOKEN",
},
request_arg: "/tmp/ws/requests/health.grpc.json",
extra_flags: &[],
});
assert_eq!(
record,
concat!(
"# 2026-03-06T10:00:00Z exit=7 setup_dir=setup/grpc url=<omitted> auth=ACCESS_TOKEN\n",
"api-grpc call \\\n",
" --config-dir 'setup/grpc' \\\n",
" 'requests/health.grpc.json' \\\n",
"| jq .\n\n",
)
);
}
#[test]
fn request_call_history_appends_extra_flags_before_request_arg() {
let extra_flags = [RequestCallHistoryFlag::raw("format", "json")];
let record = build_request_call_history_record(RequestCallHistoryRecord {
stamp: "2026-03-06T10:00:00Z",
exit_code: 0,
setup_dir: Path::new("/tmp/ws/setup/websocket"),
invocation_dir: Path::new("/tmp/ws"),
command_name: "api-websocket",
endpoint_label_used: "",
endpoint_value_used: "",
log_url: true,
auth: RequestCallHistoryAuth::None,
request_arg: "requests/health.ws.json",
extra_flags: &extra_flags,
});
assert_eq!(
record,
concat!(
"# 2026-03-06T10:00:00Z exit=0 setup_dir=setup/websocket\n",
"api-websocket call \\\n",
" --config-dir 'setup/websocket' \\\n",
" --format json \\\n",
" 'requests/health.ws.json' \\\n",
"| jq .\n\n",
)
);
}
}