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_with_config(value, config)
343        .await
344        .with_context(|| format!("decrypting {key}"))?;
345    write_decrypted_plaintext(std::io::stdout().lock(), &plaintext)?;
346    Ok(())
347}
348
349fn write_decrypted_plaintext(mut output: impl std::io::Write, plaintext: &str) -> Result<()> {
350    output
351        .write_all(plaintext.as_bytes())
352        .context("writing decrypted plaintext")?;
353    output.flush().context("flushing decrypted plaintext")
354}
355
356pub async fn handle_export(config: &Config, key: &str, alias: Option<&str>) -> Result<()> {
357    validate_env_export_key(key)?;
358    if let Some(alias) = alias {
359        validate_env_export_key(alias)?;
360    }
361    let env = EnvConfig::load_or_init(config).await?;
362    let value = match resolve_env_export_value(&env, key)? {
363        EnvExportValue::Secret {
364            key: secret_key,
365            value,
366        } => secret::decrypt_with_config(value, config)
367            .await
368            .with_context(|| format!("decrypting {secret_key}"))?,
369        EnvExportValue::Plaintext(value) => value.to_string(),
370    };
371    let export_as = alias.unwrap_or(key);
372    println!(
373        "{}",
374        format_env_export(&config.shell_type, export_as, &value)
375    );
376    Ok(())
377}
378
379type EnvExportValue<'a> = StoredValue<'a>;
380
381fn resolve_env_export_value<'a>(env: &'a EnvConfig, key: &str) -> Result<EnvExportValue<'a>> {
382    resolve_stored_value(env, key)
383}
384
385fn env_export_secret_key(key: &str) -> String {
386    secret_key(key)
387}
388
389fn validate_env_export_key(key: &str) -> Result<()> {
390    let mut chars = key.chars();
391    let Some(first) = chars.next() else {
392        bail!("env secret export key must not be empty");
393    };
394    if !(first == '_' || first.is_ascii_alphabetic()) {
395        bail!("env secret export key must start with a letter or underscore: {key}");
396    }
397    if !chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) {
398        bail!("env secret export key must contain only letters, digits, and underscores: {key}");
399    }
400    Ok(())
401}
402
403/// `pub(crate)` so `theme::handle_sync` can reuse the same per-shell quoting
404/// instead of adding a fourth `single_quote` implementation to the codebase
405/// (see docs/terminal-theme-sync-prd.md §7/§10).
406pub(crate) fn format_env_export(shell: &shells::ShellType, key: &str, value: &str) -> String {
407    match shell {
408        shells::ShellType::Fish => format!("set -gx {key} {}", fish_quote(value)),
409        shells::ShellType::PowerShell => {
410            format!("$env:{key} = {}", powershell_string_quote(value))
411        }
412        _ => format!("export {key}={}", posix_shell_quote(value)),
413    }
414}
415
416fn posix_shell_quote(value: &str) -> String {
417    format!("'{}'", value.replace('\'', "'\\''"))
418}
419
420fn fish_quote(value: &str) -> String {
421    format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\'"))
422}
423
424fn powershell_string_quote(value: &str) -> String {
425    format!("'{}'", value.replace('\'', "''"))
426}
427
428#[derive(Debug, PartialEq, Eq)]
429enum EnvEncryptOutput {
430    Print,
431    Set(String),
432}
433
434fn resolve_env_encrypt_output(
435    set_key: Option<&str>,
436    from_key: Option<&str>,
437) -> Result<EnvEncryptOutput> {
438    if let Some(key) = set_key {
439        return Ok(EnvEncryptOutput::Set(key.to_string()));
440    }
441    if let Some(key) = from_key {
442        validate_env_export_key(key)?;
443        return Ok(EnvEncryptOutput::Set(env_export_secret_key(key)));
444    }
445    Ok(EnvEncryptOutput::Print)
446}
447
448fn resolve_encrypt_backend(config: &Config, backend: Option<&str>) -> Result<BackendKind> {
449    if let Some(backend) = backend.map(str::trim).filter(|value| !value.is_empty()) {
450        return backend.parse();
451    }
452    if let Some(backend) = config
453        .secret_backend
454        .as_deref()
455        .map(str::trim)
456        .filter(|value| !value.is_empty())
457    {
458        return backend.parse();
459    }
460    Ok(BackendKind::default())
461}
462
463fn clean_recipients(recipients: &[String]) -> Vec<String> {
464    recipients
465        .iter()
466        .map(|value| value.trim().to_string())
467        .filter(|value| !value.is_empty())
468        .collect()
469}
470
471fn resolve_encrypt_recipients(
472    backend: BackendKind,
473    cli_recipients: &[String],
474    config: &Config,
475) -> Result<EncryptRecipients> {
476    let cli_recipients = clean_recipients(cli_recipients);
477    if !cli_recipients.is_empty() {
478        if backend == BackendKind::Gpg
479            && let Some(hint) = cli_recipients
480                .iter()
481                .find(|value| value.starts_with("age1"))
482        {
483            bail!("recipient \"{hint}\" looks like an age recipient; did you mean --backend age?");
484        }
485        return Ok(match backend {
486            BackendKind::Gpg => EncryptRecipients::Gpg(cli_recipients),
487            BackendKind::Age => EncryptRecipients::Age(cli_recipients),
488            BackendKind::Hybrid => {
489                bail!("hybrid requires workspace access lists; use env secret seal")
490            }
491        });
492    }
493
494    match backend {
495        BackendKind::Hybrid => bail!("hybrid requires workspace access lists; use env secret seal"),
496        BackendKind::Gpg => {
497            if config.legacy_gpg_key_id.is_some() {
498                bail!(
499                    "gpg_key_id is retired; run `shine state migrate` to convert it to gpg_recipients"
500                );
501            }
502            let recipients = clean_recipients(&config.gpg_recipients);
503            if recipients.is_empty() {
504                bail!(
505                    "GPG recipients are required; pass -r/--recipient, set gpg_recipients, or set secret_backend/age_recipients for age"
506                );
507            }
508            Ok(EncryptRecipients::Gpg(recipients))
509        }
510        BackendKind::Age => {
511            let recipients = clean_recipients(&config.age_recipients);
512            if recipients.is_empty() {
513                bail!(
514                    "age recipients are required; pass -r/--recipient or set age_recipients in config.toml"
515                );
516            }
517            Ok(EncryptRecipients::Age(recipients))
518        }
519    }
520}
521
522pub async fn handle_encrypt(
523    config: &Config,
524    backend: Option<&str>,
525    recipients: &[String],
526    set_key: Option<&str>,
527    from_key: Option<&str>,
528    force: bool,
529) -> Result<()> {
530    use std::io::Read as _;
531
532    let backend = resolve_encrypt_backend(config, backend)?;
533    let recipients = resolve_encrypt_recipients(backend, recipients, config)?;
534    let plaintext = if let Some(key) = from_key {
535        let env = EnvConfig::load_or_init(config).await?;
536        let Some(value) = env.get(key) else {
537            bail!("{key} is not set in the active config [env]");
538        };
539        value.as_bytes().to_vec()
540    } else {
541        let mut input = Vec::new();
542        std::io::stdin()
543            .read_to_end(&mut input)
544            .context("reading secret from stdin")?;
545        input
546    };
547    let encoded = secret::encrypt_secret(&plaintext, &recipients)
548        .await
549        .context("encrypting secret")?;
550    match resolve_env_encrypt_output(set_key, from_key)? {
551        EnvEncryptOutput::Set(key) => match resolve_env_write_target(config, &key, force)? {
552            EnvWriteTarget::ConfigToml => {
553                let mut env = EnvConfig::load_or_init(config).await?;
554                env.set(&key, &encoded);
555                env.save(config).await?;
556                println!(
557                    "{}",
558                    colors::green(&format!(
559                        "set {key} = \"{encoded}\" in {}",
560                        path_display::format(config.config_path())
561                    ))
562                );
563            }
564            EnvWriteTarget::OverrideFile(source) => {
565                crate::config::write_env_override_entry(&source.path, &key, Some(&encoded)).await?;
566                println!(
567                    "{}",
568                    colors::green(&format!(
569                        "set {key} = \"{encoded}\" in {}",
570                        path_display::format(&source.path)
571                    ))
572                );
573            }
574        },
575        EnvEncryptOutput::Print => println!("{encoded}"),
576    }
577    Ok(())
578}
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583    use crate::config::Config;
584    use tokio::fs;
585
586    async fn make_temp_dir() -> std::path::PathBuf {
587        crate::test_support::make_temp_dir("shine-env-cmd-test").await
588    }
589
590    fn config_in(dir: &std::path::Path) -> Config {
591        crate::test_support::test_config(dir)
592    }
593
594    #[test]
595    fn env_show_redacts_sensitive_values() {
596        assert_eq!(display_env_value("secret", true, false), "<redacted>");
597        assert_eq!(display_env_value("secret", true, true), "secret");
598        assert_eq!(display_env_value("", true, false), "<empty>");
599        assert!(is_sensitive_env_key("MY_API_KEY"));
600        assert!(is_sensitive_env_key("token"));
601        assert!(is_sensitive_env_key("SURGE_SUBSCRIPTION_URL"));
602        assert!(!is_sensitive_env_key("MONKEY"));
603    }
604
605    #[test]
606    fn decrypted_plaintext_is_written_without_an_appended_line_ending() {
607        let mut output = Vec::new();
608        write_decrypted_plaintext(&mut output, "abc").unwrap();
609        assert_eq!(output, b"abc");
610
611        output.clear();
612        write_decrypted_plaintext(&mut output, "abc\n").unwrap();
613        assert_eq!(output, b"abc\n");
614    }
615
616    fn source(kind: EnvOverrideKind, managed: bool) -> EnvOverrideSource {
617        EnvOverrideSource {
618            path: std::path::PathBuf::from("/tmp/shine.env.toml"),
619            kind,
620            is_managed_overlay: managed,
621        }
622    }
623
624    #[test]
625    fn env_source_group_maps_each_layer() {
626        assert_eq!(env_source_group(None), EnvSourceGroup::Config);
627        assert_eq!(
628            env_source_group(Some(&source(EnvOverrideKind::Global, false))),
629            EnvSourceGroup::Global
630        );
631        assert_eq!(
632            env_source_group(Some(&source(EnvOverrideKind::Overlay, false))),
633            EnvSourceGroup::Overlay { managed: false }
634        );
635        assert_eq!(
636            env_source_group(Some(&source(EnvOverrideKind::Overlay, true))),
637            EnvSourceGroup::Overlay { managed: true }
638        );
639        assert_eq!(
640            env_source_group(Some(&source(EnvOverrideKind::Project, false))),
641            EnvSourceGroup::Project
642        );
643    }
644
645    #[test]
646    fn group_env_keys_orders_sections_and_skips_empty() {
647        let global = source(EnvOverrideKind::Global, false);
648        let overlay = source(EnvOverrideKind::Overlay, true);
649        // Keys deliberately out of source order; config keys have no override.
650        let keys = ["PROJECT_LESS", "FROM_OVERLAY", "FROM_CONFIG", "FROM_GLOBAL"];
651        let groups = group_env_keys(keys.iter().copied(), |key| match key {
652            "FROM_GLOBAL" => Some(&global),
653            "FROM_OVERLAY" => Some(&overlay),
654            _ => None,
655        });
656
657        // Only Config, Global, Overlay are present (Project skipped), in order.
658        assert_eq!(
659            groups.iter().map(|(g, _)| *g).collect::<Vec<_>>(),
660            vec![
661                EnvSourceGroup::Config,
662                EnvSourceGroup::Global,
663                EnvSourceGroup::Overlay { managed: true },
664            ]
665        );
666        assert_eq!(groups[0].1, vec!["PROJECT_LESS", "FROM_CONFIG"]);
667        assert_eq!(groups[1].1, vec!["FROM_GLOBAL"]);
668        assert_eq!(groups[2].1, vec!["FROM_OVERLAY"]);
669    }
670
671    #[test]
672    fn group_env_keys_all_config_yields_single_group() {
673        let keys = ["A", "B", "C"];
674        let groups = group_env_keys(keys.iter().copied(), |_| None);
675        assert_eq!(groups.len(), 1);
676        assert_eq!(groups[0].0, EnvSourceGroup::Config);
677        assert_eq!(groups[0].1, vec!["A", "B", "C"]);
678    }
679
680    #[test]
681    fn env_source_group_labels_are_stable() {
682        assert_eq!(EnvSourceGroup::Config.label(), "config.toml");
683        assert_eq!(EnvSourceGroup::Global.label(), "global env file");
684        assert_eq!(
685            EnvSourceGroup::Overlay { managed: false }.label(),
686            "overlay"
687        );
688        assert_eq!(
689            EnvSourceGroup::Overlay { managed: true }.label(),
690            "overlay (managed)"
691        );
692        assert_eq!(EnvSourceGroup::Project.label(), "project env file");
693    }
694
695    #[test]
696    fn env_show_truncates_long_values_to_requested_width() {
697        assert_eq!(truncate_text("abcdefgh", 5), "abcd…");
698        let (value, description) = fit_env_row(
699            "abcdefghijklmnopqrstuvwxyz",
700            "A description that is also fairly long",
701            8,
702            48,
703        );
704        assert!(value.chars().count() <= env_value_width(8, 48));
705        assert!(description.chars().count() <= 48);
706    }
707
708    #[test]
709    fn env_export_uses_alias_as_variable_name() {
710        let value = "secret123";
711        assert_eq!(
712            format_env_export(&shells::ShellType::Zsh, "MY_ALIAS", value),
713            "export MY_ALIAS='secret123'"
714        );
715    }
716
717    #[test]
718    fn env_export_alias_formats_powershell_correctly() {
719        let value = "secret123";
720        assert_eq!(
721            format_env_export(&shells::ShellType::PowerShell, "MY_ALIAS", value),
722            "$env:MY_ALIAS = 'secret123'"
723        );
724    }
725
726    #[tokio::test]
727    async fn env_delete_removes_key_from_saved_config() {
728        let dir = make_temp_dir().await;
729        let mut config = config_in(&dir);
730        config.env.insert("MY_TOKEN".into(), "secret".into());
731        config.save().await.unwrap();
732
733        handle_delete(&config, "MY_TOKEN", false).await.unwrap();
734
735        let contents = fs::read_to_string(config.config_path()).await.unwrap();
736        let parsed: toml::Table = toml::from_str(&contents).unwrap();
737        let env = parsed
738            .get("env")
739            .and_then(|value| value.as_table())
740            .unwrap();
741        assert!(
742            !env.contains_key("MY_TOKEN"),
743            "deleted key should not remain in saved config: {contents}"
744        );
745
746        fs::remove_dir_all(&dir).await.unwrap();
747    }
748
749    #[tokio::test]
750    async fn env_delete_fails_when_key_is_missing() {
751        let dir = make_temp_dir().await;
752        let config = config_in(&dir);
753
754        let err = handle_delete(&config, "MY_TOKEN", false).await.unwrap_err();
755
756        assert!(
757            err.to_string()
758                .contains("MY_TOKEN is not set in the active config [env]"),
759            "error should explain missing key: {err:#}"
760        );
761        fs::remove_dir_all(&dir).await.unwrap();
762    }
763
764    #[test]
765    fn env_export_secret_key_appends_secret_suffix() {
766        assert_eq!(
767            env_export_secret_key("DEEPSEEK_API_KEY"),
768            "DEEPSEEK_API_KEY_SECRET"
769        );
770        assert_eq!(env_export_secret_key("xxx"), "xxx_SECRET");
771    }
772
773    #[test]
774    fn env_export_resolves_secret_when_present() {
775        let mut env = EnvConfig::default();
776        env.set("MY_TOKEN_SECRET", "encrypted");
777
778        assert_eq!(
779            resolve_env_export_value(&env, "MY_TOKEN").unwrap(),
780            EnvExportValue::Secret {
781                key: "MY_TOKEN_SECRET".to_string(),
782                value: "encrypted"
783            }
784        );
785    }
786
787    #[test]
788    fn env_export_falls_back_to_plaintext_value() {
789        let mut env = EnvConfig::default();
790        env.set("MY_TOKEN", "plain");
791
792        assert_eq!(
793            resolve_env_export_value(&env, "MY_TOKEN").unwrap(),
794            EnvExportValue::Plaintext("plain")
795        );
796    }
797
798    #[test]
799    fn env_export_secret_wins_over_plaintext_value() {
800        let mut env = EnvConfig::default();
801        env.set("MY_TOKEN", "plain");
802        env.set("MY_TOKEN_SECRET", "encrypted");
803
804        assert_eq!(
805            resolve_env_export_value(&env, "MY_TOKEN").unwrap(),
806            EnvExportValue::Secret {
807                key: "MY_TOKEN_SECRET".to_string(),
808                value: "encrypted"
809            }
810        );
811    }
812
813    #[test]
814    fn env_export_reports_both_missing_keys() {
815        let env = EnvConfig::default();
816
817        let err = resolve_env_export_value(&env, "MY_TOKEN").unwrap_err();
818
819        assert!(
820            err.to_string()
821                .contains("MY_TOKEN_SECRET or MY_TOKEN is not set in the active config [env]"),
822            "error should explain both checked keys: {err:#}"
823        );
824    }
825
826    #[test]
827    fn env_export_key_validation_accepts_shell_variable_names() {
828        for key in ["FOO", "_FOO", "foo_123", "A1"] {
829            validate_env_export_key(key).unwrap();
830        }
831    }
832
833    #[test]
834    fn env_export_key_validation_rejects_unsafe_names() {
835        for key in ["", "1FOO", "FOO-BAR", "FOO;BAR", "FOO BAR", "FOO.SECRET"] {
836            assert!(
837                validate_env_export_key(key).is_err(),
838                "key should be rejected: {key}"
839            );
840        }
841    }
842
843    #[test]
844    fn env_export_formats_posix_shell_code_safely() {
845        let value = "abc def'ghi$HOME\nnext; rm -rf /";
846        assert_eq!(
847            format_env_export(&shells::ShellType::Zsh, "TOKEN", value),
848            "export TOKEN='abc def'\\''ghi$HOME\nnext; rm -rf /'"
849        );
850    }
851
852    #[test]
853    fn env_export_formats_fish_shell_code_safely() {
854        let value = "abc def'ghi\\path\nnext; rm -rf /";
855        assert_eq!(
856            format_env_export(&shells::ShellType::Fish, "TOKEN", value),
857            "set -gx TOKEN 'abc def\\'ghi\\\\path\nnext; rm -rf /'"
858        );
859    }
860
861    #[test]
862    fn env_export_formats_powershell_code_safely() {
863        let value = "abc def'ghi$HOME\nnext; Remove-Item /";
864        assert_eq!(
865            format_env_export(&shells::ShellType::PowerShell, "TOKEN", value),
866            "$env:TOKEN = 'abc def''ghi$HOME\nnext; Remove-Item /'"
867        );
868    }
869
870    #[test]
871    fn env_encrypt_output_defaults_from_key_to_secret_key() {
872        assert_eq!(
873            resolve_env_encrypt_output(None, Some("GH_TOKEN")).unwrap(),
874            EnvEncryptOutput::Set("GH_TOKEN_SECRET".to_string())
875        );
876    }
877
878    #[test]
879    fn env_encrypt_output_explicit_set_wins_over_default() {
880        assert_eq!(
881            resolve_env_encrypt_output(Some("CUSTOM_SECRET"), Some("GH_TOKEN")).unwrap(),
882            EnvEncryptOutput::Set("CUSTOM_SECRET".to_string())
883        );
884    }
885
886    #[test]
887    fn env_encrypt_output_prints_stdin_without_set() {
888        assert_eq!(
889            resolve_env_encrypt_output(None, None).unwrap(),
890            EnvEncryptOutput::Print
891        );
892    }
893
894    #[test]
895    fn env_encrypt_output_rejects_invalid_inferred_from_key() {
896        let err = resolve_env_encrypt_output(None, Some("GH-TOKEN")).unwrap_err();
897
898        assert!(
899            err.to_string().contains(
900                "env secret export key must contain only letters, digits, and underscores"
901            ),
902            "error should explain invalid inferred key: {err:#}"
903        );
904    }
905
906    #[test]
907    fn encrypt_backend_cli_wins_over_config() {
908        let dir = std::env::temp_dir().join(format!("shine-env-backend-{}", uuid::Uuid::new_v4()));
909        let mut config = config_in(&dir);
910        config.secret_backend = Some("age".to_string());
911
912        assert_eq!(
913            resolve_encrypt_backend(&config, Some("gpg")).unwrap(),
914            BackendKind::Gpg
915        );
916    }
917
918    #[test]
919    fn encrypt_backend_falls_back_to_config() {
920        let dir = std::env::temp_dir().join(format!("shine-env-backend-{}", uuid::Uuid::new_v4()));
921        let mut config = config_in(&dir);
922        config.secret_backend = Some("age".to_string());
923
924        assert_eq!(
925            resolve_encrypt_backend(&config, None).unwrap(),
926            BackendKind::Age
927        );
928    }
929
930    #[test]
931    fn encrypt_backend_defaults_to_gpg() {
932        let dir = std::env::temp_dir().join(format!("shine-env-backend-{}", uuid::Uuid::new_v4()));
933        let config = config_in(&dir);
934
935        assert_eq!(
936            resolve_encrypt_backend(&config, None).unwrap(),
937            BackendKind::Gpg
938        );
939    }
940
941    #[test]
942    fn encrypt_recipients_cli_wins_over_config_for_gpg() {
943        let dir =
944            std::env::temp_dir().join(format!("shine-env-recipient-{}", uuid::Uuid::new_v4()));
945        let mut config = config_in(&dir);
946        config.gpg_recipients = vec!["config@example.com".to_string()];
947
948        let recipients =
949            resolve_encrypt_recipients(BackendKind::Gpg, &["cli@example.com".to_string()], &config)
950                .unwrap();
951
952        match recipients {
953            EncryptRecipients::Gpg(values) => assert_eq!(values, vec!["cli@example.com"]),
954            _ => panic!("expected gpg recipients"),
955        }
956    }
957
958    #[test]
959    fn encrypt_recipients_gpg_falls_back_to_config() {
960        let dir =
961            std::env::temp_dir().join(format!("shine-env-recipient-{}", uuid::Uuid::new_v4()));
962        let mut config = config_in(&dir);
963        config.gpg_recipients = vec![
964            "config@example.com".to_string(),
965            "team@example.com".to_string(),
966        ];
967
968        let recipients = resolve_encrypt_recipients(BackendKind::Gpg, &[], &config).unwrap();
969
970        match recipients {
971            EncryptRecipients::Gpg(values) => {
972                assert_eq!(values, vec!["config@example.com", "team@example.com"])
973            }
974            _ => panic!("expected gpg recipients"),
975        }
976    }
977
978    #[test]
979    fn encrypt_recipients_gpg_treats_empty_config_as_missing() {
980        let dir =
981            std::env::temp_dir().join(format!("shine-env-recipient-{}", uuid::Uuid::new_v4()));
982        let mut config = config_in(&dir);
983        config.gpg_recipients = vec!["  ".to_string()];
984
985        let err = resolve_encrypt_recipients(BackendKind::Gpg, &[], &config).unwrap_err();
986
987        assert!(
988            err.to_string()
989                .contains("pass -r/--recipient, set gpg_recipients"),
990            "error should explain how to set recipient: {err:#}"
991        );
992    }
993
994    #[test]
995    fn encrypt_recipients_gpg_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::Gpg, &[], &config).unwrap_err();
1001
1002        assert!(
1003            err.to_string()
1004                .contains("pass -r/--recipient, set gpg_recipients"),
1005            "error should explain how to set recipient: {err:#}"
1006        );
1007    }
1008
1009    #[test]
1010    fn encrypt_recipients_age_falls_back_to_config() {
1011        let dir =
1012            std::env::temp_dir().join(format!("shine-env-recipient-{}", uuid::Uuid::new_v4()));
1013        let mut config = config_in(&dir);
1014        config.age_recipients = vec!["age1qexample".to_string()];
1015
1016        let recipients = resolve_encrypt_recipients(BackendKind::Age, &[], &config).unwrap();
1017
1018        match recipients {
1019            EncryptRecipients::Age(values) => assert_eq!(values, vec!["age1qexample"]),
1020            _ => panic!("expected age recipients"),
1021        }
1022    }
1023
1024    #[test]
1025    fn encrypt_recipients_age_errors_when_missing() {
1026        let dir =
1027            std::env::temp_dir().join(format!("shine-env-recipient-{}", uuid::Uuid::new_v4()));
1028        let config = config_in(&dir);
1029
1030        let err = resolve_encrypt_recipients(BackendKind::Age, &[], &config).unwrap_err();
1031
1032        assert!(
1033            err.to_string().contains("age recipients are required"),
1034            "error should explain how to set age recipients: {err:#}"
1035        );
1036    }
1037
1038    #[test]
1039    fn encrypt_recipients_hints_when_age_recipient_used_with_gpg_backend() {
1040        let dir =
1041            std::env::temp_dir().join(format!("shine-env-recipient-{}", uuid::Uuid::new_v4()));
1042        let config = config_in(&dir);
1043
1044        let err =
1045            resolve_encrypt_recipients(BackendKind::Gpg, &["age1qexample".to_string()], &config)
1046                .unwrap_err();
1047
1048        assert!(
1049            err.to_string().contains("did you mean --backend age"),
1050            "error should hint at the age backend: {err:#}"
1051        );
1052    }
1053
1054    fn shadow_key(
1055        config: &mut Config,
1056        key: &str,
1057        path: std::path::PathBuf,
1058        is_managed_overlay: bool,
1059    ) {
1060        let kind = if is_managed_overlay {
1061            crate::config::EnvOverrideKind::Overlay
1062        } else {
1063            crate::config::EnvOverrideKind::Global
1064        };
1065        config.env_override_sources.insert(
1066            key.to_string(),
1067            crate::config::EnvOverrideSource {
1068                path,
1069                kind,
1070                is_managed_overlay,
1071            },
1072        );
1073    }
1074
1075    #[test]
1076    fn resolve_env_write_target_returns_config_toml_when_unshadowed() {
1077        let dir = std::env::temp_dir().join(format!("shine-env-write-{}", uuid::Uuid::new_v4()));
1078        let config = config_in(&dir);
1079
1080        let target = resolve_env_write_target(&config, "MY_TOKEN", false).unwrap();
1081
1082        assert!(matches!(target, EnvWriteTarget::ConfigToml));
1083    }
1084
1085    #[test]
1086    fn resolve_env_write_target_refuses_without_force_when_shadowed() {
1087        let dir = std::env::temp_dir().join(format!("shine-env-write-{}", uuid::Uuid::new_v4()));
1088        let mut config = config_in(&dir);
1089        let override_path = dir.join("shine.env.toml");
1090        shadow_key(&mut config, "MY_TOKEN", override_path.clone(), false);
1091
1092        let err = resolve_env_write_target(&config, "MY_TOKEN", false).unwrap_err();
1093
1094        assert!(
1095            err.to_string()
1096                .contains(&crate::path_display::format(&override_path)),
1097            "error should name the winning override file: {err:#}"
1098        );
1099        assert!(
1100            err.to_string().contains("--force"),
1101            "error should hint at --force: {err:#}"
1102        );
1103    }
1104
1105    #[test]
1106    fn resolve_env_write_target_returns_override_file_with_force() {
1107        let dir = std::env::temp_dir().join(format!("shine-env-write-{}", uuid::Uuid::new_v4()));
1108        let mut config = config_in(&dir);
1109        let override_path = dir.join("shine.env.toml");
1110        shadow_key(&mut config, "MY_TOKEN", override_path.clone(), false);
1111
1112        let target = resolve_env_write_target(&config, "MY_TOKEN", true).unwrap();
1113
1114        match target {
1115            EnvWriteTarget::OverrideFile(source) => assert_eq!(source.path, override_path),
1116            EnvWriteTarget::ConfigToml => panic!("expected the shadowing override file"),
1117        }
1118    }
1119
1120    #[test]
1121    fn resolve_env_write_target_allows_managed_overlay_with_force() {
1122        let dir = std::env::temp_dir().join(format!("shine-env-write-{}", uuid::Uuid::new_v4()));
1123        let mut config = config_in(&dir);
1124        let overlay_path = dir.join("overlay").join("shine.env.toml");
1125        shadow_key(&mut config, "MY_TOKEN", overlay_path.clone(), true);
1126
1127        let target = resolve_env_write_target(&config, "MY_TOKEN", true).unwrap();
1128
1129        match target {
1130            EnvWriteTarget::OverrideFile(source) => {
1131                assert_eq!(source.path, overlay_path);
1132                assert!(source.is_managed_overlay);
1133            }
1134            EnvWriteTarget::ConfigToml => panic!("expected the managed overlay override file"),
1135        }
1136    }
1137
1138    #[tokio::test]
1139    async fn env_set_refuses_when_shadowed_without_force() {
1140        let dir = make_temp_dir().await;
1141        let mut config = config_in(&dir);
1142        let override_path = dir.join("shine.env.toml");
1143        shadow_key(&mut config, "MY_TOKEN", override_path.clone(), false);
1144
1145        let err = handle_set(&config, "MY_TOKEN", "newval", false)
1146            .await
1147            .unwrap_err();
1148
1149        assert!(
1150            err.to_string()
1151                .contains(&crate::path_display::format(&override_path))
1152        );
1153        assert!(
1154            !fs::try_exists(&override_path).await.unwrap(),
1155            "refused write must not touch the override file"
1156        );
1157        assert!(
1158            !fs::read_to_string(config.config_path())
1159                .await
1160                .unwrap_or_default()
1161                .contains("MY_TOKEN"),
1162            "refused write must not touch config.toml either"
1163        );
1164
1165        fs::remove_dir_all(&dir).await.unwrap();
1166    }
1167
1168    #[tokio::test]
1169    async fn env_set_writes_into_override_file_when_forced() {
1170        let dir = make_temp_dir().await;
1171        let mut config = config_in(&dir);
1172        let override_path = dir.join("shine.env.toml");
1173        fs::write(&override_path, "MY_TOKEN = \"old\"\n")
1174            .await
1175            .unwrap();
1176        shadow_key(&mut config, "MY_TOKEN", override_path.clone(), false);
1177
1178        handle_set(&config, "MY_TOKEN", "newval", true)
1179            .await
1180            .unwrap();
1181
1182        let content = fs::read_to_string(&override_path).await.unwrap();
1183        assert!(content.contains("MY_TOKEN = \"newval\""));
1184        assert!(
1185            !fs::read_to_string(config.config_path())
1186                .await
1187                .unwrap_or_default()
1188                .contains("MY_TOKEN"),
1189            "forced write must go into the override file, not config.toml"
1190        );
1191
1192        fs::remove_dir_all(&dir).await.unwrap();
1193    }
1194
1195    #[tokio::test]
1196    async fn env_delete_refuses_when_shadowed_without_force() {
1197        let dir = make_temp_dir().await;
1198        let mut config = config_in(&dir);
1199        let override_path = dir.join("shine.env.toml");
1200        fs::write(&override_path, "MY_TOKEN = \"secret\"\n")
1201            .await
1202            .unwrap();
1203        shadow_key(&mut config, "MY_TOKEN", override_path.clone(), false);
1204
1205        let err = handle_delete(&config, "MY_TOKEN", false).await.unwrap_err();
1206
1207        assert!(
1208            err.to_string()
1209                .contains(&crate::path_display::format(&override_path))
1210        );
1211        let content = fs::read_to_string(&override_path).await.unwrap();
1212        assert!(
1213            content.contains("MY_TOKEN"),
1214            "refused delete must leave the override file untouched"
1215        );
1216
1217        fs::remove_dir_all(&dir).await.unwrap();
1218    }
1219
1220    #[tokio::test]
1221    async fn env_delete_removes_from_override_file_when_forced() {
1222        let dir = make_temp_dir().await;
1223        let mut config = config_in(&dir);
1224        let override_path = dir.join("shine.env.toml");
1225        fs::write(&override_path, "MY_TOKEN = \"secret\"\nOTHER = \"kept\"\n")
1226            .await
1227            .unwrap();
1228        shadow_key(&mut config, "MY_TOKEN", override_path.clone(), false);
1229
1230        handle_delete(&config, "MY_TOKEN", true).await.unwrap();
1231
1232        let content = fs::read_to_string(&override_path).await.unwrap();
1233        let table: toml::Table = toml::from_str(&content).unwrap();
1234        assert!(!table.contains_key("MY_TOKEN"));
1235        assert!(table.contains_key("OTHER"));
1236
1237        fs::remove_dir_all(&dir).await.unwrap();
1238    }
1239}