Skip to main content

ryra_core/generate/
mod.rs

1pub mod bundle;
2pub mod context;
3pub mod template;
4use std::collections::{BTreeMap, BTreeSet};
5use std::path::PathBuf;
6
7use crate::config::schema::Config;
8use crate::error::{Error, Result};
9use crate::exposure::Exposure;
10use crate::registry::service_def::{AuthKind, EnvKind, EnvVar, PortDef, ServiceDef};
11
12#[derive(Debug)]
13pub struct GeneratedFile {
14    pub path: PathBuf,
15    pub content: String,
16}
17
18/// Parameters for [`generate_env`].
19pub struct GenerateEnvParams<'a> {
20    pub config: &'a Config,
21    pub service_def: &'a ServiceDef,
22    /// The auth kind the user chose to enable, if any.
23    pub auth_kind: Option<&'a AuthKind>,
24    /// Primary host port (for `service.url` / `service.port` templating).
25    pub host_port: Option<u16>,
26    /// Per-port resolved host ports, keyed by port name (e.g. "http", "smtp").
27    /// Used to emit `PORT_*` lines in the .env file — each entry here
28    /// corresponds to one `[[ports]]` definition in service.toml.
29    pub resolved_ports: &'a [(String, u16)],
30    pub env_overrides: &'a BTreeMap<String, String>,
31    /// How the service is exposed; its URL (if any) feeds templates like
32    /// `{{service.external_url}}` / `{{service.domain}}`.
33    pub exposure: &'a Exposure,
34    /// Additional env vars to append to the .env file (e.g., CA cert trust vars).
35    pub extra_env: BTreeMap<String, String>,
36    /// Pre-built template context. When provided, secrets and auth credentials
37    /// from this context are reused instead of generating fresh ones. This
38    /// ensures the values shown during interactive prompts match what gets
39    /// written to the .env file.
40    pub pre_built_ctx: Option<BTreeMap<String, String>>,
41    /// Whether this service should use the globally configured SMTP. When
42    /// false, smtp.* is left out of the context and [mappings.smtp] is skipped
43    /// — lets the user opt a single service out of email without clearing the
44    /// global SMTP config.
45    pub enable_smtp: bool,
46    /// Names of `[[env_group]]` entries the user toggled on. Members of
47    /// groups not listed here are fully omitted from the generated `.env`.
48    pub enabled_groups: &'a BTreeSet<String>,
49    /// `[[choice]]` selections (`choice name -> option name`). Only the
50    /// selected option's members are written; absent choices fall back to
51    /// their declared `default`.
52    pub selected_choices: &'a BTreeMap<String, String>,
53}
54
55/// Result of generating env for a service.
56pub struct EnvOutput {
57    pub env_file: GeneratedFile,
58    /// The template context used during generation (for auth registration, etc.).
59    pub ctx: BTreeMap<String, String>,
60}
61
62/// Generate the .env file for a service based on its definition and context.
63pub fn generate_env(params: GenerateEnvParams<'_>) -> Result<EnvOutput> {
64    let name = &params.service_def.service.name;
65
66    // Always build a fresh context with the now-known host_port. Overlay
67    // secret.* and auth.* entries from the pre-built context so randomly
68    // generated values the user saw during prompts stay stable.
69    let mut ctx = context::build_context(
70        params.config,
71        params.service_def,
72        params.host_port,
73        params.auth_kind,
74        params.exposure,
75        params.enable_smtp,
76    )?;
77    if let Some(prebuilt) = params.pre_built_ctx {
78        for (key, value) in prebuilt {
79            if key.starts_with("secret.") || key.starts_with("auth.") {
80                ctx.insert(key, value);
81            }
82        }
83    }
84    // Effective ports = top-level plus the selected choice option's gated ports
85    // (so a gated port's port_url resolves; mirrors the allocator in lib.rs).
86    let mut eff_ports: Vec<&PortDef> = params.service_def.ports.iter().collect();
87    for choice in &params.service_def.choices {
88        let sel = params
89            .selected_choices
90            .get(&choice.name)
91            .unwrap_or(&choice.default);
92        if let Some(opt) = choice.options.iter().find(|o| &o.name == sel) {
93            eff_ports.extend(opt.ports.iter());
94        }
95    }
96    insert_port_urls(
97        &mut ctx,
98        &eff_ports,
99        params.resolved_ports,
100        params.exposure.url(),
101    );
102
103    let rendered_env = render_env_vars(
104        params.service_def,
105        &ctx,
106        params.env_overrides,
107        params.auth_kind,
108        params.enabled_groups,
109        params.selected_choices,
110    )?;
111
112    // Build .env file content
113    let home_dir = crate::service_home(name)?;
114    let mut env_file = build_env_file(&home_dir, &rendered_env, params.resolved_ports);
115
116    // Append extra env vars (e.g., CA cert trust for OIDC)
117    for (key, value) in &params.extra_env {
118        env_file.content.push_str(&format!("{key}={value}\n"));
119    }
120
121    Ok(EnvOutput { env_file, ctx })
122}
123
124/// Insert `service.port_url.<name>` for every declared port — the URL at
125/// which that specific port is reachable from a browser.
126///
127/// For single-endpoint services every port resolves to `external_url`.
128/// Multi-port services (ente: web UI on 443, API on 8080, served on separate
129/// Tailscale HTTPS ports) get a distinct URL per port, so a template like
130/// `ENTE_API_ORIGIN = {{service.port_url.http}}` points at the API endpoint
131/// while the bare hostname serves the UI — in every exposure mode (loopback,
132/// raw `--url`, or `--tailscale`).
133fn insert_port_urls(
134    ctx: &mut BTreeMap<String, String>,
135    // Effective ports: top-level plus the selected choice option's, so a gated
136    // port (e.g. ente's bundled minio) gets `service.port_url.*` too.
137    ports: &[&PortDef],
138    resolved_ports: &[(String, u16)],
139    url: Option<&str>,
140) {
141    // Bare allocated host port per name, for templating into values like a
142    // DATABASE_URL that points at a gated container's published loopback port
143    // (`@127.0.0.1:{{service.ports.db}}`). Covers top-level and choice-gated
144    // ports alike, since `resolved_ports` already includes both. Plural
145    // `service.ports.*` so it doesn't collide with the `service.port` leaf
146    // (the template engine nests dotted keys; a key can't be leaf and parent).
147    for (name, port) in resolved_ports {
148        ctx.insert(format!("service.ports.{name}"), port.to_string());
149    }
150    // The primary port (named "http", else the first) answers at the root
151    // URL — for it, `port_url` is exactly `external_url` outside Tailscale.
152    let primary = ports
153        .iter()
154        .copied()
155        .find(|p| p.name.eq_ignore_ascii_case("http"))
156        .or_else(|| ports.first().copied())
157        .map(|p| p.name.clone());
158    let parsed = url.and_then(|u| url::Url::parse(u).ok());
159    let host = parsed
160        .as_ref()
161        .and_then(|u| u.host_str())
162        .map(str::to_string);
163    let scheme = parsed.as_ref().map(|u| u.scheme().to_string());
164    let is_ts = host.as_deref().is_some_and(|h| h.ends_with(".ts.net"));
165    let external_url = ctx.get("service.external_url").cloned();
166
167    for p in ports.iter().copied() {
168        let host_port = resolved_ports
169            .iter()
170            .find(|(n, _)| n == &p.name)
171            .map(|(_, hp)| *hp)
172            .or(p.host_port)
173            .unwrap_or(p.container_port);
174        let is_primary = primary.as_deref() == Some(p.name.as_str());
175        let port_url =
176            if let (true, Some(https), Some(h)) = (is_ts, p.tailscale_https, host.as_deref()) {
177                // Tailscale: this port answers at the service hostname on its
178                // HTTPS port (443 is the bare hostname, no explicit port).
179                if https == 443 {
180                    format!("https://{h}")
181                } else {
182                    format!("https://{h}:{https}")
183                }
184            } else if is_primary && let Some(ext) = &external_url {
185                ext.clone()
186            } else if let (Some(s), Some(h)) = (scheme.as_deref(), host.as_deref()) {
187                // Non-primary port under a raw `--url`: directly published at the
188                // same host on its own host port.
189                format!("{s}://{h}:{host_port}")
190            } else {
191                format!("http://127.0.0.1:{host_port}")
192            };
193        ctx.insert(format!("service.port_url.{}", p.name), port_url);
194    }
195}
196
197/// Build the .env file for a service.
198fn build_env_file(
199    home_dir: &std::path::Path,
200    rendered_env: &[EnvVar],
201    resolved_ports: &[(String, u16)],
202) -> GeneratedFile {
203    let mut lines = Vec::new();
204
205    for env in rendered_env {
206        // Write raw KEY=VALUE for podman --env-file. Podman does NOT strip
207        // quotes (single or double), so any shell-style quoting ends up as
208        // literal characters in the container. Tests that source the .env
209        // must stick to values that survive unquoted bash parsing.
210        lines.push(format!("{}={}", env.name, env.value));
211    }
212
213    // Expose service home path so scripts can reference it
214    lines.push(format!("SERVICE_HOME={}", home_dir.display()));
215
216    // Expose each [[ports]] entry as SERVICE_PORT_<NAME> with its
217    // resolved host port. The SERVICE_ prefix matches SERVICE_HOME and
218    // makes ryra-emitted vars visually distinct from service-specific
219    // ones (which carry their own naming, e.g. POSTGRES_PASSWORD).
220    for (name, port) in resolved_ports {
221        let var_name = format!("SERVICE_PORT_{}", name.to_uppercase());
222        lines.push(format!("{var_name}={port}"));
223    }
224
225    GeneratedFile {
226        path: home_dir.join(".env"),
227        content: lines.join("\n") + "\n",
228    }
229}
230
231// --- Shared helpers ---
232
233fn render_env_vars(
234    service_def: &ServiceDef,
235    ctx: &BTreeMap<String, String>,
236    env_overrides: &BTreeMap<String, String>,
237    auth_kind: Option<&AuthKind>,
238    enabled_groups: &BTreeSet<String>,
239    selected_choices: &BTreeMap<String, String>,
240) -> Result<Vec<EnvVar>> {
241    let mut rendered: Vec<EnvVar> = service_def
242        .env
243        .iter()
244        .map(|env| render_one(env, env_overrides, ctx, None))
245        .collect::<Result<Vec<_>>>()?;
246
247    // Append members of every enabled `[[env_group]]`. Groups not toggled
248    // on are fully omitted, no partial state possible.
249    for group in &service_def.env_groups {
250        if !enabled_groups.contains(&group.name) {
251            continue;
252        }
253        let loc = format!("group '{}'", group.name);
254        for env in &group.env {
255            rendered.push(render_one(env, env_overrides, ctx, Some(&loc))?);
256        }
257    }
258
259    // Append the selected option of every `[[choice]]`. With no recorded
260    // selection (a choice added after install) we fall back to the choice's
261    // `default`, which validate() guarantees names a real option. Only the
262    // selected option's members are written; the rest never appear.
263    for choice in &service_def.choices {
264        let selected = selected_choices
265            .get(&choice.name)
266            .unwrap_or(&choice.default);
267        let Some(option) = choice.options.iter().find(|o| &o.name == selected) else {
268            continue;
269        };
270        let loc = format!("choice '{}' option '{}'", choice.name, option.name);
271        for env in &option.env {
272            rendered.push(render_one(env, env_overrides, ctx, Some(&loc))?);
273        }
274    }
275
276    if service_def.integrations.smtp && ctx.contains_key("smtp.host") {
277        for (env_name, value_template) in &service_def.mappings.smtp {
278            let value = template::render(value_template, ctx)?;
279            // Empty values are valid — e.g., inbucket doesn't need username/password.
280            // Static values (no template) are always included as-is.
281            rendered.push(EnvVar {
282                name: env_name.clone(),
283                value,
284                kind: Default::default(),
285                prompt: None,
286                format: Default::default(),
287                length: None,
288                jwt_claims: None,
289                jwt_signing_key: None,
290            });
291        }
292    }
293    if auth_kind.is_some() {
294        for (env_name, value_template) in &service_def.mappings.auth {
295            let value = template::render(value_template, ctx)?;
296            if value.is_empty() {
297                return Err(Error::Template(format!(
298                    "auth mapping {env_name} rendered to empty value from template: {value_template}"
299                )));
300            }
301            rendered.push(EnvVar {
302                name: env_name.clone(),
303                value,
304                kind: Default::default(),
305                prompt: None,
306                format: Default::default(),
307                length: None,
308                jwt_claims: None,
309                jwt_signing_key: None,
310            });
311        }
312    }
313
314    Ok(rendered)
315}
316
317/// Render a single `EnvVar` — apply an override if present, otherwise run
318/// the template. Required group members without an override are a hard
319/// error so the service never starts with half of a group configured.
320fn render_one(
321    env: &EnvVar,
322    env_overrides: &BTreeMap<String, String>,
323    ctx: &BTreeMap<String, String>,
324    // Location phrase for the "required member has no value" error, e.g.
325    // `"group 'stripe'"` or `"choice 'billing' option 'live'"`. `None` for a
326    // top-level var (a required top-level var is caught earlier at prompt time).
327    member_of: Option<&str>,
328) -> Result<EnvVar> {
329    let value = match env_overrides.get(&env.name) {
330        Some(override_value) => override_value.clone(),
331        None => {
332            if let Some(loc) = member_of
333                && env.kind == EnvKind::Required
334            {
335                return Err(Error::Template(format!(
336                    "required env var '{}' in {loc} has no value; provide it via the interactive prompt or process env",
337                    env.name
338                )));
339            }
340            template::render(&env.value, ctx)?
341        }
342    };
343    Ok(EnvVar {
344        name: env.name.clone(),
345        value,
346        kind: Default::default(),
347        prompt: None,
348        format: Default::default(),
349        length: None,
350        jwt_claims: None,
351        jwt_signing_key: None,
352    })
353}
354
355pub fn extract_secret_refs(value: &str) -> Vec<String> {
356    let mut secrets = Vec::new();
357    let mut rest = value;
358    while let Some(start) = rest.find("{{secret.") {
359        let after = &rest[start + 9..];
360        if let Some(end) = after.find("}}") {
361            secrets.push(after[..end].to_string());
362            rest = &after[end + 2..];
363        } else {
364            break;
365        }
366    }
367    secrets
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use crate::config::schema::Config;
374    use crate::registry::service_def::{
375        EnvGroup, EnvKind, EnvVar, PortDef, ServiceDef, ServiceMeta,
376    };
377
378    fn minimal_service_def() -> ServiceDef {
379        ServiceDef {
380            service: ServiceMeta {
381                name: "demo".into(),
382                description: "demo".into(),
383                url: None,
384                kind: Default::default(),
385                architecture: vec![],
386                https: Default::default(),
387                runtime: Default::default(),
388                run: None,
389                build: None,
390                post_install: None,
391                deploy: Default::default(),
392                health_check: None,
393                health_timeout: None,
394            },
395            requirements: None,
396            ports: vec![PortDef {
397                name: "http".into(),
398                container_port: 80,
399                host_port: None,
400                protocol: Default::default(),
401                tailscale_https: None,
402            }],
403            env: vec![
404                EnvVar {
405                    name: "HOSTPORT".into(),
406                    value: "{{service.port}}".into(),
407                    kind: EnvKind::Default,
408                    prompt: None,
409                    format: Default::default(),
410                    length: None,
411                    jwt_claims: None,
412                    jwt_signing_key: None,
413                },
414                EnvVar {
415                    name: "ADMIN_PASSWORD".into(),
416                    value: "{{secret.admin}}".into(),
417                    kind: EnvKind::Default,
418                    prompt: None,
419                    format: Default::default(),
420                    length: Some(16),
421                    jwt_claims: None,
422                    jwt_signing_key: None,
423                },
424            ],
425            env_groups: vec![],
426            choices: vec![],
427            requires: vec![],
428            mappings: Default::default(),
429            integrations: Default::default(),
430            capabilities: Default::default(),
431            backup: None,
432            metrics: None,
433        }
434    }
435
436    fn plain_env(name: &str, value: &str, kind: EnvKind) -> EnvVar {
437        EnvVar {
438            name: name.into(),
439            value: value.into(),
440            kind,
441            prompt: None,
442            format: Default::default(),
443            length: None,
444            jwt_claims: None,
445            jwt_signing_key: None,
446        }
447    }
448
449    fn def_with_oauth_group() -> ServiceDef {
450        let mut def = minimal_service_def();
451        def.env_groups.push(EnvGroup {
452            name: "google_oauth".into(),
453            prompt: "Enable Google?".into(),
454            env: vec![
455                plain_env("CLIENT_ID", "", EnvKind::Required),
456                plain_env("CLIENT_SECRET", "", EnvKind::Required),
457                plain_env("CALLBACK_URL", "https://demo/cb", EnvKind::Default),
458                plain_env("OAUTH_ENABLED", "true", EnvKind::Default),
459            ],
460        });
461        def
462    }
463
464    /// Two-endpoint service like ente: museum API ("http") served over
465    /// Tailscale on :8080, Photos UI ("photos") at the bare hostname (:443).
466    fn multiport_def() -> ServiceDef {
467        let mut def = minimal_service_def();
468        def.ports = vec![
469            PortDef {
470                name: "http".into(),
471                container_port: 8080,
472                host_port: None,
473                protocol: Default::default(),
474                tailscale_https: Some(8080),
475            },
476            PortDef {
477                name: "photos".into(),
478                container_port: 3000,
479                host_port: None,
480                protocol: Default::default(),
481                tailscale_https: Some(443),
482            },
483        ];
484        def
485    }
486
487    fn port_urls(url: Option<&str>, external_url: &str) -> BTreeMap<String, String> {
488        let def = multiport_def();
489        let resolved = vec![
490            ("http".to_string(), 8080u16),
491            ("photos".to_string(), 10002u16),
492        ];
493        let mut ctx = BTreeMap::new();
494        ctx.insert("service.external_url".to_string(), external_url.to_string());
495        let ports: Vec<&PortDef> = def.ports.iter().collect();
496        insert_port_urls(&mut ctx, &ports, &resolved, url);
497        ctx
498    }
499
500    #[test]
501    fn port_url_loopback_uses_host_ports() {
502        // No --url: primary == external_url (localhost), others at 127.0.0.1:<port>.
503        let ctx = port_urls(None, "http://127.0.0.1:8080");
504        assert_eq!(ctx["service.port_url.http"], "http://127.0.0.1:8080");
505        assert_eq!(ctx["service.port_url.photos"], "http://127.0.0.1:10002");
506    }
507
508    #[test]
509    fn port_url_raw_ip_url_exposes_each_port() {
510        // Raw --url at a tailnet IP: museum == the url, photos directly published.
511        let ctx = port_urls(Some("http://100.69.58.21:8080"), "http://100.69.58.21:8080");
512        assert_eq!(ctx["service.port_url.http"], "http://100.69.58.21:8080");
513        assert_eq!(ctx["service.port_url.photos"], "http://100.69.58.21:10002");
514    }
515
516    #[test]
517    fn port_url_tailscale_splits_root_and_api() {
518        // --tailscale: photos answers at the bare hostname, museum on :8080.
519        let url = "https://ente-debian.cobbler-tuna.ts.net";
520        let ctx = port_urls(Some(url), url);
521        assert_eq!(
522            ctx["service.port_url.http"],
523            "https://ente-debian.cobbler-tuna.ts.net:8080"
524        );
525        assert_eq!(
526            ctx["service.port_url.photos"],
527            "https://ente-debian.cobbler-tuna.ts.net"
528        );
529    }
530
531    fn gen_with_group(
532        def: &ServiceDef,
533        enabled_groups: &BTreeSet<String>,
534        overrides: &BTreeMap<String, String>,
535    ) -> Result<String> {
536        let config = Config::default();
537        let resolved = vec![("http".to_string(), 10002u16)];
538        let output = generate_env(GenerateEnvParams {
539            config: &config,
540            service_def: def,
541            auth_kind: None,
542            host_port: Some(10002),
543            resolved_ports: &resolved,
544            env_overrides: overrides,
545            exposure: &Exposure::Loopback,
546            extra_env: BTreeMap::new(),
547            pre_built_ctx: None,
548            enable_smtp: false,
549            enabled_groups,
550            selected_choices: &BTreeMap::new(),
551        })?;
552        Ok(output.env_file.content)
553    }
554
555    fn gen_with_choices(
556        def: &ServiceDef,
557        selected: &BTreeMap<String, String>,
558        overrides: &BTreeMap<String, String>,
559    ) -> Result<String> {
560        let config = Config::default();
561        let resolved = vec![("http".to_string(), 10002u16)];
562        let output = generate_env(GenerateEnvParams {
563            config: &config,
564            service_def: def,
565            auth_kind: None,
566            host_port: Some(10002),
567            resolved_ports: &resolved,
568            env_overrides: overrides,
569            exposure: &Exposure::Loopback,
570            extra_env: BTreeMap::new(),
571            pre_built_ctx: None,
572            enable_smtp: false,
573            enabled_groups: &BTreeSet::new(),
574            selected_choices: selected,
575        })?;
576        Ok(output.env_file.content)
577    }
578
579    fn def_with_billing_choice() -> ServiceDef {
580        toml::from_str(
581            r#"
582[service]
583name = "billed"
584description = "x"
585
586[[ports]]
587name = "http"
588container_port = 8080
589
590[[choice]]
591name = "billing"
592prompt = "Billing mode"
593default = "mock"
594
595[[choice.option]]
596name = "live"
597[[choice.option.env]]
598name = "BILLING_MODE"
599value = "live"
600[[choice.option.env]]
601name = "STRIPE_SECRET_KEY"
602value = ""
603kind = "required"
604
605[[choice.option]]
606name = "mock"
607[[choice.option.env]]
608name = "BILLING_MODE"
609value = "mock"
610"#,
611        )
612        .expect("parse")
613    }
614
615    #[test]
616    fn choice_writes_only_selected_option_members() {
617        let def = def_with_billing_choice();
618        let mut selected = BTreeMap::new();
619        selected.insert("billing".to_string(), "mock".to_string());
620        let content =
621            gen_with_choices(&def, &selected, &BTreeMap::new()).expect("mock selection renders");
622        assert!(content.contains("BILLING_MODE=mock"), "got: {content}");
623        // The `live`-only Stripe var must not appear.
624        assert!(!content.contains("STRIPE_SECRET_KEY"), "got: {content}");
625    }
626
627    #[test]
628    fn choice_option_secret_is_generated() {
629        // Regression: a `{{secret.*}}` referenced only inside a choice option
630        // must still be minted. Secret generation used to scan top-level env
631        // only, so this rendered to an undefined value and `ryra add` failed.
632        let def = toml::from_str::<ServiceDef>(
633            r#"
634[service]
635name = "s"
636description = "x"
637[[ports]]
638name = "http"
639container_port = 8080
640[[choice]]
641name = "database"
642prompt = "Database"
643default = "internal"
644[[choice.option]]
645name = "internal"
646[[choice.option.env]]
647name = "DB_PASSWORD"
648value = "{{secret.db_password}}"
649[[choice.option]]
650name = "external"
651[[choice.option.env]]
652name = "DB_PASSWORD"
653value = ""
654kind = "required"
655"#,
656        )
657        .expect("parse");
658        let mut selected = BTreeMap::new();
659        selected.insert("database".to_string(), "internal".to_string());
660        let content = gen_with_choices(&def, &selected, &BTreeMap::new())
661            .expect("renders with generated secret");
662        let line = content
663            .lines()
664            .find(|l| l.starts_with("DB_PASSWORD="))
665            .expect("DB_PASSWORD present");
666        let val = line.trim_start_matches("DB_PASSWORD=");
667        assert!(!val.is_empty() && !val.contains("{{"), "got: {line}");
668    }
669
670    #[test]
671    fn choice_falls_back_to_default_when_unselected() {
672        let def = def_with_billing_choice();
673        // Empty selection map -> the `default` (mock) is rendered.
674        let content = gen_with_choices(&def, &BTreeMap::new(), &BTreeMap::new())
675            .expect("default selection renders");
676        assert!(content.contains("BILLING_MODE=mock"), "got: {content}");
677    }
678
679    #[test]
680    fn choice_required_member_needs_a_value() {
681        // Selecting `live` without providing STRIPE_SECRET_KEY must error,
682        // mirroring a required group member with no value.
683        let def = def_with_billing_choice();
684        let mut selected = BTreeMap::new();
685        selected.insert("billing".to_string(), "live".to_string());
686        let err = gen_with_choices(&def, &selected, &BTreeMap::new())
687            .expect_err("required member without value must fail");
688        assert!(
689            format!("{err}").contains("STRIPE_SECRET_KEY"),
690            "error names the missing var: {err}"
691        );
692    }
693
694    #[test]
695    fn choice_required_member_value_is_written() {
696        let def = def_with_billing_choice();
697        let mut selected = BTreeMap::new();
698        selected.insert("billing".to_string(), "live".to_string());
699        let mut overrides = BTreeMap::new();
700        overrides.insert("STRIPE_SECRET_KEY".to_string(), "sk_test_123".to_string());
701        let content = gen_with_choices(&def, &selected, &overrides).expect("live renders");
702        assert!(content.contains("BILLING_MODE=live"), "got: {content}");
703        assert!(
704            content.contains("STRIPE_SECRET_KEY=sk_test_123"),
705            "got: {content}"
706        );
707    }
708
709    #[test]
710    fn env_group_disabled_writes_no_members() {
711        let def = def_with_oauth_group();
712        let no_groups = BTreeSet::new();
713        let content = gen_with_group(&def, &no_groups, &BTreeMap::new())
714            .expect("generate_env should succeed with no groups enabled");
715        for name in [
716            "CLIENT_ID",
717            "CLIENT_SECRET",
718            "CALLBACK_URL",
719            "OAUTH_ENABLED",
720        ] {
721            assert!(
722                !content.contains(&format!("{name}=")),
723                "disabled group member '{name}' leaked into .env: {content}"
724            );
725        }
726    }
727
728    #[test]
729    fn env_group_enabled_writes_all_members() {
730        let def = def_with_oauth_group();
731        let mut enabled = BTreeSet::new();
732        enabled.insert("google_oauth".to_string());
733        let mut overrides = BTreeMap::new();
734        overrides.insert("CLIENT_ID".into(), "my-client".into());
735        overrides.insert("CLIENT_SECRET".into(), "my-secret".into());
736        let content = gen_with_group(&def, &enabled, &overrides)
737            .expect("generate_env should succeed with the group enabled + overrides supplied");
738        assert!(content.contains("CLIENT_ID=my-client"), "{content}");
739        assert!(content.contains("CLIENT_SECRET=my-secret"), "{content}");
740        assert!(
741            content.contains("CALLBACK_URL=https://demo/cb"),
742            "{content}"
743        );
744        assert!(content.contains("OAUTH_ENABLED=true"), "{content}");
745    }
746
747    #[test]
748    fn env_group_enabled_required_member_without_override_errors() {
749        let def = def_with_oauth_group();
750        let mut enabled = BTreeSet::new();
751        enabled.insert("google_oauth".to_string());
752        // Intentionally leave CLIENT_SECRET out — required members with no
753        // value must fail loudly, never produce an empty .env entry.
754        let mut overrides = BTreeMap::new();
755        overrides.insert("CLIENT_ID".into(), "my-client".into());
756        let err = gen_with_group(&def, &enabled, &overrides)
757            .expect_err("required member missing must surface as an error");
758        let msg = err.to_string();
759        assert!(
760            msg.contains("CLIENT_SECRET") && msg.contains("google_oauth"),
761            "error should name the missing member + group: {msg}"
762        );
763    }
764
765    /// Regression: when the interactive CLI builds `pre_built_ctx` with
766    /// `host_port: None` (the real port isn't allocated yet), `generate_env`
767    /// must still produce a valid env file with the real `service.port`.
768    /// Previously the pre-built ctx was reused wholesale, so any env value
769    /// referencing `{{service.port}}` failed strict-mode rendering with
770    /// "undefined value".
771    #[test]
772    fn generate_env_rebuilds_port_when_prebuilt_ctx_lacks_it() {
773        let def = minimal_service_def();
774        let config = Config::default();
775        // Build the pre-built ctx as the interactive prompt phase does:
776        // host_port is None, so `service.port` is absent from the ctx.
777        let prebuilt =
778            context::build_context(&config, &def, None, None, &Exposure::Loopback, false)
779                .expect("build_context with host_port=None should succeed");
780        assert!(!prebuilt.contains_key("service.port"));
781        let admin_secret = prebuilt
782            .get("secret.admin")
783            .expect("secret.admin should have been generated in the prompt phase")
784            .clone();
785
786        // Now run generate_env with the real allocated host_port.
787        let resolved = vec![("http".to_string(), 10002u16)];
788        let no_groups = BTreeSet::new();
789        let output = generate_env(GenerateEnvParams {
790            config: &config,
791            service_def: &def,
792            auth_kind: None,
793            host_port: Some(10002),
794            resolved_ports: &resolved,
795            env_overrides: &BTreeMap::new(),
796            exposure: &Exposure::Loopback,
797            extra_env: BTreeMap::new(),
798            pre_built_ctx: Some(prebuilt),
799            enable_smtp: false,
800            enabled_groups: &no_groups,
801            selected_choices: &BTreeMap::new(),
802        })
803        .expect("generate_env must succeed with the real host_port");
804
805        // The resulting .env must carry the allocated port, and the randomly
806        // generated secret from the prompt phase must be preserved verbatim.
807        assert!(
808            output.env_file.content.contains("HOSTPORT=10002"),
809            ".env missing real port: {}",
810            output.env_file.content,
811        );
812        assert!(
813            output
814                .env_file
815                .content
816                .contains(&format!("ADMIN_PASSWORD={admin_secret}")),
817            "prompt-phase secret not preserved in .env: {}",
818            output.env_file.content,
819        );
820    }
821}