Skip to main content

tracel_xtask/commands/
secrets.rs

1/// Manage AWS Secrets Manager secrets.
2use std::{collections::HashMap, fmt::Write as _, fs, io, path::PathBuf};
3
4use anyhow::Context as _;
5
6use crate::prelude::{Context, Environment};
7use crate::utils::aws::cli::{
8    secretsmanager_create_empty_secret, secretsmanager_get_secret_string,
9    secretsmanager_list_secret_versions_json, secretsmanager_put_secret_string,
10};
11
12const FALLBACK_EDITOR: &str = "vi";
13
14#[tracel_xtask_macros::declare_command_args(None, SecretsSubCommand)]
15pub struct SecretsCmdArgs {}
16
17impl Default for SecretsSubCommand {
18    fn default() -> Self {
19        SecretsSubCommand::View(SecretsViewSubCmdArgs::default())
20    }
21}
22
23#[derive(clap::Args, Default, Clone, PartialEq)]
24pub struct SecretsCreateSubCmdArgs {
25    /// Region where the secret will be created
26    #[arg(long)]
27    pub region: String,
28
29    /// Secret name to create with an initial empty JSON value
30    #[arg(value_name = "SECRET_ID")]
31    pub secret_id: String,
32
33    /// Optional description for the secret
34    #[arg(long)]
35    pub description: Option<String>,
36}
37
38#[derive(clap::Args, Default, Clone, PartialEq)]
39pub struct SecretsCopySubCmdArgs {
40    /// Region where the secrets live
41    #[arg(long)]
42    pub region: String,
43
44    /// Source secret identifier (name or ARN)
45    #[arg(long, value_name = "FROM_SECRET_ID")]
46    pub from: String,
47
48    /// Target secret identifier (name or ARN)
49    #[arg(long, value_name = "TO_SECRET_ID")]
50    pub to: String,
51}
52
53#[derive(clap::Args, Default, Clone, PartialEq)]
54pub struct SecretsEditSubCmdArgs {
55    /// Region where the secret lives
56    #[arg(long)]
57    pub region: String,
58
59    /// Secret identifier (name or ARN)
60    #[arg(value_name = "SECRET_ID")]
61    pub secret_id: String,
62
63    /// Push the new secret version without asking for confirmation
64    #[arg(short = 'y', long = "yes")]
65    pub yes: bool,
66}
67
68#[derive(clap::Args, Default, Clone, PartialEq)]
69pub struct SecretsEnvFileSubCmdArgs {
70    /// Output file path. If omitted, writes to stdout.
71    #[arg(long)]
72    pub output: Option<std::path::PathBuf>,
73
74    /// Region where the secrets live
75    #[arg(long)]
76    pub region: String,
77
78    /// Secret identifiers (names or ARN), can provide multiple ones.
79    #[arg(value_name = "SECRET_ID", num_args(1..), required = true)]
80    pub secret_ids: Vec<String>,
81}
82
83#[derive(clap::Args, Default, Clone, PartialEq)]
84pub struct SecretsListSubCmdArgs {
85    /// Region where the secret lives
86    #[arg(long)]
87    pub region: String,
88
89    /// Secret identifier (name or ARN)
90    #[arg(value_name = "SECRET_ID")]
91    pub secret_id: String,
92}
93
94#[derive(clap::Args, Default, Clone, PartialEq)]
95pub struct SecretsPushSubCmdArgs {
96    /// Region where the secret lives
97    #[arg(long)]
98    pub region: String,
99
100    /// Secret identifier (name or ARN)
101    #[arg(long, value_name = "SECRET_ID")]
102    pub secret_id: String,
103
104    /// Push the new secret version without asking for confirmation
105    #[arg(short = 'y', long = "yes")]
106    pub yes: bool,
107
108    /// Key-value updates in the form KEY=VALUE
109    #[arg(value_name = "KEY=VALUE", num_args(1..), required = true)]
110    pub kv: Vec<String>,
111}
112
113#[derive(clap::Args, Default, Clone, PartialEq)]
114pub struct SecretsViewSubCmdArgs {
115    /// Region where the secret lives
116    #[arg(long)]
117    pub region: String,
118
119    /// Secret identifier (name or ARN)
120    #[arg(value_name = "SECRET_ID")]
121    pub secret_id: String,
122}
123
124pub fn handle_command(
125    args: SecretsCmdArgs,
126    _env: Environment,
127    _ctx: Context,
128) -> anyhow::Result<()> {
129    match args.get_command() {
130        SecretsSubCommand::Create(create_args) => create(create_args),
131        SecretsSubCommand::Copy(copy_args) => copy(copy_args),
132        SecretsSubCommand::Edit(edit_args) => edit(edit_args),
133        SecretsSubCommand::EnvFile(env_args) => env_file(env_args),
134        SecretsSubCommand::List(list_args) => list(list_args),
135        SecretsSubCommand::Push(push_args) => push(push_args),
136        SecretsSubCommand::View(view_args) => view(view_args),
137    }
138}
139
140/// Create a secret and attach an initial empty JSON (`{}`) version as plain text.
141fn create(args: SecretsCreateSubCmdArgs) -> anyhow::Result<()> {
142    // create the secret metadata
143    secretsmanager_create_empty_secret(&args.secret_id, &args.region, args.description.as_deref())?;
144    // add a first version as an empty JSON object.
145    secretsmanager_put_secret_string(&args.secret_id, &args.region, "{}")?;
146    eprintln!(
147        "✅ Created secret '{}' in region '{}' with an initial empty JSON value.",
148        args.secret_id, args.region
149    );
150    Ok(())
151}
152
153/// Copy a secret value from one secret ID to another in the same region.
154pub fn copy(args: SecretsCopySubCmdArgs) -> anyhow::Result<()> {
155    if args.from == args.to {
156        eprintln!(
157            "Source and target secrets are identical ('{}'), nothing to do.",
158            args.from
159        );
160        return Ok(());
161    }
162
163    eprintln!(
164        "Fetching source secret '{}' in region '{}'...",
165        args.from, args.region
166    );
167    let value = secretsmanager_get_secret_string(&args.from, &args.region, "text")?;
168
169    // Check if the target already has a current version.
170    // If we can successfully fetch it, we consider that a current version exists
171    // and ask for confirmation before creating a new one.
172    let target_has_version =
173        secretsmanager_get_secret_string(&args.to, &args.region, "text").is_ok();
174    if target_has_version {
175        eprintln!(
176            "Secret '{}' already has a current version in region '{}'.",
177            args.to, args.region
178        );
179        if !confirm_push()? {
180            eprintln!("Aborting: new secret version was not pushed.");
181            return Ok(());
182        }
183    }
184
185    eprintln!(
186        "Writing target secret '{}' in region '{}'...",
187        args.to, args.region
188    );
189    secretsmanager_put_secret_string(&args.to, &args.region, &value)?;
190    eprintln!(
191        "✅ Copied secret value from '{}' to '{}'.",
192        args.from, args.to
193    );
194
195    Ok(())
196}
197
198/// Fetch secret into a temp file, open editor,
199/// ask to commit or discard on close and then push a new version if confirmed.
200///
201/// Behavior:
202/// - If the secret is JSON, it is pretty-printed for editing and stored back
203///   minified on a single line.
204/// - If the secret is not JSON, it is treated as an opaque string.
205fn edit(args: SecretsEditSubCmdArgs) -> anyhow::Result<()> {
206    // 1) fetch current secret value
207    let original_raw = secretsmanager_get_secret_string(&args.secret_id, &args.region, "text")?;
208    let original_raw_trimmed = original_raw.trim_end_matches('\n');
209    // 2) make things pretty if possible
210    let to_edit =
211        pretty_json(original_raw_trimmed).unwrap_or_else(|| original_raw_trimmed.to_string());
212    // 3) write the secrets to a temp file for editing
213    let tmp_path = temp_file_path(&args.secret_id);
214    fs::write(&tmp_path, &to_edit)?;
215    eprintln!(
216        "Editing secret '{}' in region '{}' using temporary file:\n  {}",
217        args.secret_id,
218        args.region,
219        tmp_path.display()
220    );
221    // 4) open editor
222    let editor = detect_editor();
223    let mut parts = editor.split_whitespace();
224    let cmd = parts.next().unwrap_or(FALLBACK_EDITOR);
225    let mut command = std::process::Command::new(cmd);
226    for arg in parts {
227        command.arg(arg);
228    }
229    command.arg(&tmp_path);
230    let status = command
231        .status()
232        .map_err(|e| anyhow::anyhow!("launching editor '{editor}' should succeed: {e}"))?;
233    if !status.success() {
234        fs::remove_file(&tmp_path).ok();
235        return Err(anyhow::anyhow!(
236            "editor '{editor}' should exit successfully (exit status {status})"
237        ));
238    }
239    // 5) read updated contents of file and do some cleanup
240    let edited_raw = fs::read_to_string(&tmp_path)?;
241    fs::remove_file(&tmp_path).ok();
242    let edited_raw_trimmed = edited_raw.trim_end_matches('\n');
243    // Try to treat content as JSON on both sides
244    let original_norm_json = normalize_json(original_raw_trimmed);
245    let edited_norm_json = normalize_json(edited_raw_trimmed);
246    // If both are valid JSON, compare and store minified JSON
247    if let (Some(orig_norm), Some(edited_norm)) = (original_norm_json, edited_norm_json) {
248        if orig_norm == edited_norm {
249            eprintln!(
250                "No changes detected (JSON content unchanged), not pushing a new secret version."
251            );
252            return Ok(());
253        }
254        eprintln!("Secret JSON content has changed.");
255        if !args.yes && !confirm_push()? {
256            eprintln!("Aborting: new secret version was not pushed.");
257            return Ok(());
258        }
259        // Store minified JSON
260        secretsmanager_put_secret_string(&args.secret_id, &args.region, &edited_norm)?;
261        eprintln!(
262            "✅ New JSON version pushed for secret '{}' in region '{}'.",
263            args.secret_id, args.region
264        );
265        return Ok(());
266    }
267    // 6) Fallback for non-JSON secrets
268    if edited_raw_trimmed == original_raw_trimmed {
269        eprintln!("No changes detected, not pushing a new secret version.");
270        return Ok(());
271    }
272    eprintln!("Secret content has changed.");
273    if !args.yes && !confirm_push()? {
274        eprintln!("Aborting: new secret version was not pushed.");
275        return Ok(());
276    }
277    secretsmanager_put_secret_string(&args.secret_id, &args.region, edited_raw_trimmed)?;
278    eprintln!(
279        "✅ New version pushed for secret '{}' in region '{}'.",
280        args.secret_id, args.region
281    );
282
283    Ok(())
284}
285
286pub fn env_file(args: SecretsEnvFileSubCmdArgs) -> anyhow::Result<()> {
287    if args.secret_ids.is_empty() {
288        eprintln!("No secrets provided.");
289        return Ok(());
290    }
291    // In case of multiple same secret names, the latest wins
292    let mut merged: HashMap<String, String> = HashMap::new();
293    for id in &args.secret_ids {
294        eprintln!("Fetching secret '{id}'...");
295        let s = secretsmanager_get_secret_string(id, &args.region, "text")?;
296        let s = s.trim();
297        if s.is_empty() {
298            continue;
299        }
300        // 1) try JSON object
301        if let Ok(value) = serde_json::from_str::<serde_json::Value>(s) {
302            if let Some(obj) = value.as_object() {
303                for (k, v) in obj {
304                    let v_str = v
305                        .as_str()
306                        .map(|x| x.to_string())
307                        .unwrap_or_else(|| v.to_string());
308                    merged.insert(k.clone(), v_str);
309                }
310                continue;
311            }
312        }
313        // 2) fallback to .env style format
314        for line in s.lines() {
315            let line = line.trim();
316            if line.is_empty() || line.starts_with('#') {
317                continue;
318            }
319            if let Some((k, v)) = line.split_once('=') {
320                merged.insert(k.trim().to_string(), v.trim().to_string());
321            }
322        }
323    }
324
325    // Expand variable inside values
326    let merged = expand_env_map(&merged);
327
328    // Sort the env vars for deterministic ordering
329    let mut entries: Vec<(String, String)> = merged.into_iter().collect();
330    entries.sort_by(|a, b| a.0.cmp(&b.0));
331    // Write to passed path or to stdout if no path has been passed
332    let mut buf = String::new();
333    for (k, v) in entries {
334        writeln!(&mut buf, "{k}={v}")?;
335    }
336    if let Some(path) = args.output {
337        if let Some(dir) = path.parent() {
338            std::fs::create_dir_all(dir)?;
339        }
340        std::fs::write(&path, buf)?;
341        eprintln!("Wrote env file to {}", path.display());
342    } else {
343        print!("{buf}");
344    }
345
346    Ok(())
347}
348
349/// list all versions of the secrets.
350fn list(args: SecretsListSubCmdArgs) -> anyhow::Result<()> {
351    eprintln!(
352        "Listing versions for secret '{}' in region '{}'...",
353        args.secret_id, args.region
354    );
355    let json = secretsmanager_list_secret_versions_json(&args.secret_id, &args.region)?;
356    let v: serde_json::Value = serde_json::from_str(&json).context(
357        "Parsing Secrets Manager list-secret-version-ids response as JSON should succeed",
358    )?;
359    let versions = v
360        .get("Versions")
361        .and_then(|v| v.as_array())
362        .ok_or_else(|| {
363            anyhow::anyhow!(
364                "AWS response for secret '{}' should contain a 'Versions' array",
365                args.secret_id
366            )
367        })?;
368
369    if versions.is_empty() {
370        println!("No versions found for secret '{}'.", args.secret_id);
371        return Ok(());
372    }
373
374    struct Row {
375        id: String,
376        created: String,
377        stages: String,
378    }
379
380    let mut rows: Vec<Row> = Vec::new();
381    let mut id_w = "VersionId".len();
382    let mut created_w = "Created".len();
383    let mut stages_w = "Stages".len();
384
385    for ver in versions {
386        let id = ver
387            .get("VersionId")
388            .and_then(|x| x.as_str())
389            .unwrap_or("")
390            .to_string();
391
392        let created = match ver.get("CreatedDate") {
393            Some(serde_json::Value::String(s)) => s.clone(),
394            Some(serde_json::Value::Number(n)) => n.to_string(),
395            Some(other) => other.to_string(),
396            None => "".to_string(),
397        };
398
399        let stages = ver
400            .get("VersionStages")
401            .and_then(|x| x.as_array())
402            .map(|arr| {
403                arr.iter()
404                    .filter_map(|v| v.as_str())
405                    .collect::<Vec<_>>()
406                    .join(",")
407            })
408            .unwrap_or_default();
409
410        id_w = id_w.max(id.len());
411        created_w = created_w.max(created.len());
412        stages_w = stages_w.max(stages.len());
413
414        rows.push(Row {
415            id,
416            created,
417            stages,
418        });
419    }
420
421    // Header
422    println!(
423        "{:<id_w$}  {:<created_w$}  {:<stages_w$}",
424        "VersionId",
425        "Created",
426        "Stages",
427        id_w = id_w,
428        created_w = created_w,
429        stages_w = stages_w,
430    );
431
432    // Separator
433    println!(
434        "{:-<id_w$}  {:-<created_w$}  {:-<stages_w$}",
435        "",
436        "",
437        "",
438        id_w = id_w,
439        created_w = created_w,
440        stages_w = stages_w,
441    );
442
443    // Rows
444    for r in rows {
445        println!(
446            "{:<id_w$}  {:<created_w$}  {:<stages_w$}",
447            r.id,
448            r.created,
449            r.stages,
450            id_w = id_w,
451            created_w = created_w,
452            stages_w = stages_w,
453        );
454    }
455
456    Ok(())
457}
458
459/// Push updates to a JSON secret by setting one or more KEY=VALUE pairs.
460/// The secret must be a JSON object and updated value is stored on a single line.
461pub fn push(args: SecretsPushSubCmdArgs) -> anyhow::Result<()> {
462    // 1) fetch current secrets
463    eprintln!(
464        "Fetching secret '{}' in region '{}'...",
465        args.secret_id, args.region
466    );
467    let original = secretsmanager_get_secret_string(&args.secret_id, &args.region, "text")?;
468    let original_trimmed = original.trim_end_matches('\n');
469    let mut value: serde_json::Value =
470        serde_json::from_str(original_trimmed).with_context(|| {
471            format!(
472                "Parsing secret '{}' as JSON should succeed to use the 'push' subcommand",
473                args.secret_id
474            )
475        })?;
476    let obj = value.as_object_mut().ok_or_else(|| {
477        anyhow::anyhow!(
478            "Secret '{}' should be a JSON object to use the 'push' subcommand",
479            args.secret_id
480        )
481    })?;
482
483    // 2) Add key-value pairs to secret
484    let mut changed = false;
485    eprintln!("Changed entries to update:");
486    for kv in &args.kv {
487        let (key, val) = kv.split_once('=').ok_or_else(|| {
488            anyhow::anyhow!(
489                "Key/value argument '{kv}' should use the KEY=VALUE format for secret '{}'",
490                args.secret_id
491            )
492        })?;
493        let key = key.trim();
494        let val = val.trim();
495        if key.is_empty() {
496            anyhow::bail!(
497                "Key in '{kv}' should not be empty for secret '{}'",
498                args.secret_id
499            );
500        }
501        let existing = obj.get(key);
502        // If existing value is the same string, skip
503        if let Some(existing_val) = existing {
504            if existing_val.is_string() && existing_val.as_str() == Some(val) {
505                // skip value if it has not changed
506                continue;
507            }
508        }
509        obj.insert(key.to_string(), serde_json::Value::String(val.to_string()));
510        changed = true;
511        eprintln!("  - {key}");
512    }
513    if !changed {
514        eprintln!("None.");
515        eprintln!(
516            "No changes detected (JSON content unchanged), not pushing a new secret version."
517        );
518        return Ok(());
519    }
520
521    // 3) Confirmation prompt
522    eprintln!("Secret JSON content has changed.");
523    if !args.yes && !confirm_push()? {
524        eprintln!("Aborting: new secret version was not pushed.");
525        return Ok(());
526    }
527
528    // 4) Store the new version of the secrets as a minified JSON format
529    let normalized =
530        serde_json::to_string(&value).context("Serializing updated JSON secret should succeed")?;
531    secretsmanager_put_secret_string(&args.secret_id, &args.region, &normalized)?;
532    eprintln!(
533        "✅ Updated secret '{}' in region '{}' with {} key(s).",
534        args.secret_id,
535        args.region,
536        args.kv.len()
537    );
538
539    Ok(())
540}
541
542/// fetch and print the secret.
543fn view(args: SecretsViewSubCmdArgs) -> anyhow::Result<()> {
544    let value = secretsmanager_get_secret_string(&args.secret_id, &args.region, "text")?;
545    let trimmed = value.trim_end_matches('\n');
546
547    if let Some(pretty) = pretty_json(trimmed) {
548        println!("{pretty}");
549    } else {
550        println!("{value}");
551    }
552
553    Ok(())
554}
555
556/// Build a temp file path for editing a secret.
557fn temp_file_path(secret_id: &str) -> PathBuf {
558    let mut base: String = secret_id
559        .chars()
560        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
561        .collect();
562    if base.len() > 64 {
563        base.truncate(64);
564    }
565    let pid = std::process::id();
566    let filename = format!("tracel-secret-{base}-{pid}.tmp");
567    std::env::temp_dir().join(filename)
568}
569
570/// Detect the editor to use $VISUAL then $EDITOR then falling back to "vi".
571fn detect_editor() -> String {
572    std::env::var("VISUAL")
573        .or_else(|_| std::env::var("EDITOR"))
574        .unwrap_or_else(|_| FALLBACK_EDITOR.to_string())
575}
576
577/// Try to pretty-print JSON
578fn pretty_json(s: &str) -> Option<String> {
579    let value: serde_json::Value = serde_json::from_str(s).ok()?;
580    serde_json::to_string_pretty(&value).ok()
581}
582
583/// Normalize JSON to a canonical minified form.
584fn normalize_json(s: &str) -> Option<String> {
585    let value: serde_json::Value = serde_json::from_str(s).ok()?;
586    serde_json::to_string(&value).ok()
587}
588
589/// Ask user to confirm pushing a new secret version.
590fn confirm_push() -> anyhow::Result<bool> {
591    use std::io::Write as _;
592
593    print!("Do you want to push a new secret version? [y/N]: ");
594    io::stdout().flush().ok();
595
596    let mut answer = String::new();
597    io::stdin().read_line(&mut answer)?;
598    let answer = answer.trim().to_lowercase();
599    Ok(answer == "y" || answer == "yes")
600}
601
602/// Expand `${VAR}` placeholders in values using the given map.
603fn expand_value(input: &str, vars: &HashMap<String, String>) -> String {
604    let mut out = String::new();
605    let mut rest = input;
606
607    while let Some(start) = rest.find("${") {
608        // keep everything before the placeholder
609        out.push_str(&rest[..start]);
610        let after = &rest[start + 2..];
611        // find the closing brace
612        if let Some(end_rel) = after.find('}') {
613            let var_name = &after[..end_rel];
614            if let Some(val) = vars.get(var_name) {
615                // known var: substitute
616                out.push_str(val);
617            } else {
618                // unknown var: keep the placeholder as-is
619                out.push_str("${");
620                out.push_str(var_name);
621                out.push('}');
622            }
623            // continue after the closing brace
624            rest = &after[end_rel + 1..];
625        } else {
626            // no closing brace, keep the rest as-is
627            out.push_str(&rest[start..]);
628            rest = "";
629            break;
630        }
631    }
632    // trailing part without placeholders
633    out.push_str(rest);
634    out
635}
636
637/// Expand `${VAR}` placeholders in a merged env map.
638/// Note: this is a single pass expansion, preserving quotes and formatting.
639fn expand_env_map(merged: &HashMap<String, String>) -> HashMap<String, String> {
640    let mut expanded = HashMap::new();
641    for (key, value) in merged {
642        let new_val = expand_value(value, merged);
643        expanded.insert(key.clone(), new_val);
644    }
645    expanded
646}
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651    use serial_test::serial;
652    use std::env;
653
654    #[test]
655    #[serial]
656    fn test_expand_env_map_simple_expansion() {
657        // Clean any prior values that might confuse debugging
658        unsafe {
659            env::remove_var("LOG_LEVEL_TEST");
660            env::remove_var("RUST_LOG_TEST");
661        }
662
663        let mut merged: HashMap<String, String> = HashMap::new();
664        merged.insert("LOG_LEVEL_TEST".to_string(), "info".to_string());
665        merged.insert(
666            "RUST_LOG_TEST".to_string(),
667            "xtask=${LOG_LEVEL_TEST},server=${LOG_LEVEL_TEST}".to_string(),
668        );
669
670        let expanded = expand_env_map(&merged);
671
672        let log_level = expanded
673            .get("LOG_LEVEL_TEST")
674            .expect("LOG_LEVEL_TEST should be present after expansion");
675        let rust_log = expanded
676            .get("RUST_LOG_TEST")
677            .expect("RUST_LOG_TEST should be present after expansion");
678
679        assert_eq!(
680            log_level, "info",
681            "LOG_LEVEL_TEST should keep its literal value after expansion"
682        );
683        assert!(
684            !rust_log.contains("${LOG_LEVEL_TEST}"),
685            "RUST_LOG_TEST should not contain the raw placeholder '${{LOG_LEVEL_TEST}}', got: {rust_log}"
686        );
687        assert!(
688            rust_log.contains(log_level),
689            "RUST_LOG_TEST should contain the expanded LOG_LEVEL_TEST value; LOG_LEVEL_TEST={log_level}, RUST_LOG_TEST={rust_log}"
690        );
691    }
692
693    #[test]
694    #[serial]
695    fn test_expand_env_map_mixed_values_and_non_expanded_keys() {
696        unsafe {
697            env::remove_var("LOG_LEVEL_TEST");
698            env::remove_var("RUST_LOG_TEST");
699            env::remove_var("PLAIN_KEY_TEST");
700        }
701
702        let mut merged: HashMap<String, String> = HashMap::new();
703        merged.insert("LOG_LEVEL_TEST".to_string(), "debug".to_string());
704        merged.insert(
705            "RUST_LOG_TEST".to_string(),
706            "xtask=${LOG_LEVEL_TEST},other=${LOG_LEVEL_TEST}".to_string(),
707        );
708        merged.insert("PLAIN_KEY_TEST".to_string(), "no_placeholders".to_string());
709
710        let expanded = expand_env_map(&merged);
711
712        let log_level = expanded
713            .get("LOG_LEVEL_TEST")
714            .expect("LOG_LEVEL_TEST should be present after expansion");
715        let rust_log = expanded
716            .get("RUST_LOG_TEST")
717            .expect("RUST_LOG_TEST should be present after expansion");
718        let plain = expanded
719            .get("PLAIN_KEY_TEST")
720            .expect("PLAIN_KEY_TEST should be present after expansion");
721
722        assert_eq!(log_level, "debug");
723        assert!(
724            !rust_log.contains("${LOG_LEVEL_TEST}"),
725            "RUST_LOG_TEST should not contain the raw placeholder '${{LOG_LEVEL_TEST}}', got: {rust_log}"
726        );
727        assert!(
728            rust_log.contains(log_level),
729            "RUST_LOG_TEST should contain the expanded LOG_LEVEL_TEST value; LOG_LEVEL_TEST={log_level}, RUST_LOG_TEST={rust_log}"
730        );
731        assert_eq!(
732            plain, "no_placeholders",
733            "PLAIN_KEY_TEST should remain unchanged when there are no placeholders"
734        );
735    }
736
737    #[test]
738    #[serial]
739    fn test_expand_env_map_unknown_placeholder_is_left_intact() {
740        unsafe {
741            env::remove_var("UNKNOWN_PLACEHOLDER_TEST");
742            env::remove_var("USES_UNKNOWN_TEST");
743        }
744
745        let mut merged: HashMap<String, String> = HashMap::new();
746        merged.insert(
747            "USES_UNKNOWN_TEST".to_string(),
748            "value=${UNKNOWN_PLACEHOLDER_TEST}".to_string(),
749        );
750
751        let expanded = expand_env_map(&merged);
752
753        let uses_unknown = expanded
754            .get("USES_UNKNOWN_TEST")
755            .expect("USES_UNKNOWN_TEST should be present after expansion");
756
757        // Unknown placeholders should be preserved exactly
758        assert_eq!(
759            uses_unknown, "value=${UNKNOWN_PLACEHOLDER_TEST}",
760            "Unknown placeholder should be left intact"
761        );
762    }
763
764    #[test]
765    #[serial]
766    fn test_expand_env_map_preserves_quotes_around_values() {
767        unsafe {
768            env::remove_var("LOG_LEVEL_TEST");
769            env::remove_var("RUST_LOG_QUOTED_TEST");
770            env::remove_var("CRON_TEST");
771        }
772
773        let mut merged: HashMap<String, String> = HashMap::new();
774        merged.insert("LOG_LEVEL_TEST".to_string(), "info".to_string());
775        // placeholder inside double quotes
776        merged.insert(
777            "RUST_LOG_QUOTED_TEST".to_string(),
778            " \"xtask=${LOG_LEVEL_TEST},server=${LOG_LEVEL_TEST}\" ".to_string(),
779        );
780        // value that contains spaces and is already quoted
781        merged.insert("CRON_TEST".to_string(), "'0 0 0 * * *'".to_string());
782
783        let expanded = expand_env_map(&merged);
784
785        let rust_log = expanded
786            .get("RUST_LOG_QUOTED_TEST")
787            .expect("RUST_LOG_QUOTED_TEST should be present after expansion");
788        let cron = expanded
789            .get("CRON_TEST")
790            .expect("CRON_TEST should be present after expansion");
791
792        // RUST_LOG_QUOTED_TEST should still start and end with a double quote (after trimming)
793        let rust_trimmed = rust_log.trim();
794        assert!(
795            rust_trimmed.starts_with('"') && rust_trimmed.ends_with('"'),
796            "RUST_LOG_QUOTED_TEST should still be double-quoted, got: {rust_log}"
797        );
798        assert!(
799            rust_trimmed.contains("xtask=info"),
800            "RUST_LOG_QUOTED_TEST should contain the expanded value; got: {rust_trimmed}"
801        );
802
803        // CRON_TEST should be unchanged, quotes preserved
804        assert_eq!(
805            cron, "'0 0 0 * * *'",
806            "CRON_TEST should keep its single quotes and content"
807        );
808    }
809}