use std::fmt::Write as _;
use std::io::Write;
use std::path::Path;
use clap::ValueEnum;
use crate::MonosecretError;
use crate::Result;
#[derive(Copy, Clone, Debug, ValueEnum, PartialEq, Eq)]
pub enum Shell {
#[value(alias("sh"), alias("zsh"))]
Bash,
Fish,
#[value(alias("pwsh"))]
Powershell,
#[value(alias("nu"))]
Nushell,
Github,
Gitlab,
Dotenv,
}
impl Shell {
pub fn as_str(self) -> &'static str {
match self {
Shell::Bash => "bash",
Shell::Fish => "fish",
Shell::Powershell => "powershell",
Shell::Nushell => "nushell",
Shell::Github => "github",
Shell::Gitlab => "gitlab",
Shell::Dotenv => "dotenv",
}
}
}
pub fn emit(shell: Shell, pairs: &[(String, String)], output: Option<&Path>) -> Result<()> {
if shell == Shell::Github {
return emit_github(pairs, output);
}
let rendered = render(shell, pairs);
if let Some(path) = output {
let mut file = std::fs::File::create(path).map_err(|e| {
MonosecretError::EnvEmit(format!("Failed to create {}: {e}", path.display()))
})?;
file.write_all(rendered.as_bytes()).map_err(|e| {
MonosecretError::EnvEmit(format!("Failed to write {}: {e}", path.display()))
})?;
} else {
let stdout = std::io::stdout();
let mut lock = stdout.lock();
lock.write_all(rendered.as_bytes())
.map_err(|e| MonosecretError::EnvEmit(format!("Failed to write to stdout: {e}")))?;
}
Ok(())
}
pub fn render(shell: Shell, pairs: &[(String, String)]) -> String {
let mut out = String::new();
for (key, value) in pairs {
match shell {
Shell::Bash => {
let _ = writeln!(&mut out, "export {}={};", key, bash_quote(value));
}
Shell::Fish => {
let _ = writeln!(&mut out, "set -gx {} {};", key, fish_quote(value));
}
Shell::Powershell => {
let _ = writeln!(&mut out, "$env:{}={};", key, ps_quote(value));
}
Shell::Nushell => {} Shell::Github => {
let delim = github_delimiter(value);
let _ = write!(&mut out, "{key}<<{delim}\n{value}\n{delim}\n");
}
Shell::Gitlab | Shell::Dotenv => {
let _ = writeln!(&mut out, "{}={}", key, dotenv_quote(value));
}
}
}
if shell == Shell::Nushell {
out.push_str("load-env {\n");
for (key, value) in pairs {
let _ = writeln!(&mut out, " {key}: {}", nu_quote(value));
}
out.push_str("}\n");
}
out
}
fn emit_github(pairs: &[(String, String)], output: Option<&Path>) -> Result<()> {
let path = match output {
Some(p) => p.to_path_buf(),
None => {
std::env::var_os("GITHUB_ENV")
.map(std::path::PathBuf::from)
.ok_or_else(|| {
MonosecretError::EnvEmit(
"Not running inside GitHub Actions (no $GITHUB_ENV). \
Pass --output <path> to write the GitHub env file elsewhere, \
or use --shell dotenv for portable KEY=value output."
.to_string(),
)
})?
}
};
let rendered = render(Shell::Github, pairs);
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|e| MonosecretError::EnvEmit(format!("Failed to open {}: {e}", path.display())))?;
file.write_all(rendered.as_bytes()).map_err(|e| {
MonosecretError::EnvEmit(format!("Failed to append to {}: {e}", path.display()))
})?;
let stdout = std::io::stdout();
let mut lock = stdout.lock();
for (_, value) in pairs {
let _ = writeln!(lock, "::add-mask::{value}");
}
Ok(())
}
fn github_delimiter(value: &str) -> String {
const BASE: &str = "__MONOSECRET_ENV_EOF__";
if !value.contains(BASE) {
return BASE.to_string();
}
let mut n = 1;
loop {
let candidate = format!("{BASE}_{n}_");
if !value.contains(&candidate) {
return candidate;
}
n += 1;
}
}
fn bash_quote(value: &str) -> String {
let s = value.replace('\'', "'\\''");
format!("'{s}'")
}
fn fish_quote(value: &str) -> String {
let s = value.replace('\\', "\\\\").replace('\'', "\\'");
format!("'{s}'")
}
fn ps_quote(value: &str) -> String {
let s = value.replace('\'', "''");
format!("'{s}'")
}
fn nu_quote(value: &str) -> String {
let s = value.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{s}\"")
}
fn dotenv_quote(value: &str) -> String {
let s = value
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r");
format!("\"{s}\"")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bash_quotes_single_quotes() {
let snapshot = format!(
"plain => {}\na'b => {}\nwith $ and `cmd` => {}",
bash_quote("plain"),
bash_quote("a'b"),
bash_quote("with $ and `cmd`")
);
insta::assert_snapshot!(snapshot);
}
#[test]
fn fish_escapes_backslash_and_single_quote() {
let snapshot = format!(
"plain => {}\na'b => {}\na\\b => {}",
fish_quote("plain"),
fish_quote("a'b"),
fish_quote("a\\b")
);
insta::assert_snapshot!(snapshot);
}
#[test]
fn powershell_doubles_single_quotes() {
let snapshot = format!("plain => {}\na'b => {}", ps_quote("plain"), ps_quote("a'b"));
insta::assert_snapshot!(snapshot);
}
#[test]
fn nushell_escapes_backslash_and_double_quote() {
let snapshot = format!(
"plain => {}\na\"b => {}\na\\b => {}",
nu_quote("plain"),
nu_quote("a\"b"),
nu_quote("a\\b")
);
insta::assert_snapshot!(snapshot);
}
#[test]
fn dotenv_escapes_special_characters() {
let snapshot = format!(
"plain => {}\na\"b => {}\na\\b => {}\nmulti-line => {}\ncrlf => {}",
dotenv_quote("plain"),
dotenv_quote("a\"b"),
dotenv_quote("a\\b"),
dotenv_quote("multi\nline"),
dotenv_quote("crlf\r\n")
);
insta::assert_snapshot!(snapshot);
}
#[test]
fn render_bash_emits_exports() {
let pairs = [("API_KEY".to_string(), "secret".to_string())];
insta::assert_snapshot!(render(Shell::Bash, &pairs));
}
#[test]
fn render_fish_emits_set_gx() {
let pairs = [("API_KEY".to_string(), "secret value".to_string())];
insta::assert_snapshot!(render(Shell::Fish, &pairs));
}
#[test]
fn render_powershell_emits_env_assignments() {
let pairs = [("API_KEY".to_string(), "secret".to_string())];
insta::assert_snapshot!(render(Shell::Powershell, &pairs));
}
#[test]
fn render_nushell_emits_load_env_record() {
let pairs = [
("API_KEY".to_string(), "secret".to_string()),
("DB_URL".to_string(), "postgres://localhost".to_string()),
];
insta::assert_snapshot!(render(Shell::Nushell, &pairs));
}
#[test]
fn render_dotenv_and_gitlab_match() {
let pairs = [("API_KEY".to_string(), "secret".to_string())];
insta::assert_snapshot!("render_dotenv", render(Shell::Dotenv, &pairs));
insta::assert_snapshot!("render_gitlab", render(Shell::Gitlab, &pairs));
}
#[test]
fn render_github_uses_heredoc_blocks() {
let pairs = [("API_KEY".to_string(), "secret".to_string())];
insta::assert_snapshot!(render(Shell::Github, &pairs));
}
#[test]
fn github_delimiter_avoids_value_collisions() {
assert_eq!(github_delimiter("plain"), "__MONOSECRET_ENV_EOF__");
let collision = "__MONOSECRET_ENV_EOF__";
let d = github_delimiter(collision);
assert!(!collision.contains(&d));
assert!(!d.is_empty());
}
#[test]
fn github_multiline_value_uses_heredoc() {
let pairs = [("KEY".to_string(), "line1\nline2".to_string())];
insta::assert_snapshot!(render(Shell::Github, &pairs));
}
#[test]
fn emit_to_output_file_writes_render() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("env.sh");
let pairs = [("API_KEY".to_string(), "secret".to_string())];
emit(Shell::Bash, &pairs, Some(&path)).unwrap();
insta::assert_snapshot!(std::fs::read_to_string(&path).unwrap());
}
#[test]
fn emit_github_without_env_or_output_errors() {
unsafe {
std::env::remove_var("GITHUB_ENV");
}
let pairs = [("API_KEY".to_string(), "secret".to_string())];
let err = emit(Shell::Github, &pairs, None).unwrap_err();
assert!(err.to_string().contains("$GITHUB_ENV"));
}
#[test]
fn emit_github_appends_to_output_and_masks() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("gh_env");
let pairs = [
("API_KEY".to_string(), "secret".to_string()),
("DB".to_string(), "multi\nline".to_string()),
];
emit(Shell::Github, &pairs, Some(&path)).unwrap();
insta::assert_snapshot!(std::fs::read_to_string(&path).unwrap());
}
#[test]
fn emit_to_stdout_writes_rendered_dotenv() {
let pairs = [("API_KEY".to_string(), "secret".to_string())];
emit(Shell::Dotenv, &pairs, None).unwrap();
}
#[test]
fn emit_to_output_file_errors_when_parent_dir_missing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nonexistent_dir").join("env.sh");
let pairs = [("API_KEY".to_string(), "secret".to_string())];
let err = emit(Shell::Bash, &pairs, Some(&path)).unwrap_err();
assert!(err.to_string().contains("Failed to create"), "{err}");
}
}