use std::io::Write;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, clap::ValueEnum)]
pub enum OutputFormat {
#[default]
Table,
Json,
Dotenv,
Shell,
}
pub enum OutputData<'a> {
SecretList {
env: &'a str,
secrets: &'a [(String, String)],
},
SecretItem { key: &'a str, value: &'a str },
ExportList {
env: &'a str,
secrets: &'a [(String, String)],
},
NotFound { key: &'a str },
}
#[derive(serde::Serialize)]
struct SecretPair<'a> {
key: &'a str,
value: &'a str,
}
#[derive(serde::Serialize)]
struct ListJson<'a> {
secrets: Vec<SecretPair<'a>>,
}
#[derive(serde::Serialize)]
struct ItemJson<'a> {
key: &'a str,
value: &'a str,
}
#[derive(serde::Serialize)]
struct ExportJson<'a> {
environment: &'a str,
secrets: Vec<SecretPair<'a>>,
}
#[derive(serde::Serialize)]
struct ErrorJson<'a> {
error: &'a str,
}
#[derive(Debug, thiserror::Error)]
pub enum FormatError {
#[error("write error: {0}")]
Io(#[from] std::io::Error),
#[error("json serialisation error: {0}")]
Json(#[from] serde_json::Error),
}
pub fn print_output(
format: OutputFormat,
data: OutputData<'_>,
writer: &mut impl Write,
) -> Result<(), FormatError> {
match format {
OutputFormat::Table => fmt_table(&data, writer),
OutputFormat::Json => fmt_json(&data, writer),
OutputFormat::Dotenv => fmt_dotenv(&data, writer),
OutputFormat::Shell => fmt_shell(&data, writer),
}
}
fn fmt_table(data: &OutputData<'_>, writer: &mut impl Write) -> Result<(), FormatError> {
match data {
OutputData::SecretList { env, secrets } => {
if secrets.is_empty() {
let _ = env; } else {
for (key, _value) in *secrets {
writeln!(writer, "{key}")?;
}
}
}
OutputData::SecretItem { key: _, value } => {
writeln!(writer, "{value}")?;
}
OutputData::ExportList { env: _, secrets } => {
for (key, value) in *secrets {
writeln!(writer, "{key}={value}")?;
}
}
OutputData::NotFound { key: _ } => {
}
}
Ok(())
}
fn fmt_json(data: &OutputData<'_>, writer: &mut impl Write) -> Result<(), FormatError> {
match data {
OutputData::SecretList { env: _, secrets } => {
let payload = ListJson {
secrets: secrets
.iter()
.map(|(k, v)| SecretPair { key: k, value: v })
.collect(),
};
serde_json::to_writer(writer.by_ref(), &payload)?;
writeln!(writer)?;
}
OutputData::SecretItem { key, value } => {
let payload = ItemJson { key, value };
serde_json::to_writer(writer.by_ref(), &payload)?;
writeln!(writer)?;
}
OutputData::ExportList { env, secrets } => {
let payload = ExportJson {
environment: env,
secrets: secrets
.iter()
.map(|(k, v)| SecretPair { key: k, value: v })
.collect(),
};
serde_json::to_writer(writer.by_ref(), &payload)?;
writeln!(writer)?;
}
OutputData::NotFound { key: _ } => {
let payload = ErrorJson {
error: "key not found",
};
serde_json::to_writer(writer.by_ref(), &payload)?;
writeln!(writer)?;
}
}
Ok(())
}
fn fmt_dotenv(data: &OutputData<'_>, writer: &mut impl Write) -> Result<(), FormatError> {
let secrets: &[(String, String)] = match data {
OutputData::SecretList { secrets, .. } => secrets,
OutputData::ExportList { secrets, .. } => secrets,
OutputData::SecretItem { key, value } => {
writeln!(writer, "{key}={value}")?;
return Ok(());
}
OutputData::NotFound { key: _ } => return Ok(()),
};
for (key, value) in secrets {
writeln!(writer, "{key}={value}")?;
}
Ok(())
}
fn shell_escape(value: &str) -> String {
value.replace('\'', r"'\''")
}
fn fmt_shell(data: &OutputData<'_>, writer: &mut impl Write) -> Result<(), FormatError> {
let secrets: &[(String, String)] = match data {
OutputData::SecretList { secrets, .. } => secrets,
OutputData::ExportList { secrets, .. } => secrets,
OutputData::SecretItem { key, value } => {
writeln!(writer, "export {key}='{}'", shell_escape(value))?;
return Ok(());
}
OutputData::NotFound { key: _ } => return Ok(()),
};
for (key, value) in secrets {
writeln!(writer, "export {key}='{}'", shell_escape(value))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn render(format: OutputFormat, data: OutputData<'_>) -> String {
let mut buf = Vec::new();
print_output(format, data, &mut buf).expect("print_output must not fail");
String::from_utf8(buf).expect("output must be valid UTF-8")
}
fn pairs(items: &[(&str, &str)]) -> Vec<(String, String)> {
items
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[test]
fn table_list_keys_only() {
let secrets = pairs(&[("API_KEY", "abc123"), ("DB_HOST", "localhost")]);
let out = render(
OutputFormat::Table,
OutputData::SecretList {
env: "development",
secrets: &secrets,
},
);
assert_eq!(out, "API_KEY\nDB_HOST\n");
}
#[test]
fn table_list_empty_writes_nothing_to_stdout() {
let secrets: Vec<(String, String)> = vec![];
let out = render(
OutputFormat::Table,
OutputData::SecretList {
env: "development",
secrets: &secrets,
},
);
assert_eq!(
out, "",
"empty list must produce no stdout output in table mode"
);
}
#[test]
fn table_item() {
let out = render(
OutputFormat::Table,
OutputData::SecretItem {
key: "API_KEY",
value: "abc123",
},
);
assert_eq!(out, "abc123\n");
}
#[test]
fn json_list_found() {
let secrets = pairs(&[("API_KEY", "abc123"), ("DB_HOST", "localhost")]);
let out = render(
OutputFormat::Json,
OutputData::SecretList {
env: "development",
secrets: &secrets,
},
);
let v: serde_json::Value = serde_json::from_str(out.trim()).expect("must be valid JSON");
let arr = v["secrets"].as_array().expect("secrets must be array");
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["key"], "API_KEY");
assert_eq!(arr[0]["value"], "abc123");
}
#[test]
fn json_list_empty() {
let secrets: Vec<(String, String)> = vec![];
let out = render(
OutputFormat::Json,
OutputData::SecretList {
env: "development",
secrets: &secrets,
},
);
let v: serde_json::Value = serde_json::from_str(out.trim()).expect("must be valid JSON");
assert_eq!(v["secrets"].as_array().unwrap().len(), 0);
}
#[test]
fn json_item_found() {
let out = render(
OutputFormat::Json,
OutputData::SecretItem {
key: "API_KEY",
value: "abc123",
},
);
let v: serde_json::Value = serde_json::from_str(out.trim()).expect("must be valid JSON");
assert_eq!(v["key"], "API_KEY");
assert_eq!(v["value"], "abc123");
}
#[test]
fn json_not_found() {
let out = render(OutputFormat::Json, OutputData::NotFound { key: "MISSING" });
let v: serde_json::Value = serde_json::from_str(out.trim()).expect("must be valid JSON");
assert_eq!(v["error"], "key not found");
}
#[test]
fn json_export() {
let secrets = pairs(&[("DB_PASS", "s3cr3t")]);
let out = render(
OutputFormat::Json,
OutputData::ExportList {
env: "production",
secrets: &secrets,
},
);
let v: serde_json::Value = serde_json::from_str(out.trim()).expect("must be valid JSON");
assert_eq!(v["environment"], "production");
assert_eq!(v["secrets"][0]["key"], "DB_PASS");
assert_eq!(v["secrets"][0]["value"], "s3cr3t");
}
#[test]
fn dotenv_basic() {
let secrets = pairs(&[("API_KEY", "abc123"), ("DB_HOST", "localhost")]);
let out = render(
OutputFormat::Dotenv,
OutputData::ExportList {
env: "development",
secrets: &secrets,
},
);
assert_eq!(out, "API_KEY=abc123\nDB_HOST=localhost\n");
}
#[test]
fn dotenv_value_with_equals() {
let secrets = pairs(&[("JDBC_URL", "jdbc:mysql://host/db?user=admin&pass=x")]);
let out = render(
OutputFormat::Dotenv,
OutputData::ExportList {
env: "development",
secrets: &secrets,
},
);
assert_eq!(out, "JDBC_URL=jdbc:mysql://host/db?user=admin&pass=x\n");
}
#[test]
fn shell_basic() {
let secrets = pairs(&[("DB_PASS", "s3cr3t")]);
let out = render(
OutputFormat::Shell,
OutputData::ExportList {
env: "development",
secrets: &secrets,
},
);
assert_eq!(out, "export DB_PASS='s3cr3t'\n");
}
#[test]
fn shell_single_quote_escape() {
let secrets = pairs(&[("MSG", "it's here")]);
let out = render(
OutputFormat::Shell,
OutputData::ExportList {
env: "development",
secrets: &secrets,
},
);
assert_eq!(out, "export MSG='it'\\''s here'\n");
}
#[test]
fn shell_special_chars_no_extra_escaping() {
let secrets = pairs(&[("CMD", "$HOME/`echo foo` and \"quotes\"")]);
let out = render(
OutputFormat::Shell,
OutputData::ExportList {
env: "development",
secrets: &secrets,
},
);
assert_eq!(out, "export CMD='$HOME/`echo foo` and \"quotes\"'\n");
}
#[test]
fn shell_escape_unit() {
assert_eq!(shell_escape("hello"), "hello");
assert_eq!(shell_escape("it's"), "it'\\''s");
assert_eq!(shell_escape("a'b'c"), "a'\\''b'\\''c");
}
}