Skip to main content

cli/env/
commands.rs

1//! Handlers for `shine env list/set/delete/get/decrypt/export/encrypt`.
2
3use anyhow::{Context, Result, bail};
4
5use super::{EnvConfig, StoredValue, resolve_stored_value, secret_key};
6use crate::config::{Config, EnvOverrideKind, EnvOverrideSource};
7use crate::secret::{BackendKind, EncryptRecipients};
8use crate::{colors, path_display, secret, shells};
9
10/// Which layer supplied a variable's effective value, used to group the
11/// `env list` output. `Config` is the `config.toml [env]` table (global or
12/// project, deliberately not distinguished); the rest are `shine.env.toml`
13/// override files. Ordering matches display order (`config.toml` first, then
14/// override layers low-to-high by precedence).
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16enum EnvSourceGroup {
17    Config,
18    Global,
19    Overlay { managed: bool },
20    Project,
21}
22
23impl EnvSourceGroup {
24    /// Fixed display order. Lower sorts first.
25    fn order(self) -> u8 {
26        match self {
27            EnvSourceGroup::Config => 0,
28            EnvSourceGroup::Global => 1,
29            EnvSourceGroup::Overlay { .. } => 2,
30            EnvSourceGroup::Project => 3,
31        }
32    }
33
34    /// The bold header label for this section.
35    fn label(self) -> &'static str {
36        match self {
37            EnvSourceGroup::Config => "config.toml",
38            EnvSourceGroup::Global => "global env file",
39            EnvSourceGroup::Overlay { managed: false } => "overlay",
40            EnvSourceGroup::Overlay { managed: true } => "overlay (managed)",
41            EnvSourceGroup::Project => "project env file",
42        }
43    }
44}
45
46/// Classify a key by its override source: no override → `config.toml [env]`,
47/// otherwise the override file's layer (folding `is_managed_overlay` into the
48/// `Overlay` variant).
49fn env_source_group(source: Option<&EnvOverrideSource>) -> EnvSourceGroup {
50    match source {
51        None => EnvSourceGroup::Config,
52        Some(source) => match source.kind {
53            EnvOverrideKind::Global => EnvSourceGroup::Global,
54            EnvOverrideKind::Overlay => EnvSourceGroup::Overlay {
55                managed: source.is_managed_overlay,
56            },
57            EnvOverrideKind::Project => EnvSourceGroup::Project,
58        },
59    }
60}
61
62/// Partition `keys` into ordered, non-empty sections by source group,
63/// preserving each key's relative order within its group. Pure over a
64/// `source_of` lookup so it can be unit-tested without terminal/config I/O.
65fn group_env_keys<'a>(
66    keys: impl Iterator<Item = &'a str>,
67    source_of: impl Fn(&str) -> Option<&'a EnvOverrideSource>,
68) -> Vec<(EnvSourceGroup, Vec<&'a str>)> {
69    let mut groups: Vec<(EnvSourceGroup, Vec<&'a str>)> = Vec::new();
70    for key in keys {
71        let group = env_source_group(source_of(key));
72        match groups.iter_mut().find(|(g, _)| *g == group) {
73            Some((_, members)) => members.push(key),
74            None => groups.push((group, vec![key])),
75        }
76    }
77    groups.sort_by_key(|(group, _)| group.order());
78    groups
79}
80
81pub async fn handle_list(config: &Config, reveal: bool) -> Result<()> {
82    let env = EnvConfig::load_or_init(config).await?;
83    let catalog = super::catalog::load(config).await?;
84    let terminal_width = usize::from(console::Term::stdout().size().1).max(40);
85    let key_width = env
86        .iter()
87        .map(|(key, _)| key.chars().count())
88        .max()
89        .unwrap_or(0);
90
91    println!("{}", colors::bold("Environment"));
92    println!();
93    if env.as_map().is_empty() {
94        println!("  {}", colors::dim("No variables configured."));
95        println!();
96    }
97
98    let groups = group_env_keys(env.iter().map(|(k, _)| k), |key| {
99        config.env_override_source(key)
100    });
101    for (group, keys) in groups {
102        // Header: bold label, plus the override file's path for non-config groups.
103        match keys.first().and_then(|key| config.env_override_source(key)) {
104            Some(source) => println!(
105                "{}  {}",
106                colors::bold(group.label()),
107                colors::dim(&path_display::format(&source.path))
108            ),
109            None => println!("{}", colors::bold(group.label())),
110        }
111        for k in keys {
112            let v = env.get(k).unwrap_or_default();
113            let metadata = catalog.get(k);
114            let description = env
115                .description(k)
116                .or_else(|| metadata.map(|item| item.description.as_str()))
117                .unwrap_or_default();
118            let sensitive = metadata.is_some_and(|item| item.sensitive) || is_sensitive_env_key(k);
119            let display_value = display_env_value(v, sensitive, reveal);
120            let (display_value, description) =
121                fit_env_row(&display_value, description, key_width, terminal_width);
122            let key_padding = " ".repeat(key_width.saturating_sub(k.chars().count()));
123            if description.is_empty() {
124                println!("  {}{}  {}", colors::cyan(k), key_padding, display_value);
125            } else {
126                println!(
127                    "  {}{}  {:<value_width$}  {}",
128                    colors::cyan(k),
129                    key_padding,
130                    display_value,
131                    colors::dim(&description),
132                    value_width = env_value_width(key_width, terminal_width),
133                );
134            }
135        }
136        println!();
137    }
138
139    println!(
140        "  {}  {}",
141        colors::dim("Config"),
142        colors::dim(&path_display::format(config.config_path()))
143    );
144    println!(
145        "  {}",
146        colors::dim(&format!("{} variables", env.as_map().len()))
147    );
148    Ok(())
149}
150
151fn is_sensitive_env_key(key: &str) -> bool {
152    let key = key.to_ascii_uppercase();
153    [
154        "SECRET",
155        "TOKEN",
156        "PASSWORD",
157        "PASSPHRASE",
158        "API_KEY",
159        "PRIVATE_KEY",
160        "ACCESS_KEY",
161        "SUBSCRIPTION_URL",
162    ]
163    .iter()
164    .any(|suffix| key == *suffix || key.ends_with(&format!("_{suffix}")))
165}
166
167fn display_env_value(value: &str, sensitive: bool, reveal: bool) -> String {
168    if value.is_empty() {
169        "<empty>".to_string()
170    } else if sensitive && !reveal {
171        "<redacted>".to_string()
172    } else {
173        value.to_string()
174    }
175}
176
177fn env_value_width(key_width: usize, terminal_width: usize) -> usize {
178    terminal_width.saturating_sub(key_width + 28).clamp(12, 36)
179}
180
181fn fit_env_row(
182    value: &str,
183    description: &str,
184    key_width: usize,
185    terminal_width: usize,
186) -> (String, String) {
187    let value_width = env_value_width(key_width, terminal_width);
188    let value = truncate_text(value, value_width);
189    let description_width = terminal_width.saturating_sub(2 + key_width + 2 + value_width + 2);
190    let description = if description_width < 8 {
191        String::new()
192    } else {
193        truncate_text(description, description_width)
194    };
195    (value, description)
196}
197
198fn truncate_text(value: &str, max_width: usize) -> String {
199    if value.chars().count() <= max_width {
200        return value.to_string();
201    }
202    if max_width <= 1 {
203        return "…".to_string();
204    }
205    let mut result = value.chars().take(max_width - 1).collect::<String>();
206    result.push('…');
207    result
208}
209
210/// Where an `env set`/`encrypt`/`delete` write should land: `config.toml [env]`
211/// (the default, unshadowed case), or a specific override file that already
212/// supplies the key's effective value.
213#[derive(Debug)]
214enum EnvWriteTarget<'a> {
215    ConfigToml,
216    OverrideFile(&'a crate::config::EnvOverrideSource),
217}
218
219/// Decide where a write to `key` should go. Refuses (unless `force`) when an
220/// override file already shadows `config.toml [env]` for this key, since a
221/// plain write there would silently have no effect on the resolved value. With
222/// `force`, warns loudly when the winning file is the shine-managed overlay
223/// mirror, since that write will be discarded on the next `shine preset pull`.
224fn resolve_env_write_target<'a>(
225    config: &'a Config,
226    key: &str,
227    force: bool,
228) -> Result<EnvWriteTarget<'a>> {
229    let Some(source) = config.env_override_source(key) else {
230        return Ok(EnvWriteTarget::ConfigToml);
231    };
232    if !force {
233        bail!(
234            "{key} currently resolves from {} (an env override file), which takes precedence over {}; this write would have no effect.\nRe-run with --force to write directly into that file instead.",
235            path_display::format(&source.path),
236            path_display::format(config.config_path()),
237        );
238    }
239    if source.is_managed_overlay {
240        eprintln!(
241            "{}",
242            colors::yellow(&format!(
243                "Warning: {} is the shine-managed overlay mirror; this change will be discarded on the next `shine preset pull`/`shine update`. Edit it upstream on the maintaining device instead.",
244                path_display::format(&source.path)
245            ))
246        );
247    }
248    Ok(EnvWriteTarget::OverrideFile(source))
249}
250
251pub async fn handle_set(config: &Config, key: &str, value: &str, force: bool) -> Result<()> {
252    let catalog = super::catalog::load(config).await?;
253    let sensitive =
254        catalog.get(key).is_some_and(|item| item.sensitive) || is_sensitive_env_key(key);
255    let display_value = display_env_value(value, sensitive, false);
256    match resolve_env_write_target(config, key, force)? {
257        EnvWriteTarget::ConfigToml => {
258            let mut env = EnvConfig::load_or_init(config).await?;
259            env.set(key, value);
260            env.save(config).await?;
261            println!(
262                "{}",
263                colors::green(&format!(
264                    "set {key} = \"{display_value}\" in {}",
265                    path_display::format(config.config_path())
266                ))
267            );
268        }
269        EnvWriteTarget::OverrideFile(source) => {
270            crate::config::write_env_override_entry(&source.path, key, Some(value)).await?;
271            println!(
272                "{}",
273                colors::green(&format!(
274                    "set {key} = \"{display_value}\" in {}",
275                    path_display::format(&source.path)
276                ))
277            );
278        }
279    }
280    println!(
281        "{}",
282        colors::dim("Run `shine upgrade` to apply to already-installed presets.")
283    );
284    Ok(())
285}
286
287pub async fn handle_delete(config: &Config, key: &str, force: bool) -> Result<()> {
288    if !config.env.contains_key(key) && config.env_override_source(key).is_none() {
289        bail!("{key} is not set in the active config [env]");
290    }
291    match resolve_env_write_target(config, key, force)? {
292        EnvWriteTarget::ConfigToml => {
293            let mut env = EnvConfig::load_or_init(config).await?;
294            env.remove(key);
295            env.save(config).await?;
296            println!(
297                "{}",
298                colors::green(&format!(
299                    "deleted {key} from {}",
300                    path_display::format(config.config_path())
301                ))
302            );
303        }
304        EnvWriteTarget::OverrideFile(source) => {
305            crate::config::write_env_override_entry(&source.path, key, None).await?;
306            println!(
307                "{}",
308                colors::green(&format!(
309                    "deleted {key} from {}",
310                    path_display::format(&source.path)
311                ))
312            );
313        }
314    }
315    println!(
316        "{}",
317        colors::dim("Run `shine upgrade` to apply to already-installed presets.")
318    );
319    Ok(())
320}
321
322pub async fn handle_get(config: &Config, key: &str) -> Result<()> {
323    let env = EnvConfig::load_or_init(config).await?;
324    match env.get(key) {
325        Some(v) => println!("{v}"),
326        None => {
327            eprintln!(
328                "{}",
329                colors::yellow(&format!("{key} is not set in the active config [env]"))
330            );
331            std::process::exit(1);
332        }
333    }
334    Ok(())
335}
336
337pub async fn handle_decrypt(config: &Config, key: &str) -> Result<()> {
338    let env = EnvConfig::load_or_init(config).await?;
339    let Some(value) = env.get(key) else {
340        bail!("{key} is not set in the active config [env]");
341    };
342    let plaintext = secret::decrypt_secret(value, &config.age_identities())
343        .await
344        .with_context(|| format!("decrypting {key}"))?;
345    print!("{plaintext}");
346    Ok(())
347}
348
349pub async fn handle_export(config: &Config, key: &str, alias: Option<&str>) -> Result<()> {
350    validate_env_export_key(key)?;
351    if let Some(alias) = alias {
352        validate_env_export_key(alias)?;
353    }
354    let env = EnvConfig::load_or_init(config).await?;
355    let value = match resolve_env_export_value(&env, key)? {
356        EnvExportValue::Secret {
357            key: secret_key,
358            value,
359        } => secret::decrypt_secret(value, &config.age_identities())
360            .await
361            .with_context(|| format!("decrypting {secret_key}"))?,
362        EnvExportValue::Plaintext(value) => value.to_string(),
363    };
364    let export_as = alias.unwrap_or(key);
365    println!(
366        "{}",
367        format_env_export(&config.shell_type, export_as, &value)
368    );
369    Ok(())
370}
371
372type EnvExportValue<'a> = StoredValue<'a>;
373
374fn resolve_env_export_value<'a>(env: &'a EnvConfig, key: &str) -> Result<EnvExportValue<'a>> {
375    resolve_stored_value(env, key)
376}
377
378fn env_export_secret_key(key: &str) -> String {
379    secret_key(key)
380}
381
382fn validate_env_export_key(key: &str) -> Result<()> {
383    let mut chars = key.chars();
384    let Some(first) = chars.next() else {
385        bail!("env secret export key must not be empty");
386    };
387    if !(first == '_' || first.is_ascii_alphabetic()) {
388        bail!("env secret export key must start with a letter or underscore: {key}");
389    }
390    if !chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) {
391        bail!("env secret export key must contain only letters, digits, and underscores: {key}");
392    }
393    Ok(())
394}
395
396/// `pub(crate)` so `theme::handle_sync` can reuse the same per-shell quoting
397/// instead of adding a fourth `single_quote` implementation to the codebase
398/// (see docs/terminal-theme-sync-prd.md §7/§10).
399pub(crate) fn format_env_export(shell: &shells::ShellType, key: &str, value: &str) -> String {
400    match shell {
401        shells::ShellType::Fish => format!("set -gx {key} {}", fish_quote(value)),
402        shells::ShellType::PowerShell => {
403            format!("$env:{key} = {}", powershell_string_quote(value))
404        }
405        _ => format!("export {key}={}", posix_shell_quote(value)),
406    }
407}
408
409fn posix_shell_quote(value: &str) -> String {
410    format!("'{}'", value.replace('\'', "'\\''"))
411}
412
413fn fish_quote(value: &str) -> String {
414    format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\'"))
415}
416
417fn powershell_string_quote(value: &str) -> String {
418    format!("'{}'", value.replace('\'', "''"))
419}
420
421#[derive(Debug, PartialEq, Eq)]
422enum EnvEncryptOutput {
423    Print,
424    Set(String),
425}
426
427fn resolve_env_encrypt_output(
428    set_key: Option<&str>,
429    from_key: Option<&str>,
430) -> Result<EnvEncryptOutput> {
431    if let Some(key) = set_key {
432        return Ok(EnvEncryptOutput::Set(key.to_string()));
433    }
434    if let Some(key) = from_key {
435        validate_env_export_key(key)?;
436        return Ok(EnvEncryptOutput::Set(env_export_secret_key(key)));
437    }
438    Ok(EnvEncryptOutput::Print)
439}
440
441fn resolve_encrypt_backend(config: &Config, backend: Option<&str>) -> Result<BackendKind> {
442    if let Some(backend) = backend.map(str::trim).filter(|value| !value.is_empty()) {
443        return backend.parse();
444    }
445    if let Some(backend) = config
446        .secret_backend
447        .as_deref()
448        .map(str::trim)
449        .filter(|value| !value.is_empty())
450    {
451        return backend.parse();
452    }
453    Ok(BackendKind::default())
454}
455
456fn clean_recipients(recipients: &[String]) -> Vec<String> {
457    recipients
458        .iter()
459        .map(|value| value.trim().to_string())
460        .filter(|value| !value.is_empty())
461        .collect()
462}
463
464fn resolve_encrypt_recipients(
465    backend: BackendKind,
466    cli_recipients: &[String],
467    config: &Config,
468) -> Result<EncryptRecipients> {
469    let cli_recipients = clean_recipients(cli_recipients);
470    if !cli_recipients.is_empty() {
471        if backend == BackendKind::Gpg
472            && let Some(hint) = cli_recipients
473                .iter()
474                .find(|value| value.starts_with("age1"))
475        {
476            bail!("recipient \"{hint}\" looks like an age recipient; did you mean --backend age?");
477        }
478        return Ok(match backend {
479            BackendKind::Gpg => EncryptRecipients::Gpg(cli_recipients),
480            BackendKind::Age => EncryptRecipients::Age(cli_recipients),
481        });
482    }
483
484    match backend {
485        BackendKind::Gpg => {
486            let recipient = config
487                .gpg_key_id
488                .as_deref()
489                .map(str::trim)
490                .filter(|value| !value.is_empty())
491                .context(
492                    "GPG recipient is required; pass -r/--recipient, set gpg_key_id, or set secret_backend/age_recipients for age",
493                )?;
494            Ok(EncryptRecipients::Gpg(vec![recipient.to_string()]))
495        }
496        BackendKind::Age => {
497            let recipients = clean_recipients(&config.age_recipients);
498            if recipients.is_empty() {
499                bail!(
500                    "age recipients are required; pass -r/--recipient or set age_recipients in config.toml"
501                );
502            }
503            Ok(EncryptRecipients::Age(recipients))
504        }
505    }
506}
507
508pub async fn handle_encrypt(
509    config: &Config,
510    backend: Option<&str>,
511    recipients: &[String],
512    set_key: Option<&str>,
513    from_key: Option<&str>,
514    force: bool,
515) -> Result<()> {
516    use std::io::Read as _;
517
518    let backend = resolve_encrypt_backend(config, backend)?;
519    let recipients = resolve_encrypt_recipients(backend, recipients, config)?;
520    let plaintext = if let Some(key) = from_key {
521        let env = EnvConfig::load_or_init(config).await?;
522        let Some(value) = env.get(key) else {
523            bail!("{key} is not set in the active config [env]");
524        };
525        value.as_bytes().to_vec()
526    } else {
527        let mut input = Vec::new();
528        std::io::stdin()
529            .read_to_end(&mut input)
530            .context("reading secret from stdin")?;
531        input
532    };
533    let encoded = secret::encrypt_secret(&plaintext, &recipients)
534        .await
535        .context("encrypting secret")?;
536    match resolve_env_encrypt_output(set_key, from_key)? {
537        EnvEncryptOutput::Set(key) => match resolve_env_write_target(config, &key, force)? {
538            EnvWriteTarget::ConfigToml => {
539                let mut env = EnvConfig::load_or_init(config).await?;
540                env.set(&key, &encoded);
541                env.save(config).await?;
542                println!(
543                    "{}",
544                    colors::green(&format!(
545                        "set {key} = \"{encoded}\" in {}",
546                        path_display::format(config.config_path())
547                    ))
548                );
549            }
550            EnvWriteTarget::OverrideFile(source) => {
551                crate::config::write_env_override_entry(&source.path, &key, Some(&encoded)).await?;
552                println!(
553                    "{}",
554                    colors::green(&format!(
555                        "set {key} = \"{encoded}\" in {}",
556                        path_display::format(&source.path)
557                    ))
558                );
559            }
560        },
561        EnvEncryptOutput::Print => println!("{encoded}"),
562    }
563    Ok(())
564}
565
566#[cfg(test)]
567mod tests {
568    use super::*;
569    use crate::config::Config;
570    use tokio::fs;
571
572    async fn make_temp_dir() -> std::path::PathBuf {
573        crate::test_support::make_temp_dir("shine-env-cmd-test").await
574    }
575
576    fn config_in(dir: &std::path::Path) -> Config {
577        crate::test_support::test_config(dir)
578    }
579
580    #[test]
581    fn env_show_redacts_sensitive_values() {
582        assert_eq!(display_env_value("secret", true, false), "<redacted>");
583        assert_eq!(display_env_value("secret", true, true), "secret");
584        assert_eq!(display_env_value("", true, false), "<empty>");
585        assert!(is_sensitive_env_key("MY_API_KEY"));
586        assert!(is_sensitive_env_key("token"));
587        assert!(is_sensitive_env_key("SURGE_SUBSCRIPTION_URL"));
588        assert!(!is_sensitive_env_key("MONKEY"));
589    }
590
591    fn source(kind: EnvOverrideKind, managed: bool) -> EnvOverrideSource {
592        EnvOverrideSource {
593            path: std::path::PathBuf::from("/tmp/shine.env.toml"),
594            kind,
595            is_managed_overlay: managed,
596        }
597    }
598
599    #[test]
600    fn env_source_group_maps_each_layer() {
601        assert_eq!(env_source_group(None), EnvSourceGroup::Config);
602        assert_eq!(
603            env_source_group(Some(&source(EnvOverrideKind::Global, false))),
604            EnvSourceGroup::Global
605        );
606        assert_eq!(
607            env_source_group(Some(&source(EnvOverrideKind::Overlay, false))),
608            EnvSourceGroup::Overlay { managed: false }
609        );
610        assert_eq!(
611            env_source_group(Some(&source(EnvOverrideKind::Overlay, true))),
612            EnvSourceGroup::Overlay { managed: true }
613        );
614        assert_eq!(
615            env_source_group(Some(&source(EnvOverrideKind::Project, false))),
616            EnvSourceGroup::Project
617        );
618    }
619
620    #[test]
621    fn group_env_keys_orders_sections_and_skips_empty() {
622        let global = source(EnvOverrideKind::Global, false);
623        let overlay = source(EnvOverrideKind::Overlay, true);
624        // Keys deliberately out of source order; config keys have no override.
625        let keys = ["PROJECT_LESS", "FROM_OVERLAY", "FROM_CONFIG", "FROM_GLOBAL"];
626        let groups = group_env_keys(keys.iter().copied(), |key| match key {
627            "FROM_GLOBAL" => Some(&global),
628            "FROM_OVERLAY" => Some(&overlay),
629            _ => None,
630        });
631
632        // Only Config, Global, Overlay are present (Project skipped), in order.
633        assert_eq!(
634            groups.iter().map(|(g, _)| *g).collect::<Vec<_>>(),
635            vec![
636                EnvSourceGroup::Config,
637                EnvSourceGroup::Global,
638                EnvSourceGroup::Overlay { managed: true },
639            ]
640        );
641        assert_eq!(groups[0].1, vec!["PROJECT_LESS", "FROM_CONFIG"]);
642        assert_eq!(groups[1].1, vec!["FROM_GLOBAL"]);
643        assert_eq!(groups[2].1, vec!["FROM_OVERLAY"]);
644    }
645
646    #[test]
647    fn group_env_keys_all_config_yields_single_group() {
648        let keys = ["A", "B", "C"];
649        let groups = group_env_keys(keys.iter().copied(), |_| None);
650        assert_eq!(groups.len(), 1);
651        assert_eq!(groups[0].0, EnvSourceGroup::Config);
652        assert_eq!(groups[0].1, vec!["A", "B", "C"]);
653    }
654
655    #[test]
656    fn env_source_group_labels_are_stable() {
657        assert_eq!(EnvSourceGroup::Config.label(), "config.toml");
658        assert_eq!(EnvSourceGroup::Global.label(), "global env file");
659        assert_eq!(
660            EnvSourceGroup::Overlay { managed: false }.label(),
661            "overlay"
662        );
663        assert_eq!(
664            EnvSourceGroup::Overlay { managed: true }.label(),
665            "overlay (managed)"
666        );
667        assert_eq!(EnvSourceGroup::Project.label(), "project env file");
668    }
669
670    #[test]
671    fn env_show_truncates_long_values_to_requested_width() {
672        assert_eq!(truncate_text("abcdefgh", 5), "abcd…");
673        let (value, description) = fit_env_row(
674            "abcdefghijklmnopqrstuvwxyz",
675            "A description that is also fairly long",
676            8,
677            48,
678        );
679        assert!(value.chars().count() <= env_value_width(8, 48));
680        assert!(description.chars().count() <= 48);
681    }
682
683    #[test]
684    fn env_export_uses_alias_as_variable_name() {
685        let value = "secret123";
686        assert_eq!(
687            format_env_export(&shells::ShellType::Zsh, "MY_ALIAS", value),
688            "export MY_ALIAS='secret123'"
689        );
690    }
691
692    #[test]
693    fn env_export_alias_formats_powershell_correctly() {
694        let value = "secret123";
695        assert_eq!(
696            format_env_export(&shells::ShellType::PowerShell, "MY_ALIAS", value),
697            "$env:MY_ALIAS = 'secret123'"
698        );
699    }
700
701    #[tokio::test]
702    async fn env_delete_removes_key_from_saved_config() {
703        let dir = make_temp_dir().await;
704        let mut config = config_in(&dir);
705        config.env.insert("MY_TOKEN".into(), "secret".into());
706        config.save().await.unwrap();
707
708        handle_delete(&config, "MY_TOKEN", false).await.unwrap();
709
710        let contents = fs::read_to_string(config.config_path()).await.unwrap();
711        let parsed: toml::Table = toml::from_str(&contents).unwrap();
712        let env = parsed
713            .get("env")
714            .and_then(|value| value.as_table())
715            .unwrap();
716        assert!(
717            !env.contains_key("MY_TOKEN"),
718            "deleted key should not remain in saved config: {contents}"
719        );
720
721        fs::remove_dir_all(&dir).await.unwrap();
722    }
723
724    #[tokio::test]
725    async fn env_delete_fails_when_key_is_missing() {
726        let dir = make_temp_dir().await;
727        let config = config_in(&dir);
728
729        let err = handle_delete(&config, "MY_TOKEN", false).await.unwrap_err();
730
731        assert!(
732            err.to_string()
733                .contains("MY_TOKEN is not set in the active config [env]"),
734            "error should explain missing key: {err:#}"
735        );
736        fs::remove_dir_all(&dir).await.unwrap();
737    }
738
739    #[test]
740    fn env_export_secret_key_appends_secret_suffix() {
741        assert_eq!(
742            env_export_secret_key("DEEPSEEK_API_KEY"),
743            "DEEPSEEK_API_KEY_SECRET"
744        );
745        assert_eq!(env_export_secret_key("xxx"), "xxx_SECRET");
746    }
747
748    #[test]
749    fn env_export_resolves_secret_when_present() {
750        let mut env = EnvConfig::default();
751        env.set("MY_TOKEN_SECRET", "encrypted");
752
753        assert_eq!(
754            resolve_env_export_value(&env, "MY_TOKEN").unwrap(),
755            EnvExportValue::Secret {
756                key: "MY_TOKEN_SECRET".to_string(),
757                value: "encrypted"
758            }
759        );
760    }
761
762    #[test]
763    fn env_export_falls_back_to_plaintext_value() {
764        let mut env = EnvConfig::default();
765        env.set("MY_TOKEN", "plain");
766
767        assert_eq!(
768            resolve_env_export_value(&env, "MY_TOKEN").unwrap(),
769            EnvExportValue::Plaintext("plain")
770        );
771    }
772
773    #[test]
774    fn env_export_secret_wins_over_plaintext_value() {
775        let mut env = EnvConfig::default();
776        env.set("MY_TOKEN", "plain");
777        env.set("MY_TOKEN_SECRET", "encrypted");
778
779        assert_eq!(
780            resolve_env_export_value(&env, "MY_TOKEN").unwrap(),
781            EnvExportValue::Secret {
782                key: "MY_TOKEN_SECRET".to_string(),
783                value: "encrypted"
784            }
785        );
786    }
787
788    #[test]
789    fn env_export_reports_both_missing_keys() {
790        let env = EnvConfig::default();
791
792        let err = resolve_env_export_value(&env, "MY_TOKEN").unwrap_err();
793
794        assert!(
795            err.to_string()
796                .contains("MY_TOKEN_SECRET or MY_TOKEN is not set in the active config [env]"),
797            "error should explain both checked keys: {err:#}"
798        );
799    }
800
801    #[test]
802    fn env_export_key_validation_accepts_shell_variable_names() {
803        for key in ["FOO", "_FOO", "foo_123", "A1"] {
804            validate_env_export_key(key).unwrap();
805        }
806    }
807
808    #[test]
809    fn env_export_key_validation_rejects_unsafe_names() {
810        for key in ["", "1FOO", "FOO-BAR", "FOO;BAR", "FOO BAR", "FOO.SECRET"] {
811            assert!(
812                validate_env_export_key(key).is_err(),
813                "key should be rejected: {key}"
814            );
815        }
816    }
817
818    #[test]
819    fn env_export_formats_posix_shell_code_safely() {
820        let value = "abc def'ghi$HOME\nnext; rm -rf /";
821        assert_eq!(
822            format_env_export(&shells::ShellType::Zsh, "TOKEN", value),
823            "export TOKEN='abc def'\\''ghi$HOME\nnext; rm -rf /'"
824        );
825    }
826
827    #[test]
828    fn env_export_formats_fish_shell_code_safely() {
829        let value = "abc def'ghi\\path\nnext; rm -rf /";
830        assert_eq!(
831            format_env_export(&shells::ShellType::Fish, "TOKEN", value),
832            "set -gx TOKEN 'abc def\\'ghi\\\\path\nnext; rm -rf /'"
833        );
834    }
835
836    #[test]
837    fn env_export_formats_powershell_code_safely() {
838        let value = "abc def'ghi$HOME\nnext; Remove-Item /";
839        assert_eq!(
840            format_env_export(&shells::ShellType::PowerShell, "TOKEN", value),
841            "$env:TOKEN = 'abc def''ghi$HOME\nnext; Remove-Item /'"
842        );
843    }
844
845    #[test]
846    fn env_encrypt_output_defaults_from_key_to_secret_key() {
847        assert_eq!(
848            resolve_env_encrypt_output(None, Some("GH_TOKEN")).unwrap(),
849            EnvEncryptOutput::Set("GH_TOKEN_SECRET".to_string())
850        );
851    }
852
853    #[test]
854    fn env_encrypt_output_explicit_set_wins_over_default() {
855        assert_eq!(
856            resolve_env_encrypt_output(Some("CUSTOM_SECRET"), Some("GH_TOKEN")).unwrap(),
857            EnvEncryptOutput::Set("CUSTOM_SECRET".to_string())
858        );
859    }
860
861    #[test]
862    fn env_encrypt_output_prints_stdin_without_set() {
863        assert_eq!(
864            resolve_env_encrypt_output(None, None).unwrap(),
865            EnvEncryptOutput::Print
866        );
867    }
868
869    #[test]
870    fn env_encrypt_output_rejects_invalid_inferred_from_key() {
871        let err = resolve_env_encrypt_output(None, Some("GH-TOKEN")).unwrap_err();
872
873        assert!(
874            err.to_string().contains(
875                "env secret export key must contain only letters, digits, and underscores"
876            ),
877            "error should explain invalid inferred key: {err:#}"
878        );
879    }
880
881    #[test]
882    fn encrypt_backend_cli_wins_over_config() {
883        let dir = std::env::temp_dir().join(format!("shine-env-backend-{}", uuid::Uuid::new_v4()));
884        let mut config = config_in(&dir);
885        config.secret_backend = Some("age".to_string());
886
887        assert_eq!(
888            resolve_encrypt_backend(&config, Some("gpg")).unwrap(),
889            BackendKind::Gpg
890        );
891    }
892
893    #[test]
894    fn encrypt_backend_falls_back_to_config() {
895        let dir = std::env::temp_dir().join(format!("shine-env-backend-{}", uuid::Uuid::new_v4()));
896        let mut config = config_in(&dir);
897        config.secret_backend = Some("age".to_string());
898
899        assert_eq!(
900            resolve_encrypt_backend(&config, None).unwrap(),
901            BackendKind::Age
902        );
903    }
904
905    #[test]
906    fn encrypt_backend_defaults_to_gpg() {
907        let dir = std::env::temp_dir().join(format!("shine-env-backend-{}", uuid::Uuid::new_v4()));
908        let config = config_in(&dir);
909
910        assert_eq!(
911            resolve_encrypt_backend(&config, None).unwrap(),
912            BackendKind::Gpg
913        );
914    }
915
916    #[test]
917    fn encrypt_recipients_cli_wins_over_config_for_gpg() {
918        let dir =
919            std::env::temp_dir().join(format!("shine-env-recipient-{}", uuid::Uuid::new_v4()));
920        let mut config = config_in(&dir);
921        config.gpg_key_id = Some("config@example.com".to_string());
922
923        let recipients =
924            resolve_encrypt_recipients(BackendKind::Gpg, &["cli@example.com".to_string()], &config)
925                .unwrap();
926
927        match recipients {
928            EncryptRecipients::Gpg(values) => assert_eq!(values, vec!["cli@example.com"]),
929            EncryptRecipients::Age(_) => panic!("expected gpg recipients"),
930        }
931    }
932
933    #[test]
934    fn encrypt_recipients_gpg_falls_back_to_config() {
935        let dir =
936            std::env::temp_dir().join(format!("shine-env-recipient-{}", uuid::Uuid::new_v4()));
937        let mut config = config_in(&dir);
938        config.gpg_key_id = Some("config@example.com".to_string());
939
940        let recipients = resolve_encrypt_recipients(BackendKind::Gpg, &[], &config).unwrap();
941
942        match recipients {
943            EncryptRecipients::Gpg(values) => assert_eq!(values, vec!["config@example.com"]),
944            EncryptRecipients::Age(_) => panic!("expected gpg recipients"),
945        }
946    }
947
948    #[test]
949    fn encrypt_recipients_gpg_treats_empty_config_as_missing() {
950        let dir =
951            std::env::temp_dir().join(format!("shine-env-recipient-{}", uuid::Uuid::new_v4()));
952        let mut config = config_in(&dir);
953        config.gpg_key_id = Some("  ".to_string());
954
955        let err = resolve_encrypt_recipients(BackendKind::Gpg, &[], &config).unwrap_err();
956
957        assert!(
958            err.to_string()
959                .contains("pass -r/--recipient, set gpg_key_id"),
960            "error should explain how to set recipient: {err:#}"
961        );
962    }
963
964    #[test]
965    fn encrypt_recipients_gpg_errors_when_missing() {
966        let dir =
967            std::env::temp_dir().join(format!("shine-env-recipient-{}", uuid::Uuid::new_v4()));
968        let config = config_in(&dir);
969
970        let err = resolve_encrypt_recipients(BackendKind::Gpg, &[], &config).unwrap_err();
971
972        assert!(
973            err.to_string()
974                .contains("pass -r/--recipient, set gpg_key_id"),
975            "error should explain how to set recipient: {err:#}"
976        );
977    }
978
979    #[test]
980    fn encrypt_recipients_age_falls_back_to_config() {
981        let dir =
982            std::env::temp_dir().join(format!("shine-env-recipient-{}", uuid::Uuid::new_v4()));
983        let mut config = config_in(&dir);
984        config.age_recipients = vec!["age1qexample".to_string()];
985
986        let recipients = resolve_encrypt_recipients(BackendKind::Age, &[], &config).unwrap();
987
988        match recipients {
989            EncryptRecipients::Age(values) => assert_eq!(values, vec!["age1qexample"]),
990            EncryptRecipients::Gpg(_) => panic!("expected age recipients"),
991        }
992    }
993
994    #[test]
995    fn encrypt_recipients_age_errors_when_missing() {
996        let dir =
997            std::env::temp_dir().join(format!("shine-env-recipient-{}", uuid::Uuid::new_v4()));
998        let config = config_in(&dir);
999
1000        let err = resolve_encrypt_recipients(BackendKind::Age, &[], &config).unwrap_err();
1001
1002        assert!(
1003            err.to_string().contains("age recipients are required"),
1004            "error should explain how to set age recipients: {err:#}"
1005        );
1006    }
1007
1008    #[test]
1009    fn encrypt_recipients_hints_when_age_recipient_used_with_gpg_backend() {
1010        let dir =
1011            std::env::temp_dir().join(format!("shine-env-recipient-{}", uuid::Uuid::new_v4()));
1012        let config = config_in(&dir);
1013
1014        let err =
1015            resolve_encrypt_recipients(BackendKind::Gpg, &["age1qexample".to_string()], &config)
1016                .unwrap_err();
1017
1018        assert!(
1019            err.to_string().contains("did you mean --backend age"),
1020            "error should hint at the age backend: {err:#}"
1021        );
1022    }
1023
1024    fn shadow_key(
1025        config: &mut Config,
1026        key: &str,
1027        path: std::path::PathBuf,
1028        is_managed_overlay: bool,
1029    ) {
1030        let kind = if is_managed_overlay {
1031            crate::config::EnvOverrideKind::Overlay
1032        } else {
1033            crate::config::EnvOverrideKind::Global
1034        };
1035        config.env_override_sources.insert(
1036            key.to_string(),
1037            crate::config::EnvOverrideSource {
1038                path,
1039                kind,
1040                is_managed_overlay,
1041            },
1042        );
1043    }
1044
1045    #[test]
1046    fn resolve_env_write_target_returns_config_toml_when_unshadowed() {
1047        let dir = std::env::temp_dir().join(format!("shine-env-write-{}", uuid::Uuid::new_v4()));
1048        let config = config_in(&dir);
1049
1050        let target = resolve_env_write_target(&config, "MY_TOKEN", false).unwrap();
1051
1052        assert!(matches!(target, EnvWriteTarget::ConfigToml));
1053    }
1054
1055    #[test]
1056    fn resolve_env_write_target_refuses_without_force_when_shadowed() {
1057        let dir = std::env::temp_dir().join(format!("shine-env-write-{}", uuid::Uuid::new_v4()));
1058        let mut config = config_in(&dir);
1059        let override_path = dir.join("shine.env.toml");
1060        shadow_key(&mut config, "MY_TOKEN", override_path.clone(), false);
1061
1062        let err = resolve_env_write_target(&config, "MY_TOKEN", false).unwrap_err();
1063
1064        assert!(
1065            err.to_string().contains(override_path.to_str().unwrap()),
1066            "error should name the winning override file: {err:#}"
1067        );
1068        assert!(
1069            err.to_string().contains("--force"),
1070            "error should hint at --force: {err:#}"
1071        );
1072    }
1073
1074    #[test]
1075    fn resolve_env_write_target_returns_override_file_with_force() {
1076        let dir = std::env::temp_dir().join(format!("shine-env-write-{}", uuid::Uuid::new_v4()));
1077        let mut config = config_in(&dir);
1078        let override_path = dir.join("shine.env.toml");
1079        shadow_key(&mut config, "MY_TOKEN", override_path.clone(), false);
1080
1081        let target = resolve_env_write_target(&config, "MY_TOKEN", true).unwrap();
1082
1083        match target {
1084            EnvWriteTarget::OverrideFile(source) => assert_eq!(source.path, override_path),
1085            EnvWriteTarget::ConfigToml => panic!("expected the shadowing override file"),
1086        }
1087    }
1088
1089    #[test]
1090    fn resolve_env_write_target_allows_managed_overlay_with_force() {
1091        let dir = std::env::temp_dir().join(format!("shine-env-write-{}", uuid::Uuid::new_v4()));
1092        let mut config = config_in(&dir);
1093        let overlay_path = dir.join("overlay").join("shine.env.toml");
1094        shadow_key(&mut config, "MY_TOKEN", overlay_path.clone(), true);
1095
1096        let target = resolve_env_write_target(&config, "MY_TOKEN", true).unwrap();
1097
1098        match target {
1099            EnvWriteTarget::OverrideFile(source) => {
1100                assert_eq!(source.path, overlay_path);
1101                assert!(source.is_managed_overlay);
1102            }
1103            EnvWriteTarget::ConfigToml => panic!("expected the managed overlay override file"),
1104        }
1105    }
1106
1107    #[tokio::test]
1108    async fn env_set_refuses_when_shadowed_without_force() {
1109        let dir = make_temp_dir().await;
1110        let mut config = config_in(&dir);
1111        let override_path = dir.join("shine.env.toml");
1112        shadow_key(&mut config, "MY_TOKEN", override_path.clone(), false);
1113
1114        let err = handle_set(&config, "MY_TOKEN", "newval", false)
1115            .await
1116            .unwrap_err();
1117
1118        assert!(err.to_string().contains(override_path.to_str().unwrap()));
1119        assert!(
1120            !fs::try_exists(&override_path).await.unwrap(),
1121            "refused write must not touch the override file"
1122        );
1123        assert!(
1124            !fs::read_to_string(config.config_path())
1125                .await
1126                .unwrap_or_default()
1127                .contains("MY_TOKEN"),
1128            "refused write must not touch config.toml either"
1129        );
1130
1131        fs::remove_dir_all(&dir).await.unwrap();
1132    }
1133
1134    #[tokio::test]
1135    async fn env_set_writes_into_override_file_when_forced() {
1136        let dir = make_temp_dir().await;
1137        let mut config = config_in(&dir);
1138        let override_path = dir.join("shine.env.toml");
1139        fs::write(&override_path, "MY_TOKEN = \"old\"\n")
1140            .await
1141            .unwrap();
1142        shadow_key(&mut config, "MY_TOKEN", override_path.clone(), false);
1143
1144        handle_set(&config, "MY_TOKEN", "newval", true)
1145            .await
1146            .unwrap();
1147
1148        let content = fs::read_to_string(&override_path).await.unwrap();
1149        assert!(content.contains("MY_TOKEN = \"newval\""));
1150        assert!(
1151            !fs::read_to_string(config.config_path())
1152                .await
1153                .unwrap_or_default()
1154                .contains("MY_TOKEN"),
1155            "forced write must go into the override file, not config.toml"
1156        );
1157
1158        fs::remove_dir_all(&dir).await.unwrap();
1159    }
1160
1161    #[tokio::test]
1162    async fn env_delete_refuses_when_shadowed_without_force() {
1163        let dir = make_temp_dir().await;
1164        let mut config = config_in(&dir);
1165        let override_path = dir.join("shine.env.toml");
1166        fs::write(&override_path, "MY_TOKEN = \"secret\"\n")
1167            .await
1168            .unwrap();
1169        shadow_key(&mut config, "MY_TOKEN", override_path.clone(), false);
1170
1171        let err = handle_delete(&config, "MY_TOKEN", false).await.unwrap_err();
1172
1173        assert!(err.to_string().contains(override_path.to_str().unwrap()));
1174        let content = fs::read_to_string(&override_path).await.unwrap();
1175        assert!(
1176            content.contains("MY_TOKEN"),
1177            "refused delete must leave the override file untouched"
1178        );
1179
1180        fs::remove_dir_all(&dir).await.unwrap();
1181    }
1182
1183    #[tokio::test]
1184    async fn env_delete_removes_from_override_file_when_forced() {
1185        let dir = make_temp_dir().await;
1186        let mut config = config_in(&dir);
1187        let override_path = dir.join("shine.env.toml");
1188        fs::write(&override_path, "MY_TOKEN = \"secret\"\nOTHER = \"kept\"\n")
1189            .await
1190            .unwrap();
1191        shadow_key(&mut config, "MY_TOKEN", override_path.clone(), false);
1192
1193        handle_delete(&config, "MY_TOKEN", true).await.unwrap();
1194
1195        let content = fs::read_to_string(&override_path).await.unwrap();
1196        let table: toml::Table = toml::from_str(&content).unwrap();
1197        assert!(!table.contains_key("MY_TOKEN"));
1198        assert!(table.contains_key("OTHER"));
1199
1200        fs::remove_dir_all(&dir).await.unwrap();
1201    }
1202}