Skip to main content

tonin_plugin/
plan.rs

1//! Plan: typed deployment description loaded from `tonin.toml`.
2
3use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7
8use crate::stateful::{
9    self, CacheSpec, ConfigSpec, DatabaseSpec, EmittedEnv, MigrationsSpec, RawCache, RawCallers,
10    RawConfigBlock, RawDatabase, RawMigrations, RawSecrets, SecretsSpec,
11};
12
13#[derive(Debug, thiserror::Error)]
14pub enum Error {
15    #[error("reading {0}: {1}")]
16    Io(PathBuf, #[source] std::io::Error),
17    #[error("parsing {0}: {1}")]
18    Toml(PathBuf, #[source] toml::de::Error),
19    #[error(
20        "{path}: schema = {found:?} is not supported by this CLI. \
21         Supported schemas: {supported:?}. \
22         Upgrade the CLI, or set `schema = \"{current}\"` at the top of tonin.toml."
23    )]
24    UnsupportedSchema {
25        path: PathBuf,
26        found: String,
27        supported: Vec<String>,
28        current: String,
29    },
30    #[error("depends_on.{name}: {reason}")]
31    InvalidDependency { name: String, reason: String },
32    #[error(
33        "{context}: namespace {value:?} has an unresolved placeholder \
34         (only `{{env}}` is supported)"
35    )]
36    UnresolvedNamespace { context: String, value: String },
37}
38
39#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
40#[serde(rename_all = "lowercase")]
41pub enum Mesh {
42    #[default]
43    Cilium,
44    Istio,
45    Linkerd,
46    None,
47}
48
49impl Mesh {
50    pub fn as_str(&self) -> &'static str {
51        match self {
52            Mesh::Cilium => "cilium",
53            Mesh::Istio => "istio",
54            Mesh::Linkerd => "linkerd",
55            Mesh::None => "none",
56        }
57    }
58}
59
60#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
61pub struct ServiceRef {
62    pub name: String,
63    pub namespace: String,
64}
65
66impl ServiceRef {
67    pub fn identity(&self) -> String {
68        format!("{}.{}", self.name, self.namespace)
69    }
70}
71
72/// One `[depends_on]` entry parsed from TOML, before environment resolution.
73///
74/// Both the shorthand (`name = "<ns>"`) and the table form
75/// (`name = { namespace = "<ns>", <env> = "<ns>", envs = [..] }`) land here.
76struct DepSpec {
77    /// Default namespace pattern (may contain `{env}`), if declared.
78    namespace: Option<String>,
79    /// Per-env namespace overrides (env name → pattern).
80    env_overrides: BTreeMap<String, String>,
81    /// Restrict the dependency to these envs; `None` ⇒ every env.
82    envs: Option<Vec<String>>,
83}
84
85/// Substitute the `{env}` placeholder in a namespace pattern.
86fn apply_env(pattern: &str, env: &str) -> String {
87    pattern.replace("{env}", env)
88}
89
90/// Reject a namespace that still carries an unresolved `{...}` placeholder.
91/// `{env}` is the only supported token, so anything left over is a typo.
92fn ensure_resolved(context: &str, value: &str) -> Result<(), Error> {
93    if value.contains('{') || value.contains('}') {
94        return Err(Error::UnresolvedNamespace {
95            context: context.to_string(),
96            value: value.to_string(),
97        });
98    }
99    Ok(())
100}
101
102/// Parse a single `[depends_on]` entry (string shorthand or table form).
103fn parse_dependency(name: &str, value: toml::Value) -> Result<DepSpec, Error> {
104    let invalid = |reason: String| Error::InvalidDependency {
105        name: name.to_string(),
106        reason,
107    };
108    match value {
109        toml::Value::String(s) => Ok(DepSpec {
110            namespace: Some(s),
111            env_overrides: BTreeMap::new(),
112            envs: None,
113        }),
114        toml::Value::Table(table) => {
115            let mut namespace = None;
116            let mut envs = None;
117            let mut env_overrides = BTreeMap::new();
118            for (key, val) in table {
119                match key.as_str() {
120                    "namespace" => {
121                        namespace = Some(
122                            val.as_str()
123                                .ok_or_else(|| invalid("`namespace` must be a string".into()))?
124                                .to_string(),
125                        );
126                    }
127                    "envs" => {
128                        let arr = val
129                            .as_array()
130                            .ok_or_else(|| invalid("`envs` must be an array of strings".into()))?;
131                        let mut list = Vec::with_capacity(arr.len());
132                        for item in arr {
133                            list.push(
134                                item.as_str()
135                                    .ok_or_else(|| {
136                                        invalid("`envs` must be an array of strings".into())
137                                    })?
138                                    .to_string(),
139                            );
140                        }
141                        envs = Some(list);
142                    }
143                    // Any other key is a per-env namespace override (`prod = "..."`).
144                    other => {
145                        let ns = val.as_str().ok_or_else(|| {
146                            invalid(format!("override `{other}` must be a namespace string"))
147                        })?;
148                        env_overrides.insert(other.to_string(), ns.to_string());
149                    }
150                }
151            }
152            Ok(DepSpec {
153                namespace,
154                env_overrides,
155                envs,
156            })
157        }
158        other => Err(invalid(format!(
159            "expected a namespace string or a table, got {}",
160            other.type_str()
161        ))),
162    }
163}
164
165/// Resolve `[depends_on]` for one environment into concrete egress targets.
166///
167/// - A dependency whose `envs` whitelist excludes `env` is dropped.
168/// - The namespace is the per-env override if present, else the default,
169///   with `{env}` substituted.
170/// - `@inherit` drops the entry from rendered output (the namespace is
171///   supplied at deploy time / by GitOps).
172/// - An active dependency with no resolvable namespace — or one left with an
173///   unresolved placeholder — is a hard error. There is no silent fallback to
174///   a base value, which is what let a dev namespace leak into prod before.
175fn resolve_depends_on(
176    raw: BTreeMap<String, toml::Value>,
177    env: &str,
178) -> Result<Vec<ServiceRef>, Error> {
179    let mut out = Vec::new();
180    for (name, value) in raw {
181        let spec = parse_dependency(&name, value)?;
182        if let Some(envs) = &spec.envs
183            && !envs.iter().any(|e| e == env)
184        {
185            continue; // not active in this environment
186        }
187        let Some(pattern) = spec.env_overrides.get(env).or(spec.namespace.as_ref()) else {
188            return Err(Error::InvalidDependency {
189                name: name.clone(),
190                reason: format!(
191                    "has no namespace for env '{env}' \
192                     (set {name}.{env}, use \"{{env}}\", or mark \"@inherit\")"
193                ),
194            });
195        };
196        let resolved = apply_env(pattern, env);
197        if resolved == "@inherit" {
198            continue; // namespace owned by the deploy layer; omit egress entry
199        }
200        ensure_resolved(&format!("depends_on.{name}"), &resolved)?;
201        if resolved.is_empty() {
202            return Err(Error::InvalidDependency {
203                name: name.clone(),
204                reason: format!("namespace for env '{env}' is empty"),
205            });
206        }
207        out.push(ServiceRef {
208            name,
209            namespace: resolved,
210        });
211    }
212    Ok(out)
213}
214
215// ---------- on-disk TOML shape ----------
216
217/// The schema version this CLI knows how to read.
218pub const CURRENT_SCHEMA: &str = "v1";
219pub const SUPPORTED_SCHEMAS: &[&str] = &["v1"];
220
221/// Minimum `tonin` CLI version that can fully render all `tonin.toml`
222/// features exposed by this version of `tonin-plugin`.
223///
224/// The CLI checks this at `tonin k8s generate` / `tonin helm generate` time
225/// and emits a warning (never an error) when it is older. Services continue
226/// to work — the check is advisory so teams can upgrade at their own pace.
227///
228/// Bump this constant (in the same commit) whenever a new `tonin.toml`
229/// section or field is added that older CLI versions would silently ignore.
230///
231/// 0.6.0: per-environment namespaces and dependencies (`{env}` placeholders
232/// and the Cargo-style `[depends_on]` table form) — a CLI older than this
233/// can't render them.
234pub const RECOMMENDED_CLI_MIN: &str = "0.6.0";
235
236#[derive(Debug, Deserialize)]
237struct RawConfig {
238    #[serde(default)]
239    schema: Option<String>,
240    service: RawService,
241    deploy: RawDeploy,
242    resources: RawResources,
243    #[serde(default)]
244    autoscale: Option<RawAutoscale>,
245    // String shorthand (`name = "<ns>"`) or table form
246    // (`name = { namespace = "<ns>", <env> = "<ns>", envs = [..] }`).
247    // Parsed as raw values and interpreted by `resolve_depends_on`.
248    #[serde(default)]
249    depends_on: BTreeMap<String, toml::Value>,
250    #[serde(default)]
251    callers: RawCallers,
252    #[serde(default)]
253    database: Option<RawDatabase>,
254    #[serde(default)]
255    databases: std::collections::BTreeMap<String, RawDatabase>,
256    #[serde(default)]
257    cache: Option<RawCache>,
258    #[serde(default)]
259    caches: std::collections::BTreeMap<String, RawCache>,
260    #[serde(default)]
261    secrets: Option<RawSecrets>,
262    #[serde(default)]
263    migrations: Option<RawMigrations>,
264    #[serde(default)]
265    config: Option<RawConfigBlock>,
266    #[serde(default)]
267    client: Option<RawClientConfig>,
268}
269
270#[derive(Debug, Deserialize)]
271struct RawService {
272    name: String,
273    version: String,
274    #[serde(default)]
275    language: Option<String>,
276    #[serde(default, rename = "type")]
277    kind: Option<String>,
278    #[serde(default)]
279    web_mode: Option<String>,
280    #[serde(default)]
281    #[allow(dead_code)]
282    codec: Option<String>,
283    /// Explicit listen port. Optional — defaults per kind when unset
284    /// (web: 8080/3000 by mode, http: 8080, gRPC backend: 50051).
285    #[serde(default)]
286    port: Option<u32>,
287    /// HTTP health-probe config (`[service.health]`).
288    #[serde(default)]
289    health: Option<RawHealth>,
290    /// Additional HTTP endpoint (`[service.http]`). Lets a gRPC `backend` ALSO
291    /// serve HTTP (health/metrics/admin) — the two are not mutually exclusive.
292    #[serde(default)]
293    http: Option<RawHttpEndpoint>,
294}
295
296#[derive(Debug, Deserialize)]
297struct RawHealth {
298    #[serde(default)]
299    path: Option<String>,
300    #[serde(default)]
301    port: Option<u32>,
302}
303
304#[derive(Debug, Deserialize)]
305struct RawHttpEndpoint {
306    port: u32,
307    #[serde(default)]
308    health_path: Option<String>,
309}
310
311#[derive(Debug, Deserialize)]
312struct RawDeploy {
313    replicas: u32,
314    #[serde(default)]
315    mesh: Option<Mesh>,
316    #[serde(default = "default_true")]
317    mcp_sidecar: bool,
318    namespace: String,
319    #[serde(default)]
320    expose: Option<String>,
321    #[serde(default, flatten)]
322    envs: std::collections::BTreeMap<String, RawDeployEnv>,
323}
324
325#[derive(Debug, Deserialize, Default)]
326struct RawDeployEnv {
327    #[serde(default)]
328    replicas: Option<u32>,
329    #[serde(default)]
330    namespace: Option<String>,
331    #[serde(default)]
332    mesh: Option<Mesh>,
333    #[serde(default)]
334    mcp_sidecar: Option<bool>,
335    #[serde(default)]
336    expose: Option<String>,
337}
338
339#[derive(Debug, Deserialize)]
340struct RawResources {
341    cpu: String,
342    memory: String,
343}
344
345#[derive(Debug, Deserialize)]
346struct RawAutoscale {
347    max_replicas: u32,
348}
349
350fn default_true() -> bool {
351    true
352}
353
354#[derive(Debug, Default, Deserialize)]
355struct RawClientConfig {
356    #[serde(default = "default_true")]
357    coalesce: bool,
358    #[serde(default)]
359    cache: std::collections::BTreeMap<String, RawMethodCacheConfig>,
360}
361
362#[derive(Debug, Deserialize)]
363struct RawMethodCacheConfig {
364    ttl_ms: u64,
365    #[serde(default = "default_cache_capacity")]
366    capacity: usize,
367}
368
369fn default_cache_capacity() -> usize {
370    1_000
371}
372
373#[derive(Clone, Debug, Serialize)]
374pub struct MethodCacheSpec {
375    pub ttl_ms: u64,
376    pub capacity: usize,
377}
378
379#[derive(Clone, Debug, Serialize)]
380pub struct ClientSpec {
381    pub coalesce: bool,
382    pub caches: Vec<(String, MethodCacheSpec)>,
383}
384
385impl Default for ClientSpec {
386    fn default() -> Self {
387        Self {
388            coalesce: true,
389            caches: Vec::new(),
390        }
391    }
392}
393
394// ---------- normalized Plan ----------
395
396#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
397#[serde(rename_all = "lowercase")]
398pub enum ServiceKind {
399    Backend,
400    Web,
401    Http,
402}
403
404impl ServiceKind {
405    pub fn as_str(&self) -> &'static str {
406        match self {
407            ServiceKind::Backend => "backend",
408            ServiceKind::Web => "web",
409            ServiceKind::Http => "http",
410        }
411    }
412    pub fn is_web(&self) -> bool {
413        matches!(self, ServiceKind::Web)
414    }
415    pub fn is_http(&self) -> bool {
416        matches!(self, ServiceKind::Http)
417    }
418}
419
420/// Resolved HTTP health-probe configuration (`[service.health]`).
421#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
422pub struct HealthSpec {
423    pub path: String,
424    pub port: u32,
425}
426
427#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
428#[serde(rename_all = "lowercase")]
429pub enum WebMode {
430    Spa,
431    Bff,
432}
433
434impl WebMode {
435    pub fn as_str(&self) -> &'static str {
436        match self {
437            WebMode::Spa => "spa",
438            WebMode::Bff => "bff",
439        }
440    }
441    pub fn container_port(&self) -> u32 {
442        match self {
443            WebMode::Spa => 8080,
444            WebMode::Bff => 3000,
445        }
446    }
447}
448
449#[derive(Clone, Debug)]
450pub struct Plan {
451    pub name: String,
452    pub version: String,
453    pub language: String,
454    pub kind: ServiceKind,
455    pub web_mode: Option<WebMode>,
456    /// Effective primary container/listen port (`[service].port` or per-kind default).
457    pub port: u32,
458    /// Additional HTTP port, when a gRPC backend also serves HTTP (`[service.http]`).
459    pub http_port: Option<u32>,
460    /// HTTP health probe, when an HTTP surface exists (always for http; opt-in otherwise).
461    pub health: Option<HealthSpec>,
462    pub namespace: String,
463    pub mesh: Mesh,
464    pub replicas: u32,
465    pub max_replicas: u32,
466    pub mcp_sidecar: bool,
467    pub expose: Option<String>,
468    pub cpu: String,
469    pub memory: String,
470    pub image: String,
471    pub depends_on: Vec<ServiceRef>,
472    pub callers: Vec<ServiceRef>,
473    pub dir: PathBuf,
474    pub database: Option<DatabaseSpec>,
475    pub named_databases: Vec<(String, DatabaseSpec)>,
476    pub cache: Option<CacheSpec>,
477    pub named_caches: Vec<(String, CacheSpec)>,
478    pub secrets: Option<SecretsSpec>,
479    pub migrations: Option<MigrationsSpec>,
480    pub config: Option<ConfigSpec>,
481    pub emitted_env: EmittedEnv,
482    pub selected_env: String,
483    pub client: ClientSpec,
484}
485
486impl Plan {
487    pub fn load(toml_path: &Path) -> Result<Self, Error> {
488        Self::load_with_env(toml_path, &stateful::select_env(None))
489    }
490
491    pub fn load_with_env(toml_path: &Path, env: &str) -> Result<Self, Error> {
492        let raw_str = std::fs::read_to_string(toml_path)
493            .map_err(|e| Error::Io(toml_path.to_path_buf(), e))?;
494        let raw: RawConfig =
495            toml::from_str(&raw_str).map_err(|e| Error::Toml(toml_path.to_path_buf(), e))?;
496
497        if let Some(v) = raw.schema.as_deref()
498            && !SUPPORTED_SCHEMAS.contains(&v)
499        {
500            return Err(Error::UnsupportedSchema {
501                path: toml_path.to_path_buf(),
502                found: v.to_string(),
503                supported: SUPPORTED_SCHEMAS.iter().map(|s| s.to_string()).collect(),
504                current: CURRENT_SCHEMA.to_string(),
505            });
506        }
507
508        let depends_on = resolve_depends_on(raw.depends_on, env)?;
509
510        let explicit_callers = stateful::resolve_callers(&raw.callers, env);
511
512        let deploy_overlay = raw.deploy.envs.get(env);
513        let deploy_replicas = deploy_overlay
514            .and_then(|o| o.replicas)
515            .unwrap_or(raw.deploy.replicas);
516        let deploy_namespace = {
517            let raw_ns = deploy_overlay
518                .and_then(|o| o.namespace.clone())
519                .unwrap_or(raw.deploy.namespace);
520            let ns = apply_env(&raw_ns, env);
521            ensure_resolved("deploy.namespace", &ns)?;
522            ns
523        };
524        let deploy_mesh = deploy_overlay
525            .and_then(|o| o.mesh)
526            .or(raw.deploy.mesh)
527            .unwrap_or_default();
528        let deploy_mcp_sidecar = deploy_overlay
529            .and_then(|o| o.mcp_sidecar)
530            .unwrap_or(raw.deploy.mcp_sidecar);
531        let deploy_expose = deploy_overlay
532            .and_then(|o| o.expose.clone())
533            .or(raw.deploy.expose);
534
535        let max_replicas = raw
536            .autoscale
537            .as_ref()
538            .map(|a| a.max_replicas)
539            .unwrap_or(deploy_replicas);
540
541        let dir = toml_path
542            .parent()
543            .map(Path::to_path_buf)
544            .unwrap_or_else(|| PathBuf::from("."));
545
546        let image = std::env::var("TONIN_IMAGE_PREFIX")
547            .map(|prefix| format!("{prefix}/{}:{}", raw.service.name, raw.service.version))
548            .unwrap_or_else(|_| format!("micro/{}:{}", raw.service.name, raw.service.version));
549
550        let kind = match raw.service.kind.as_deref() {
551            Some("web") => ServiceKind::Web,
552            Some("http") => ServiceKind::Http,
553            _ => ServiceKind::Backend,
554        };
555        let web_mode = match (kind, raw.service.web_mode.as_deref()) {
556            (ServiceKind::Web, Some("bff")) => Some(WebMode::Bff),
557            (ServiceKind::Web, _) => Some(WebMode::Spa),
558            _ => None,
559        };
560
561        // Effective listen port. Explicit `[service].port` wins; otherwise the
562        // per-kind default — preserving pre-port-field output for web/backend.
563        let port = raw.service.port.unwrap_or_else(|| match kind {
564            ServiceKind::Web => web_mode.map(|m| m.container_port()).unwrap_or(8080),
565            ServiceKind::Http => 8080,
566            ServiceKind::Backend => 50051,
567        });
568
569        // Additional HTTP port for a gRPC backend that also serves HTTP
570        // (`[service.http]`). None for http-primary (its primary port is already
571        // HTTP) and for services with no secondary endpoint.
572        let http_port = match kind {
573            ServiceKind::Http => None,
574            _ => raw.service.http.as_ref().map(|h| h.port),
575        };
576
577        // Port an HTTP health probe targets: the http-primary port, else the
578        // secondary HTTP port. None ⇒ no HTTP surface ⇒ no httpGet probe.
579        let http_probe_port = match kind {
580            ServiceKind::Http => Some(port),
581            _ => http_port,
582        };
583
584        // HTTP health probe. Present when there is an HTTP surface (http kind or
585        // a `[service.http]` endpoint) or when `[service.health]` is declared.
586        // Path: `[service.health].path`, else `[service.http].health_path`, else
587        // `/health`. Backend/web without any of these get no probe, so existing
588        // manifests stay byte-identical.
589        let health = if raw.service.health.is_some() || http_probe_port.is_some() {
590            let declared = raw.service.health.as_ref();
591            let secondary = raw.service.http.as_ref();
592            Some(HealthSpec {
593                path: declared
594                    .and_then(|h| h.path.clone())
595                    .or_else(|| secondary.and_then(|h| h.health_path.clone()))
596                    .unwrap_or_else(|| "/health".into()),
597                port: declared
598                    .and_then(|h| h.port)
599                    .or(http_probe_port)
600                    .unwrap_or(port),
601            })
602        } else {
603            None
604        };
605
606        // MCP sidecar proxies to a gRPC server on :50051, so it cannot front an
607        // HTTP-primary service — force it off for kind = http.
608        let mcp_sidecar = deploy_mcp_sidecar && !matches!(kind, ServiceKind::Http);
609
610        let svc_name = raw.service.name.clone();
611        let svc_ns = deploy_namespace.clone();
612        let database = raw
613            .database
614            .as_ref()
615            .map(|r| stateful::resolve_database(r, env, &svc_name, &svc_ns));
616        let named_databases: Vec<(String, DatabaseSpec)> = raw
617            .databases
618            .iter()
619            .map(|(name, r)| {
620                (
621                    name.clone(),
622                    stateful::resolve_database(r, env, &svc_name, &svc_ns),
623                )
624            })
625            .collect();
626        let cache = raw
627            .cache
628            .as_ref()
629            .map(|r| stateful::resolve_cache(r, env, &svc_name, &svc_ns));
630        let named_caches: Vec<(String, CacheSpec)> = raw
631            .caches
632            .iter()
633            .map(|(name, r)| {
634                (
635                    name.clone(),
636                    stateful::resolve_cache(r, env, &svc_name, &svc_ns),
637                )
638            })
639            .collect();
640        let secrets = raw.secrets.as_ref().map(stateful::resolve_secrets);
641        let migrations = raw.migrations.as_ref().map(stateful::resolve_migrations);
642        let config = raw.config.as_ref().map(stateful::resolve_config);
643
644        let client = raw
645            .client
646            .map(|c| {
647                let mut caches: Vec<(String, MethodCacheSpec)> = c
648                    .cache
649                    .into_iter()
650                    .map(|(method, mc)| {
651                        (
652                            method,
653                            MethodCacheSpec {
654                                ttl_ms: mc.ttl_ms,
655                                capacity: mc.capacity,
656                            },
657                        )
658                    })
659                    .collect();
660                caches.sort_by(|a, b| a.0.cmp(&b.0));
661                ClientSpec {
662                    coalesce: c.coalesce,
663                    caches,
664                }
665            })
666            .unwrap_or_default();
667
668        let mut emitted_env = EmittedEnv::default();
669        if let Some(d) = &database {
670            emitted_env.extend_database(d, &svc_name);
671        }
672        for (name, d) in &named_databases {
673            let prefix = format!("{}_DATABASE", name.to_uppercase());
674            emitted_env.extend_database_named(&prefix, d, &svc_name);
675        }
676        if let Some(c) = &cache {
677            emitted_env.extend_cache(c);
678        }
679        for (name, c) in &named_caches {
680            let prefix = format!("{}_REDIS", name.to_uppercase());
681            emitted_env.extend_cache_named(&prefix, c);
682        }
683        if let Some(s) = &secrets {
684            emitted_env.extend_secrets(s);
685        }
686
687        Ok(Plan {
688            name: raw.service.name,
689            version: raw.service.version,
690            language: raw.service.language.unwrap_or_else(|| "rust".into()),
691            kind,
692            web_mode,
693            port,
694            http_port,
695            health,
696            namespace: deploy_namespace,
697            mesh: deploy_mesh,
698            replicas: deploy_replicas,
699            max_replicas,
700            mcp_sidecar,
701            expose: deploy_expose,
702            cpu: raw.resources.cpu,
703            memory: raw.resources.memory,
704            image,
705            depends_on,
706            callers: explicit_callers,
707            dir,
708            database,
709            named_databases,
710            cache,
711            named_caches,
712            secrets,
713            migrations,
714            config,
715            client,
716            emitted_env,
717            selected_env: env.to_string(),
718        })
719    }
720
721    pub fn load_workspace(root: &Path) -> Result<Vec<Plan>, Error> {
722        Self::load_workspace_with_env(root, &stateful::select_env(None))
723    }
724
725    pub fn load_workspace_with_env(root: &Path, env: &str) -> Result<Vec<Plan>, Error> {
726        let mut plans: Vec<Plan> = walkdir::WalkDir::new(root)
727            .into_iter()
728            .filter_map(|e| e.ok())
729            .filter(|e| e.file_name() == "tonin.toml")
730            .map(|e| Plan::load_with_env(e.path(), env))
731            .collect::<Result<_, _>>()?;
732
733        let snapshot: Vec<(String, String, Vec<ServiceRef>)> = plans
734            .iter()
735            .map(|p| (p.name.clone(), p.namespace.clone(), p.depends_on.clone()))
736            .collect();
737        for plan in plans.iter_mut() {
738            for (caller_name, caller_ns, deps) in &snapshot {
739                if deps
740                    .iter()
741                    .any(|d| d.name == plan.name && d.namespace == plan.namespace)
742                {
743                    plan.callers.push(ServiceRef {
744                        name: caller_name.clone(),
745                        namespace: caller_ns.clone(),
746                    });
747                }
748            }
749            plan.callers.sort();
750            plan.callers.dedup();
751        }
752
753        plans.sort_by(|a, b| a.name.cmp(&b.name));
754        Ok(plans)
755    }
756}
757
758#[cfg(test)]
759mod tests {
760    use super::*;
761    use std::sync::atomic::{AtomicU32, Ordering};
762
763    static COUNTER: AtomicU32 = AtomicU32::new(0);
764
765    const BASE: &str = "\n[deploy]\nreplicas = 1\nnamespace = \"demo\"\n\n[resources]\ncpu = \"100m\"\nmemory = \"128Mi\"\n";
766
767    /// Write `<service block> + BASE` to a temp tonin.toml and load it.
768    fn load(service: &str) -> Plan {
769        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
770        let dir = std::env::temp_dir().join(format!("tonin-plan-test-{}-{n}", std::process::id()));
771        std::fs::create_dir_all(&dir).unwrap();
772        let path = dir.join("tonin.toml");
773        std::fs::write(&path, format!("{service}{BASE}")).unwrap();
774        let plan = Plan::load_with_env(&path, "prod").unwrap();
775        let _ = std::fs::remove_dir_all(&dir);
776        plan
777    }
778
779    #[test]
780    fn backend_defaults_unchanged() {
781        let p = load("[service]\nname = \"svc\"\nversion = \"0.1.0\"");
782        assert_eq!(p.kind, ServiceKind::Backend);
783        assert_eq!(p.port, 50051);
784        assert_eq!(p.http_port, None);
785        assert!(p.health.is_none());
786        assert!(p.mcp_sidecar, "backend keeps the default mcp sidecar");
787    }
788
789    #[test]
790    fn backend_port_override() {
791        let p = load("[service]\nname = \"svc\"\nversion = \"0.1.0\"\nport = 9090");
792        assert_eq!(p.port, 9090);
793    }
794
795    #[test]
796    fn http_kind_defaults_to_8080_with_default_probe_and_no_mcp() {
797        let p = load("[service]\nname = \"svc\"\nversion = \"0.1.0\"\ntype = \"http\"");
798        assert_eq!(p.kind, ServiceKind::Http);
799        assert_eq!(p.port, 8080);
800        let h = p.health.expect("http services get a default probe");
801        assert_eq!(h.path, "/health");
802        assert_eq!(h.port, 8080);
803        assert!(!p.mcp_sidecar, "http forces the mcp sidecar off");
804    }
805
806    #[test]
807    fn http_explicit_port_and_health_path() {
808        let p = load(
809            "[service]\nname = \"svc\"\nversion = \"0.1.0\"\ntype = \"http\"\nport = 7001\n[service.health]\npath = \"/healthz\"",
810        );
811        assert_eq!(p.port, 7001);
812        let h = p.health.unwrap();
813        assert_eq!(h.path, "/healthz");
814        assert_eq!(h.port, 7001);
815    }
816
817    #[test]
818    fn backend_with_http_exposes_both() {
819        let p = load(
820            "[service]\nname = \"svc\"\nversion = \"0.1.0\"\n[service.http]\nport = 8081\nhealth_path = \"/healthz\"",
821        );
822        assert_eq!(p.kind, ServiceKind::Backend);
823        assert_eq!(p.port, 50051, "gRPC primary port preserved");
824        assert_eq!(p.http_port, Some(8081));
825        let h = p.health.unwrap();
826        assert_eq!(h.path, "/healthz");
827        assert_eq!(h.port, 8081, "probe targets the http port, not gRPC");
828        assert!(p.mcp_sidecar, "a gRPC backend still gets its mcp sidecar");
829    }
830
831    // ---- depends_on per-env resolution ------------------------------------
832
833    /// A complete tonin.toml with [service] + [resources]; tests append
834    /// [deploy] and [depends_on]. Bodies use literal `{env}` (no format!),
835    /// so they're concatenated rather than interpolated.
836    const SVC: &str = "[service]\nname = \"svc\"\nversion = \"0.1.0\"\n[resources]\ncpu = \"100m\"\nmemory = \"128Mi\"\n";
837
838    fn try_load_env(body: &str, env: &str) -> Result<Plan, Error> {
839        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
840        let dir = std::env::temp_dir().join(format!("tonin-plan-dep-{}-{n}", std::process::id()));
841        std::fs::create_dir_all(&dir).unwrap();
842        let path = dir.join("tonin.toml");
843        std::fs::write(&path, body).unwrap();
844        let plan = Plan::load_with_env(&path, env);
845        let _ = std::fs::remove_dir_all(&dir);
846        plan
847    }
848
849    fn dep(plan: &Plan, name: &str) -> Option<String> {
850        plan.depends_on
851            .iter()
852            .find(|d| d.name == name)
853            .map(|d| d.namespace.clone())
854    }
855
856    #[test]
857    fn depends_on_literal_is_backward_compatible() {
858        let body = [SVC, "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nidentity = \"agnitiv-dev\"\n"].concat();
859        let p = try_load_env(&body, "prod").unwrap();
860        // No {env} → literal namespace, identical in every env (today's behaviour).
861        assert_eq!(dep(&p, "identity").as_deref(), Some("agnitiv-dev"));
862    }
863
864    #[test]
865    fn depends_on_env_placeholder_resolves_per_env() {
866        let body = [
867            SVC,
868            "[deploy]\nreplicas = 1\nnamespace =\"agnitiv-{env}\"\n[depends_on]\nidentity = \"agnitiv-{env}\"\n",
869        ]
870        .concat();
871        let dev = try_load_env(&body, "dev").unwrap();
872        assert_eq!(dev.namespace, "agnitiv-dev");
873        assert_eq!(dep(&dev, "identity").as_deref(), Some("agnitiv-dev"));
874        let prod = try_load_env(&body, "prod").unwrap();
875        assert_eq!(prod.namespace, "agnitiv-prod");
876        assert_eq!(dep(&prod, "identity").as_deref(), Some("agnitiv-prod"));
877    }
878
879    #[test]
880    fn depends_on_table_per_env_override_wins() {
881        let body = [
882            SVC,
883            "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nzradar = { namespace = \"zradar-{env}\", prod = \"zradar-shared\" }\n",
884        ]
885        .concat();
886        assert_eq!(
887            dep(&try_load_env(&body, "dev").unwrap(), "zradar").as_deref(),
888            Some("zradar-dev")
889        );
890        assert_eq!(
891            dep(&try_load_env(&body, "prod").unwrap(), "zradar").as_deref(),
892            Some("zradar-shared")
893        );
894    }
895
896    #[test]
897    fn depends_on_envs_whitelist_scopes_dependency() {
898        let body = [
899            SVC,
900            "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\naudit = { namespace = \"security-{env}\", envs = [\"prod\"] }\n",
901        ]
902        .concat();
903        assert!(
904            dep(&try_load_env(&body, "dev").unwrap(), "audit").is_none(),
905            "absent in dev"
906        );
907        assert_eq!(
908            dep(&try_load_env(&body, "prod").unwrap(), "audit").as_deref(),
909            Some("security-prod")
910        );
911    }
912
913    #[test]
914    fn depends_on_inherit_is_omitted_from_output() {
915        let body = [SVC, "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nbilling = { namespace = \"@inherit\" }\n"].concat();
916        assert!(dep(&try_load_env(&body, "prod").unwrap(), "billing").is_none());
917    }
918
919    #[test]
920    fn depends_on_unresolved_placeholder_is_error() {
921        let body = [SVC, "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nidentity = \"agnitiv-{environment}\"\n"].concat();
922        let err = try_load_env(&body, "prod").unwrap_err();
923        assert!(
924            matches!(err, Error::UnresolvedNamespace { .. }),
925            "got {err:?}"
926        );
927    }
928
929    #[test]
930    fn depends_on_missing_namespace_for_env_is_error() {
931        // Only a dev override; prod has nothing to resolve to → hard error,
932        // never a silent fallback.
933        let body = [SVC, "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nidentity = { dev = \"agnitiv-dev\" }\n"].concat();
934        let err = try_load_env(&body, "prod").unwrap_err();
935        assert!(
936            matches!(err, Error::InvalidDependency { .. }),
937            "got {err:?}"
938        );
939    }
940
941    #[test]
942    fn depends_on_bad_type_is_error() {
943        let body = [
944            SVC,
945            "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nidentity = 123\n",
946        ]
947        .concat();
948        let err = try_load_env(&body, "prod").unwrap_err();
949        assert!(
950            matches!(err, Error::InvalidDependency { .. }),
951            "got {err:?}"
952        );
953    }
954
955    #[test]
956    fn deploy_namespace_unresolved_placeholder_is_error() {
957        let body = [
958            SVC,
959            "[deploy]\nreplicas = 1\nnamespace =\"agnitiv-{cluster}\"\n",
960        ]
961        .concat();
962        let err = try_load_env(&body, "prod").unwrap_err();
963        assert!(
964            matches!(err, Error::UnresolvedNamespace { .. }),
965            "got {err:?}"
966        );
967    }
968}