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