Skip to main content

tonin_plugin/
stateful.rs

1//! Stateful dependencies (Phase 1 of stateful-deps design).
2//!
3//! Loads `[database]`, `[cache]`, `[secrets]`, `[migrations]` from
4//! `tonin.toml`, applies `[database.dev]` / `[database.prod]` env-overlay,
5//! and normalizes into types the renderer will consume.
6
7use serde::{Deserialize, Serialize};
8
9// ---------- on-disk TOML shape ----------
10
11#[derive(Debug, Deserialize, Clone)]
12pub(crate) struct RawDatabase {
13    pub engine: String,
14    #[serde(default)]
15    pub version: Option<String>,
16    #[serde(default)]
17    pub size: Option<String>,
18    #[serde(default)]
19    pub shared: bool,
20    #[serde(default)]
21    pub name: Option<String>,
22    #[serde(default)]
23    pub namespace: Option<String>,
24    #[serde(default, flatten)]
25    pub envs: std::collections::BTreeMap<String, RawDatabaseEnv>,
26}
27
28#[derive(Debug, Deserialize, Clone, Default)]
29pub(crate) struct RawDatabaseEnv {
30    #[serde(default)]
31    pub engine: Option<String>,
32    #[serde(default)]
33    pub version: Option<String>,
34    #[serde(default)]
35    pub size: Option<String>,
36    #[serde(default)]
37    pub shared: Option<bool>,
38    #[serde(default)]
39    pub name: Option<String>,
40    #[serde(default)]
41    pub namespace: Option<String>,
42    #[serde(default)]
43    pub url: Option<String>,
44}
45
46#[derive(Debug, Deserialize, Clone)]
47pub(crate) struct RawCache {
48    pub engine: String,
49    #[serde(default)]
50    pub size: Option<String>,
51    #[serde(default)]
52    pub shared: bool,
53    #[serde(default)]
54    pub name: Option<String>,
55    #[serde(default)]
56    pub namespace: Option<String>,
57    #[serde(default, flatten)]
58    pub envs: std::collections::BTreeMap<String, RawCacheEnv>,
59}
60
61#[derive(Debug, Deserialize, Clone, Default)]
62pub(crate) struct RawCacheEnv {
63    #[serde(default)]
64    pub engine: Option<String>,
65    #[serde(default)]
66    pub size: Option<String>,
67    #[serde(default)]
68    pub shared: Option<bool>,
69    #[serde(default)]
70    pub name: Option<String>,
71    #[serde(default)]
72    pub namespace: Option<String>,
73    #[serde(default)]
74    pub url: Option<String>,
75}
76
77#[derive(Debug, Deserialize, Clone)]
78pub(crate) struct RawSecrets {
79    #[serde(default = "default_secret_provider")]
80    pub provider: String,
81    #[serde(default)]
82    pub required: Vec<String>,
83    #[serde(default)]
84    pub map: std::collections::BTreeMap<String, String>,
85    #[serde(default)]
86    pub external_store: Option<RawExternalStore>,
87}
88
89fn default_secret_provider() -> String {
90    "k8s".into()
91}
92
93#[derive(Debug, Deserialize, Clone)]
94pub(crate) struct RawExternalStore {
95    pub name: String,
96    pub kind: String,
97}
98
99#[derive(Debug, Deserialize, Clone)]
100pub(crate) struct RawConfigBlock {
101    #[serde(default = "default_config_engine")]
102    pub engine: String,
103    #[serde(default)]
104    pub path_prefix: Option<String>,
105    #[serde(default)]
106    pub poll_interval_seconds: Option<u64>,
107    #[serde(default)]
108    pub endpoints: Vec<String>,
109    #[serde(default)]
110    pub repo: Option<String>,
111    #[serde(default)]
112    pub git_ref: Option<String>,
113    #[serde(default)]
114    pub sources: Vec<String>,
115}
116
117fn default_config_engine() -> String {
118    "env".into()
119}
120
121#[derive(Debug, Deserialize, Clone)]
122pub(crate) struct RawMigrations {
123    pub tool: String,
124    #[serde(default = "default_migrations_dir")]
125    pub dir: String,
126    #[serde(default = "default_run_on")]
127    pub run_on: String,
128    #[serde(default)]
129    pub command: Option<Vec<String>>,
130}
131
132fn default_migrations_dir() -> String {
133    "migrations/".into()
134}
135
136fn default_run_on() -> String {
137    "init-container".into()
138}
139
140// ---------- normalized types (what the renderer consumes) ----------
141
142#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
143#[serde(rename_all = "lowercase")]
144pub enum DatabaseEngine {
145    Postgres,
146    Mysql,
147    Sqlite,
148    Clickhouse,
149    None,
150}
151
152impl DatabaseEngine {
153    pub fn parse(s: &str) -> Self {
154        match s {
155            "postgres" => Self::Postgres,
156            "mysql" => Self::Mysql,
157            "sqlite" => Self::Sqlite,
158            "clickhouse" => Self::Clickhouse,
159            _ => Self::None,
160        }
161    }
162    pub fn as_str(&self) -> &'static str {
163        match self {
164            Self::Postgres => "postgres",
165            Self::Mysql => "mysql",
166            Self::Sqlite => "sqlite",
167            Self::Clickhouse => "clickhouse",
168            Self::None => "none",
169        }
170    }
171    pub fn default_port(&self) -> u32 {
172        match self {
173            Self::Postgres => 5432,
174            Self::Mysql => 3306,
175            Self::Clickhouse => 9000,
176            Self::Sqlite | Self::None => 0,
177        }
178    }
179}
180
181#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
182#[serde(rename_all = "lowercase")]
183pub enum CacheEngine {
184    Redis,
185    None,
186}
187
188impl CacheEngine {
189    pub fn parse(s: &str) -> Self {
190        match s {
191            "redis" => Self::Redis,
192            _ => Self::None,
193        }
194    }
195    pub fn as_str(&self) -> &'static str {
196        match self {
197            Self::Redis => "redis",
198            Self::None => "none",
199        }
200    }
201    pub fn default_port(&self) -> u32 {
202        match self {
203            Self::Redis => 6379,
204            Self::None => 0,
205        }
206    }
207}
208
209#[derive(Clone, Debug)]
210pub struct DatabaseSpec {
211    pub engine: DatabaseEngine,
212    pub version: String,
213    pub size: String,
214    pub shared: bool,
215    pub name: String,
216    pub namespace: String,
217    pub url_override: Option<String>,
218}
219
220fn default_db_version(engine: DatabaseEngine) -> String {
221    match engine {
222        DatabaseEngine::Postgres => "18".into(),
223        DatabaseEngine::Mysql => "8".into(),
224        DatabaseEngine::Clickhouse => "24.3".into(),
225        DatabaseEngine::Sqlite | DatabaseEngine::None => "latest".into(),
226    }
227}
228
229impl DatabaseSpec {
230    pub fn image(&self) -> String {
231        match self.engine {
232            DatabaseEngine::Postgres => format!("postgres:{}", self.version),
233            DatabaseEngine::Mysql => format!("mysql:{}", self.version),
234            DatabaseEngine::Clickhouse => format!("clickhouse/clickhouse-server:{}", self.version),
235            DatabaseEngine::Sqlite | DatabaseEngine::None => "".into(),
236        }
237    }
238    pub fn host(&self) -> String {
239        format!("{}.{}.svc.cluster.local", self.name, self.namespace)
240    }
241    pub fn port(&self) -> u32 {
242        self.engine.default_port()
243    }
244    pub fn url_template(&self, service_name: &str) -> String {
245        if let Some(ref url) = self.url_override {
246            return url.clone();
247        }
248        format!(
249            "{}://{svc}:$DATABASE_PASSWORD@{host}:{port}/{svc}",
250            self.engine.as_str(),
251            svc = service_name,
252            host = self.host(),
253            port = self.port(),
254        )
255    }
256}
257
258#[derive(Clone, Debug)]
259pub struct CacheSpec {
260    pub engine: CacheEngine,
261    pub size: String,
262    pub shared: bool,
263    pub name: String,
264    pub namespace: String,
265    pub url_override: Option<String>,
266}
267
268impl CacheSpec {
269    pub fn host(&self) -> String {
270        format!("{}.{}.svc.cluster.local", self.name, self.namespace)
271    }
272    pub fn port(&self) -> u32 {
273        self.engine.default_port()
274    }
275    pub fn url(&self) -> String {
276        if let Some(ref url) = self.url_override {
277            return url.clone();
278        }
279        format!("redis://{}:{}", self.host(), self.port())
280    }
281}
282
283#[derive(Clone, Debug)]
284pub struct SecretsSpec {
285    pub provider: SecretProvider,
286    pub required: Vec<String>,
287    pub map: std::collections::BTreeMap<String, String>,
288    pub external_store: Option<ExternalStore>,
289}
290
291#[derive(Clone, Copy, Debug, PartialEq, Eq)]
292pub enum SecretProvider {
293    K8s,
294    ExternalSecrets,
295    Vault,
296    AwsSecretsManager,
297}
298
299impl SecretProvider {
300    pub fn parse(s: &str) -> Self {
301        match s {
302            "external-secrets" => Self::ExternalSecrets,
303            "vault" => Self::Vault,
304            "aws-secrets-manager" => Self::AwsSecretsManager,
305            _ => Self::K8s,
306        }
307    }
308    pub fn as_str(&self) -> &'static str {
309        match self {
310            Self::K8s => "k8s",
311            Self::ExternalSecrets => "external-secrets",
312            Self::Vault => "vault",
313            Self::AwsSecretsManager => "aws-secrets-manager",
314        }
315    }
316}
317
318#[derive(Clone, Debug)]
319pub struct ExternalStore {
320    pub name: String,
321    pub kind: String,
322}
323
324#[derive(Clone, Debug)]
325pub struct ConfigSpec {
326    pub engine: ConfigEngine,
327    pub path_prefix: Option<String>,
328    pub poll_interval_seconds: u64,
329    pub endpoints: Vec<String>,
330    pub repo: Option<String>,
331    pub git_ref: Option<String>,
332    pub sources: Vec<ConfigEngine>,
333}
334
335#[derive(Clone, Copy, Debug, PartialEq, Eq)]
336pub enum ConfigEngine {
337    Env,
338    Etcd,
339    Github,
340    Chained,
341}
342
343impl ConfigEngine {
344    pub fn parse(s: &str) -> Self {
345        match s {
346            "etcd" => Self::Etcd,
347            "github" => Self::Github,
348            "chained" => Self::Chained,
349            _ => Self::Env,
350        }
351    }
352    pub fn as_str(&self) -> &'static str {
353        match self {
354            Self::Env => "env",
355            Self::Etcd => "etcd",
356            Self::Github => "github",
357            Self::Chained => "chained",
358        }
359    }
360}
361
362pub(crate) fn resolve_config(raw: &RawConfigBlock) -> ConfigSpec {
363    ConfigSpec {
364        engine: ConfigEngine::parse(&raw.engine),
365        path_prefix: raw.path_prefix.clone(),
366        poll_interval_seconds: raw.poll_interval_seconds.unwrap_or(30),
367        endpoints: raw.endpoints.clone(),
368        repo: raw.repo.clone(),
369        git_ref: raw.git_ref.clone(),
370        sources: raw.sources.iter().map(|s| ConfigEngine::parse(s)).collect(),
371    }
372}
373
374#[derive(Clone, Debug)]
375pub struct MigrationsSpec {
376    pub tool: MigrationTool,
377    pub dir: String,
378    pub run_on: MigrationRunOn,
379    pub command: Vec<String>,
380}
381
382#[derive(Clone, Copy, Debug, PartialEq, Eq)]
383pub enum MigrationTool {
384    Sqlx,
385    Refinery,
386    Flyway,
387    Custom,
388}
389
390#[derive(Clone, Copy, Debug, PartialEq, Eq)]
391pub enum MigrationRunOn {
392    InitContainer,
393    Boot,
394    Manual,
395}
396
397// ---------- [callers] with per-env overlay ----------
398
399#[derive(Debug, Deserialize, Clone)]
400#[serde(untagged)]
401pub(crate) enum RawCallerEntry {
402    Namespace(String),
403    Env(std::collections::BTreeMap<String, String>),
404}
405
406#[derive(Debug, Default, Deserialize, Clone)]
407#[serde(transparent)]
408pub(crate) struct RawCallers(pub std::collections::BTreeMap<String, RawCallerEntry>);
409
410pub(crate) fn resolve_callers(raw: &RawCallers, env: &str) -> Vec<crate::plan::ServiceRef> {
411    let mut base = std::collections::BTreeMap::new();
412    let mut overlay = std::collections::BTreeMap::new();
413
414    for (key, entry) in &raw.0 {
415        match entry {
416            RawCallerEntry::Namespace(ns) => {
417                base.insert(key.clone(), ns.clone());
418            }
419            RawCallerEntry::Env(map) if key == env => {
420                overlay = map.clone();
421            }
422            RawCallerEntry::Env(_) => {}
423        }
424    }
425
426    base.extend(overlay);
427    base.into_iter()
428        .map(|(name, namespace)| crate::plan::ServiceRef { name, namespace })
429        .collect()
430}
431
432// ---------- env selection ----------
433
434/// Decide which env we're rendering for. Precedence: explicit arg > env var > "dev".
435pub fn select_env(explicit: Option<&str>) -> String {
436    if let Some(e) = explicit {
437        return e.to_string();
438    }
439    std::env::var("TONIN_ENV").unwrap_or_else(|_| "dev".to_string())
440}
441
442pub(crate) fn resolve_database(
443    raw: &RawDatabase,
444    env: &str,
445    service_name: &str,
446    service_namespace: &str,
447) -> DatabaseSpec {
448    let overlay = raw.envs.get(env);
449    let shared = overlay.and_then(|o| o.shared).unwrap_or(raw.shared);
450    let engine = DatabaseEngine::parse(
451        overlay
452            .and_then(|o| o.engine.as_deref())
453            .unwrap_or(&raw.engine),
454    );
455    let version = overlay
456        .and_then(|o| o.version.clone())
457        .or_else(|| raw.version.clone())
458        .unwrap_or_else(|| default_db_version(engine));
459    let size = overlay
460        .and_then(|o| o.size.clone())
461        .or_else(|| raw.size.clone())
462        .unwrap_or_else(|| "2Gi".into());
463    let name = overlay
464        .and_then(|o| o.name.clone())
465        .or_else(|| raw.name.clone())
466        .unwrap_or_else(|| format!("{}-db", service_name));
467    let namespace = overlay
468        .and_then(|o| o.namespace.clone())
469        .or_else(|| raw.namespace.clone())
470        .unwrap_or_else(|| service_namespace.to_string());
471    let url_override = overlay.and_then(|o| o.url.clone());
472    DatabaseSpec {
473        engine,
474        version,
475        size,
476        shared,
477        name,
478        namespace,
479        url_override,
480    }
481}
482
483pub(crate) fn resolve_cache(
484    raw: &RawCache,
485    env: &str,
486    service_name: &str,
487    service_namespace: &str,
488) -> CacheSpec {
489    let overlay = raw.envs.get(env);
490    let shared = overlay.and_then(|o| o.shared).unwrap_or(raw.shared);
491    let engine = CacheEngine::parse(
492        overlay
493            .and_then(|o| o.engine.as_deref())
494            .unwrap_or(&raw.engine),
495    );
496    let size = overlay
497        .and_then(|o| o.size.clone())
498        .or_else(|| raw.size.clone())
499        .unwrap_or_else(|| "1Gi".into());
500    let name = overlay
501        .and_then(|o| o.name.clone())
502        .or_else(|| raw.name.clone())
503        .unwrap_or_else(|| format!("{}-cache", service_name));
504    let namespace = overlay
505        .and_then(|o| o.namespace.clone())
506        .or_else(|| raw.namespace.clone())
507        .unwrap_or_else(|| service_namespace.to_string());
508    let url_override = overlay.and_then(|o| o.url.clone());
509    CacheSpec {
510        engine,
511        size,
512        shared,
513        name,
514        namespace,
515        url_override,
516    }
517}
518
519pub(crate) fn resolve_secrets(raw: &RawSecrets) -> SecretsSpec {
520    SecretsSpec {
521        provider: SecretProvider::parse(&raw.provider),
522        required: raw.required.clone(),
523        map: raw.map.clone(),
524        external_store: raw.external_store.as_ref().map(|e| ExternalStore {
525            name: e.name.clone(),
526            kind: e.kind.clone(),
527        }),
528    }
529}
530
531pub(crate) fn resolve_migrations(raw: &RawMigrations) -> MigrationsSpec {
532    let tool = match raw.tool.as_str() {
533        "refinery" => MigrationTool::Refinery,
534        "flyway" => MigrationTool::Flyway,
535        "custom" => MigrationTool::Custom,
536        _ => MigrationTool::Sqlx,
537    };
538    let run_on = match raw.run_on.as_str() {
539        "boot" => MigrationRunOn::Boot,
540        "manual" => MigrationRunOn::Manual,
541        _ => MigrationRunOn::InitContainer,
542    };
543    let command = match (tool, &raw.command) {
544        (MigrationTool::Custom, Some(cmd)) => cmd.clone(),
545        (MigrationTool::Sqlx, _) => vec![
546            "sqlx".into(),
547            "migrate".into(),
548            "run".into(),
549            "--source".into(),
550            raw.dir.clone(),
551        ],
552        (MigrationTool::Refinery, _) => vec![
553            "refinery".into(),
554            "migrate".into(),
555            "-p".into(),
556            raw.dir.clone(),
557        ],
558        (MigrationTool::Flyway, _) => vec![
559            "flyway".into(),
560            "-locations=filesystem:".to_string() + &raw.dir,
561            "migrate".into(),
562        ],
563        (MigrationTool::Custom, None) => Vec::new(),
564    };
565    MigrationsSpec {
566        tool,
567        dir: raw.dir.clone(),
568        run_on,
569        command,
570    }
571}
572
573// ---------- emitted env vars ----------
574
575#[derive(Clone, Debug, Default)]
576pub struct EmittedEnv {
577    pub literals: Vec<(String, String)>,
578    pub from_secret: Vec<String>,
579}
580
581impl EmittedEnv {
582    pub fn extend_database(&mut self, spec: &DatabaseSpec, service_name: &str) {
583        self.extend_database_named("DATABASE", spec, service_name);
584    }
585
586    pub fn extend_database_named(
587        &mut self,
588        var_prefix: &str,
589        spec: &DatabaseSpec,
590        service_name: &str,
591    ) {
592        if matches!(spec.engine, DatabaseEngine::None) {
593            return;
594        }
595        self.literals
596            .push((format!("{var_prefix}_URL"), spec.url_template(service_name)));
597        if spec.url_override.is_none() {
598            self.from_secret.push(format!("{var_prefix}_PASSWORD"));
599        }
600    }
601
602    pub fn extend_cache(&mut self, spec: &CacheSpec) {
603        self.extend_cache_named("REDIS", spec);
604    }
605
606    pub fn extend_cache_named(&mut self, var_prefix: &str, spec: &CacheSpec) {
607        if matches!(spec.engine, CacheEngine::None) {
608            return;
609        }
610        self.literals
611            .push((format!("{var_prefix}_URL"), spec.url()));
612    }
613
614    pub fn extend_secrets(&mut self, spec: &SecretsSpec) {
615        for key in &spec.required {
616            self.from_secret.push(key.clone());
617        }
618    }
619}
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624
625    fn toml_to_raw_db(s: &str) -> RawDatabase {
626        toml::from_str::<toml::Value>(s)
627            .unwrap()
628            .get("database")
629            .unwrap()
630            .clone()
631            .try_into()
632            .unwrap()
633    }
634
635    #[test]
636    fn db_overlay_dev_wins_over_top_level() {
637        let toml = r#"
638            [database]
639            engine = "postgres"
640            shared = false
641            size = "10Gi"
642
643            [database.dev]
644            shared = true
645            name = "postgres"
646            namespace = "shared-dev"
647        "#;
648        let raw = toml_to_raw_db(toml);
649        let spec = resolve_database(&raw, "dev", "billing", "billing-ns");
650        assert!(spec.shared, "dev overlay forces shared=true");
651        assert_eq!(spec.name, "postgres");
652        assert_eq!(spec.namespace, "shared-dev");
653        assert_eq!(spec.engine, DatabaseEngine::Postgres);
654        assert_eq!(spec.size, "10Gi");
655    }
656
657    #[test]
658    fn db_prod_uses_owned_defaults() {
659        let toml = r#"
660            [database]
661            engine = "postgres"
662            shared = false
663            size = "10Gi"
664
665            [database.dev]
666            shared = true
667            name = "postgres"
668            namespace = "shared-dev"
669        "#;
670        let raw = toml_to_raw_db(toml);
671        let spec = resolve_database(&raw, "prod", "billing", "billing-ns");
672        assert!(!spec.shared);
673        assert_eq!(spec.name, "billing-db");
674        assert_eq!(spec.namespace, "billing-ns");
675        assert_eq!(spec.size, "10Gi");
676    }
677
678    #[test]
679    fn db_unknown_env_falls_back_to_top_level() {
680        let toml = r#"
681            [database]
682            engine = "postgres"
683        "#;
684        let raw = toml_to_raw_db(toml);
685        let spec = resolve_database(&raw, "staging", "audit", "audit");
686        assert!(!spec.shared);
687        assert_eq!(spec.engine, DatabaseEngine::Postgres);
688    }
689
690    #[test]
691    fn db_emits_url_and_password_secret() {
692        let toml = r#"
693            [database]
694            engine = "postgres"
695            shared = false
696        "#;
697        let raw = toml_to_raw_db(toml);
698        let spec = resolve_database(&raw, "prod", "billing", "shop");
699        let mut env = EmittedEnv::default();
700        env.extend_database(&spec, "billing");
701        assert_eq!(env.literals.len(), 1);
702        assert_eq!(env.literals[0].0, "DATABASE_URL");
703        assert!(env.literals[0].1.starts_with(
704            "postgres://billing:$DATABASE_PASSWORD@billing-db.shop.svc.cluster.local:5432/billing"
705        ));
706        assert_eq!(env.from_secret, vec!["DATABASE_PASSWORD".to_string()]);
707    }
708
709    #[test]
710    fn cache_shared_overlay() {
711        let toml = r#"
712            [cache]
713            engine = "redis"
714            shared = false
715
716            [cache.dev]
717            shared = true
718            name = "redis"
719            namespace = "shared-dev"
720        "#;
721        let raw: RawCache = toml::from_str::<toml::Value>(toml)
722            .unwrap()
723            .get("cache")
724            .unwrap()
725            .clone()
726            .try_into()
727            .unwrap();
728        let spec = resolve_cache(&raw, "dev", "billing", "shop");
729        assert!(spec.shared);
730        assert_eq!(spec.name, "redis");
731        assert_eq!(spec.namespace, "shared-dev");
732        assert_eq!(
733            spec.url(),
734            "redis://redis.shared-dev.svc.cluster.local:6379"
735        );
736    }
737
738    #[test]
739    fn secrets_default_provider_is_k8s() {
740        let raw = RawSecrets {
741            provider: default_secret_provider(),
742            required: vec!["JWT_SIGNING_KEY".into()],
743            map: Default::default(),
744            external_store: None,
745        };
746        let spec = resolve_secrets(&raw);
747        assert_eq!(spec.provider, SecretProvider::K8s);
748        assert_eq!(spec.required, vec!["JWT_SIGNING_KEY".to_string()]);
749    }
750
751    #[test]
752    fn migrations_sqlx_command_default() {
753        let raw = RawMigrations {
754            tool: "sqlx".into(),
755            dir: default_migrations_dir(),
756            run_on: default_run_on(),
757            command: None,
758        };
759        let spec = resolve_migrations(&raw);
760        assert_eq!(spec.tool, MigrationTool::Sqlx);
761        assert_eq!(spec.run_on, MigrationRunOn::InitContainer);
762        assert_eq!(
763            spec.command,
764            vec!["sqlx", "migrate", "run", "--source", "migrations/"]
765        );
766    }
767
768    #[test]
769    fn migrations_custom_requires_command() {
770        let raw = RawMigrations {
771            tool: "custom".into(),
772            dir: "migrations/".into(),
773            run_on: "init-container".into(),
774            command: Some(vec!["./migrate.sh".into(), "--all".into()]),
775        };
776        let spec = resolve_migrations(&raw);
777        assert_eq!(spec.tool, MigrationTool::Custom);
778        assert_eq!(spec.command, vec!["./migrate.sh", "--all"]);
779    }
780
781    fn parse_callers(toml_str: &str) -> RawCallers {
782        #[derive(serde::Deserialize)]
783        struct Wrapper {
784            callers: RawCallers,
785        }
786        toml::from_str::<Wrapper>(toml_str).unwrap().callers
787    }
788
789    #[test]
790    fn callers_base_only_no_overlay() {
791        let raw = parse_callers(
792            r#"
793            [callers]
794            gateway         = "agnitiv"
795            zradar-platform = "agnitiv"
796        "#,
797        );
798        let callers = resolve_callers(&raw, "dev");
799        assert_eq!(callers.len(), 2);
800        assert!(callers.iter().all(|c| c.namespace == "agnitiv"));
801    }
802
803    #[test]
804    fn callers_dev_overlay_overrides_namespace() {
805        let raw = parse_callers(
806            r#"
807            [callers]
808            gateway         = "agnitiv"
809            zradar-platform = "agnitiv"
810
811            [callers.dev]
812            gateway         = "agnitiv-dev"
813            zradar-platform = "agnitiv-dev"
814        "#,
815        );
816        let dev = resolve_callers(&raw, "dev");
817        assert!(
818            dev.iter().all(|c| c.namespace == "agnitiv-dev"),
819            "dev overlay must win"
820        );
821        let prod = resolve_callers(&raw, "prod");
822        assert!(
823            prod.iter().all(|c| c.namespace == "agnitiv"),
824            "prod falls back to base"
825        );
826    }
827
828    #[test]
829    fn callers_dev_overlay_adds_new_caller() {
830        let raw = parse_callers(
831            r#"
832            [callers]
833            gateway = "agnitiv"
834
835            [callers.dev]
836            gateway    = "agnitiv-dev"
837            debug-tool = "agnitiv-dev"
838        "#,
839        );
840        let dev = resolve_callers(&raw, "dev");
841        assert_eq!(dev.len(), 2, "overlay adds debug-tool");
842        let prod = resolve_callers(&raw, "prod");
843        assert_eq!(prod.len(), 1, "prod sees base only");
844    }
845
846    #[test]
847    fn db_dev_url_override_used_verbatim() {
848        let toml = r#"
849            [database]
850            engine = "postgres"
851
852            [database.dev]
853            shared = true
854            url    = "postgresql://postgres:postgres@shared.svc:5432/mydb"
855        "#;
856        let raw = toml_to_raw_db(toml);
857        let spec = resolve_database(&raw, "dev", "identity", "agnitiv");
858        assert_eq!(
859            spec.url_template("identity"),
860            "postgresql://postgres:postgres@shared.svc:5432/mydb"
861        );
862        assert!(spec.url_override.is_some());
863    }
864
865    #[test]
866    fn db_prod_no_url_override_keeps_template() {
867        let toml = r#"
868            [database]
869            engine = "postgres"
870
871            [database.dev]
872            shared = true
873            url    = "postgresql://postgres:postgres@shared.svc:5432/mydb"
874        "#;
875        let raw = toml_to_raw_db(toml);
876        let spec = resolve_database(&raw, "prod", "identity", "agnitiv");
877        assert!(spec.url_override.is_none());
878        assert!(
879            spec.url_template("identity").contains("$DATABASE_PASSWORD"),
880            "prod uses password-template URL"
881        );
882    }
883
884    #[test]
885    fn emitted_env_skips_password_when_url_override_set() {
886        let toml = r#"
887            [database]
888            engine = "postgres"
889
890            [database.dev]
891            shared = true
892            url    = "postgresql://postgres:postgres@shared.svc:5432/mydb"
893        "#;
894        let raw = toml_to_raw_db(toml);
895        let spec = resolve_database(&raw, "dev", "identity", "agnitiv");
896        let mut env = EmittedEnv::default();
897        env.extend_database(&spec, "identity");
898        assert!(
899            !env.from_secret.iter().any(|s| s == "DATABASE_PASSWORD"),
900            "url_override must suppress DATABASE_PASSWORD secret injection"
901        );
902        assert!(
903            env.literals
904                .iter()
905                .any(|(k, v)| k == "DATABASE_URL" && v.contains("shared.svc"))
906        );
907    }
908
909    #[test]
910    fn emitted_env_injects_password_when_no_url_override() {
911        let toml = r#"
912            [database]
913            engine = "postgres"
914        "#;
915        let raw = toml_to_raw_db(toml);
916        let spec = resolve_database(&raw, "prod", "identity", "agnitiv");
917        let mut env = EmittedEnv::default();
918        env.extend_database(&spec, "identity");
919        assert!(env.from_secret.iter().any(|s| s == "DATABASE_PASSWORD"));
920    }
921
922    #[test]
923    fn named_database_emits_prefixed_vars() {
924        #[derive(serde::Deserialize)]
925        struct Wrapper {
926            databases: std::collections::BTreeMap<String, RawDatabase>,
927        }
928        let toml = r#"
929            [databases.write]
930            engine = "postgres"
931
932            [databases.write.dev]
933            shared = true
934            url = "postgresql://postgres:postgres@shared.svc:5432/app_dev"
935        "#;
936        let w: Wrapper = toml::from_str(toml).unwrap();
937        let spec = resolve_database(w.databases.get("write").unwrap(), "dev", "app", "agnitiv");
938        let mut env = EmittedEnv::default();
939        env.extend_database_named("WRITE_DATABASE", &spec, "app");
940        assert!(env.literals.iter().any(|(k, _)| k == "WRITE_DATABASE_URL"));
941        assert!(
942            !env.from_secret
943                .iter()
944                .any(|s| s == "WRITE_DATABASE_PASSWORD")
945        );
946    }
947
948    #[test]
949    fn named_cache_emits_prefixed_var() {
950        let toml = r#"
951            [cache]
952            engine = "redis"
953
954            [cache.dev]
955            shared = true
956            url    = "redis://redis.shared-dev.svc:6379"
957        "#;
958        let raw: RawCache = toml::from_str::<toml::Value>(toml)
959            .unwrap()
960            .get("cache")
961            .unwrap()
962            .clone()
963            .try_into()
964            .unwrap();
965        let spec = resolve_cache(&raw, "dev", "identity", "agnitiv");
966        let mut env = EmittedEnv::default();
967        env.extend_cache_named("SESSION_REDIS", &spec);
968        assert!(
969            env.literals
970                .iter()
971                .any(|(k, v)| k == "SESSION_REDIS_URL" && v.contains("shared-dev"))
972        );
973    }
974
975    #[test]
976    fn env_selection_precedence() {
977        unsafe { std::env::set_var("TONIN_ENV", "staging") };
978        assert_eq!(select_env(Some("prod")), "prod");
979        assert_eq!(select_env(None), "staging");
980        unsafe { std::env::remove_var("TONIN_ENV") };
981        assert_eq!(select_env(None), "dev");
982    }
983}