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.
230pub const RECOMMENDED_CLI_MIN: &str = "0.5.6";
231
232#[derive(Debug, Deserialize)]
233struct RawConfig {
234    #[serde(default)]
235    schema: Option<String>,
236    service: RawService,
237    deploy: RawDeploy,
238    resources: RawResources,
239    #[serde(default)]
240    autoscale: Option<RawAutoscale>,
241    // String shorthand (`name = "<ns>"`) or table form
242    // (`name = { namespace = "<ns>", <env> = "<ns>", envs = [..] }`).
243    // Parsed as raw values and interpreted by `resolve_depends_on`.
244    #[serde(default)]
245    depends_on: BTreeMap<String, toml::Value>,
246    #[serde(default)]
247    callers: RawCallers,
248    #[serde(default)]
249    database: Option<RawDatabase>,
250    #[serde(default)]
251    databases: std::collections::BTreeMap<String, RawDatabase>,
252    #[serde(default)]
253    cache: Option<RawCache>,
254    #[serde(default)]
255    caches: std::collections::BTreeMap<String, RawCache>,
256    #[serde(default)]
257    secrets: Option<RawSecrets>,
258    #[serde(default)]
259    migrations: Option<RawMigrations>,
260    #[serde(default)]
261    config: Option<RawConfigBlock>,
262    #[serde(default)]
263    client: Option<RawClientConfig>,
264}
265
266#[derive(Debug, Deserialize)]
267struct RawService {
268    name: String,
269    version: String,
270    #[serde(default)]
271    language: Option<String>,
272    #[serde(default, rename = "type")]
273    kind: Option<String>,
274    #[serde(default)]
275    web_mode: Option<String>,
276    #[serde(default)]
277    #[allow(dead_code)]
278    codec: Option<String>,
279    /// Explicit listen port. Optional — defaults per kind when unset
280    /// (web: 8080/3000 by mode, http: 8080, gRPC backend: 50051).
281    #[serde(default)]
282    port: Option<u32>,
283    /// HTTP health-probe config (`[service.health]`).
284    #[serde(default)]
285    health: Option<RawHealth>,
286    /// Additional HTTP endpoint (`[service.http]`). Lets a gRPC `backend` ALSO
287    /// serve HTTP (health/metrics/admin) — the two are not mutually exclusive.
288    #[serde(default)]
289    http: Option<RawHttpEndpoint>,
290}
291
292#[derive(Debug, Deserialize)]
293struct RawHealth {
294    #[serde(default)]
295    path: Option<String>,
296    #[serde(default)]
297    port: Option<u32>,
298}
299
300#[derive(Debug, Deserialize)]
301struct RawHttpEndpoint {
302    port: u32,
303    #[serde(default)]
304    health_path: Option<String>,
305}
306
307#[derive(Debug, Deserialize)]
308struct RawDeploy {
309    replicas: u32,
310    #[serde(default)]
311    mesh: Option<Mesh>,
312    #[serde(default = "default_true")]
313    mcp_sidecar: bool,
314    namespace: String,
315    #[serde(default)]
316    expose: Option<String>,
317    #[serde(default, flatten)]
318    envs: std::collections::BTreeMap<String, RawDeployEnv>,
319}
320
321#[derive(Debug, Deserialize, Default)]
322struct RawDeployEnv {
323    #[serde(default)]
324    replicas: Option<u32>,
325    #[serde(default)]
326    namespace: Option<String>,
327    #[serde(default)]
328    mesh: Option<Mesh>,
329    #[serde(default)]
330    mcp_sidecar: Option<bool>,
331    #[serde(default)]
332    expose: Option<String>,
333}
334
335#[derive(Debug, Deserialize)]
336struct RawResources {
337    cpu: String,
338    memory: String,
339}
340
341#[derive(Debug, Deserialize)]
342struct RawAutoscale {
343    max_replicas: u32,
344}
345
346fn default_true() -> bool {
347    true
348}
349
350#[derive(Debug, Default, Deserialize)]
351struct RawClientConfig {
352    #[serde(default = "default_true")]
353    coalesce: bool,
354    #[serde(default)]
355    cache: std::collections::BTreeMap<String, RawMethodCacheConfig>,
356}
357
358#[derive(Debug, Deserialize)]
359struct RawMethodCacheConfig {
360    ttl_ms: u64,
361    #[serde(default = "default_cache_capacity")]
362    capacity: usize,
363}
364
365fn default_cache_capacity() -> usize {
366    1_000
367}
368
369#[derive(Clone, Debug, Serialize)]
370pub struct MethodCacheSpec {
371    pub ttl_ms: u64,
372    pub capacity: usize,
373}
374
375#[derive(Clone, Debug, Serialize)]
376pub struct ClientSpec {
377    pub coalesce: bool,
378    pub caches: Vec<(String, MethodCacheSpec)>,
379}
380
381impl Default for ClientSpec {
382    fn default() -> Self {
383        Self {
384            coalesce: true,
385            caches: Vec::new(),
386        }
387    }
388}
389
390// ---------- normalized Plan ----------
391
392#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
393#[serde(rename_all = "lowercase")]
394pub enum ServiceKind {
395    Backend,
396    Web,
397    Http,
398}
399
400impl ServiceKind {
401    pub fn as_str(&self) -> &'static str {
402        match self {
403            ServiceKind::Backend => "backend",
404            ServiceKind::Web => "web",
405            ServiceKind::Http => "http",
406        }
407    }
408    pub fn is_web(&self) -> bool {
409        matches!(self, ServiceKind::Web)
410    }
411    pub fn is_http(&self) -> bool {
412        matches!(self, ServiceKind::Http)
413    }
414}
415
416/// Resolved HTTP health-probe configuration (`[service.health]`).
417#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
418pub struct HealthSpec {
419    pub path: String,
420    pub port: u32,
421}
422
423#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
424#[serde(rename_all = "lowercase")]
425pub enum WebMode {
426    Spa,
427    Bff,
428}
429
430impl WebMode {
431    pub fn as_str(&self) -> &'static str {
432        match self {
433            WebMode::Spa => "spa",
434            WebMode::Bff => "bff",
435        }
436    }
437    pub fn container_port(&self) -> u32 {
438        match self {
439            WebMode::Spa => 8080,
440            WebMode::Bff => 3000,
441        }
442    }
443}
444
445#[derive(Clone, Debug)]
446pub struct Plan {
447    pub name: String,
448    pub version: String,
449    pub language: String,
450    pub kind: ServiceKind,
451    pub web_mode: Option<WebMode>,
452    /// Effective primary container/listen port (`[service].port` or per-kind default).
453    pub port: u32,
454    /// Additional HTTP port, when a gRPC backend also serves HTTP (`[service.http]`).
455    pub http_port: Option<u32>,
456    /// HTTP health probe, when an HTTP surface exists (always for http; opt-in otherwise).
457    pub health: Option<HealthSpec>,
458    pub namespace: String,
459    pub mesh: Mesh,
460    pub replicas: u32,
461    pub max_replicas: u32,
462    pub mcp_sidecar: bool,
463    pub expose: Option<String>,
464    pub cpu: String,
465    pub memory: String,
466    pub image: String,
467    pub depends_on: Vec<ServiceRef>,
468    pub callers: Vec<ServiceRef>,
469    pub dir: PathBuf,
470    pub database: Option<DatabaseSpec>,
471    pub named_databases: Vec<(String, DatabaseSpec)>,
472    pub cache: Option<CacheSpec>,
473    pub named_caches: Vec<(String, CacheSpec)>,
474    pub secrets: Option<SecretsSpec>,
475    pub migrations: Option<MigrationsSpec>,
476    pub config: Option<ConfigSpec>,
477    pub emitted_env: EmittedEnv,
478    pub selected_env: String,
479    pub client: ClientSpec,
480}
481
482impl Plan {
483    pub fn load(toml_path: &Path) -> Result<Self, Error> {
484        Self::load_with_env(toml_path, &stateful::select_env(None))
485    }
486
487    pub fn load_with_env(toml_path: &Path, env: &str) -> Result<Self, Error> {
488        let raw_str = std::fs::read_to_string(toml_path)
489            .map_err(|e| Error::Io(toml_path.to_path_buf(), e))?;
490        let raw: RawConfig =
491            toml::from_str(&raw_str).map_err(|e| Error::Toml(toml_path.to_path_buf(), e))?;
492
493        if let Some(v) = raw.schema.as_deref()
494            && !SUPPORTED_SCHEMAS.contains(&v)
495        {
496            return Err(Error::UnsupportedSchema {
497                path: toml_path.to_path_buf(),
498                found: v.to_string(),
499                supported: SUPPORTED_SCHEMAS.iter().map(|s| s.to_string()).collect(),
500                current: CURRENT_SCHEMA.to_string(),
501            });
502        }
503
504        let depends_on = resolve_depends_on(raw.depends_on, env)?;
505
506        let explicit_callers = stateful::resolve_callers(&raw.callers, env);
507
508        let deploy_overlay = raw.deploy.envs.get(env);
509        let deploy_replicas = deploy_overlay
510            .and_then(|o| o.replicas)
511            .unwrap_or(raw.deploy.replicas);
512        let deploy_namespace = {
513            let raw_ns = deploy_overlay
514                .and_then(|o| o.namespace.clone())
515                .unwrap_or(raw.deploy.namespace);
516            let ns = apply_env(&raw_ns, env);
517            ensure_resolved("deploy.namespace", &ns)?;
518            ns
519        };
520        let deploy_mesh = deploy_overlay
521            .and_then(|o| o.mesh)
522            .or(raw.deploy.mesh)
523            .unwrap_or_default();
524        let deploy_mcp_sidecar = deploy_overlay
525            .and_then(|o| o.mcp_sidecar)
526            .unwrap_or(raw.deploy.mcp_sidecar);
527        let deploy_expose = deploy_overlay
528            .and_then(|o| o.expose.clone())
529            .or(raw.deploy.expose);
530
531        let max_replicas = raw
532            .autoscale
533            .as_ref()
534            .map(|a| a.max_replicas)
535            .unwrap_or(deploy_replicas);
536
537        let dir = toml_path
538            .parent()
539            .map(Path::to_path_buf)
540            .unwrap_or_else(|| PathBuf::from("."));
541
542        let image = std::env::var("TONIN_IMAGE_PREFIX")
543            .map(|prefix| format!("{prefix}/{}:{}", raw.service.name, raw.service.version))
544            .unwrap_or_else(|_| format!("micro/{}:{}", raw.service.name, raw.service.version));
545
546        let kind = match raw.service.kind.as_deref() {
547            Some("web") => ServiceKind::Web,
548            Some("http") => ServiceKind::Http,
549            _ => ServiceKind::Backend,
550        };
551        let web_mode = match (kind, raw.service.web_mode.as_deref()) {
552            (ServiceKind::Web, Some("bff")) => Some(WebMode::Bff),
553            (ServiceKind::Web, _) => Some(WebMode::Spa),
554            _ => None,
555        };
556
557        // Effective listen port. Explicit `[service].port` wins; otherwise the
558        // per-kind default — preserving pre-port-field output for web/backend.
559        let port = raw.service.port.unwrap_or_else(|| match kind {
560            ServiceKind::Web => web_mode.map(|m| m.container_port()).unwrap_or(8080),
561            ServiceKind::Http => 8080,
562            ServiceKind::Backend => 50051,
563        });
564
565        // Additional HTTP port for a gRPC backend that also serves HTTP
566        // (`[service.http]`). None for http-primary (its primary port is already
567        // HTTP) and for services with no secondary endpoint.
568        let http_port = match kind {
569            ServiceKind::Http => None,
570            _ => raw.service.http.as_ref().map(|h| h.port),
571        };
572
573        // Port an HTTP health probe targets: the http-primary port, else the
574        // secondary HTTP port. None ⇒ no HTTP surface ⇒ no httpGet probe.
575        let http_probe_port = match kind {
576            ServiceKind::Http => Some(port),
577            _ => http_port,
578        };
579
580        // HTTP health probe. Present when there is an HTTP surface (http kind or
581        // a `[service.http]` endpoint) or when `[service.health]` is declared.
582        // Path: `[service.health].path`, else `[service.http].health_path`, else
583        // `/health`. Backend/web without any of these get no probe, so existing
584        // manifests stay byte-identical.
585        let health = if raw.service.health.is_some() || http_probe_port.is_some() {
586            let declared = raw.service.health.as_ref();
587            let secondary = raw.service.http.as_ref();
588            Some(HealthSpec {
589                path: declared
590                    .and_then(|h| h.path.clone())
591                    .or_else(|| secondary.and_then(|h| h.health_path.clone()))
592                    .unwrap_or_else(|| "/health".into()),
593                port: declared
594                    .and_then(|h| h.port)
595                    .or(http_probe_port)
596                    .unwrap_or(port),
597            })
598        } else {
599            None
600        };
601
602        // MCP sidecar proxies to a gRPC server on :50051, so it cannot front an
603        // HTTP-primary service — force it off for kind = http.
604        let mcp_sidecar = deploy_mcp_sidecar && !matches!(kind, ServiceKind::Http);
605
606        let svc_name = raw.service.name.clone();
607        let svc_ns = deploy_namespace.clone();
608        let database = raw
609            .database
610            .as_ref()
611            .map(|r| stateful::resolve_database(r, env, &svc_name, &svc_ns));
612        let named_databases: Vec<(String, DatabaseSpec)> = raw
613            .databases
614            .iter()
615            .map(|(name, r)| {
616                (
617                    name.clone(),
618                    stateful::resolve_database(r, env, &svc_name, &svc_ns),
619                )
620            })
621            .collect();
622        let cache = raw
623            .cache
624            .as_ref()
625            .map(|r| stateful::resolve_cache(r, env, &svc_name, &svc_ns));
626        let named_caches: Vec<(String, CacheSpec)> = raw
627            .caches
628            .iter()
629            .map(|(name, r)| {
630                (
631                    name.clone(),
632                    stateful::resolve_cache(r, env, &svc_name, &svc_ns),
633                )
634            })
635            .collect();
636        let secrets = raw.secrets.as_ref().map(stateful::resolve_secrets);
637        let migrations = raw.migrations.as_ref().map(stateful::resolve_migrations);
638        let config = raw.config.as_ref().map(stateful::resolve_config);
639
640        let client = raw
641            .client
642            .map(|c| {
643                let mut caches: Vec<(String, MethodCacheSpec)> = c
644                    .cache
645                    .into_iter()
646                    .map(|(method, mc)| {
647                        (
648                            method,
649                            MethodCacheSpec {
650                                ttl_ms: mc.ttl_ms,
651                                capacity: mc.capacity,
652                            },
653                        )
654                    })
655                    .collect();
656                caches.sort_by(|a, b| a.0.cmp(&b.0));
657                ClientSpec {
658                    coalesce: c.coalesce,
659                    caches,
660                }
661            })
662            .unwrap_or_default();
663
664        let mut emitted_env = EmittedEnv::default();
665        if let Some(d) = &database {
666            emitted_env.extend_database(d, &svc_name);
667        }
668        for (name, d) in &named_databases {
669            let prefix = format!("{}_DATABASE", name.to_uppercase());
670            emitted_env.extend_database_named(&prefix, d, &svc_name);
671        }
672        if let Some(c) = &cache {
673            emitted_env.extend_cache(c);
674        }
675        for (name, c) in &named_caches {
676            let prefix = format!("{}_REDIS", name.to_uppercase());
677            emitted_env.extend_cache_named(&prefix, c);
678        }
679        if let Some(s) = &secrets {
680            emitted_env.extend_secrets(s);
681        }
682
683        Ok(Plan {
684            name: raw.service.name,
685            version: raw.service.version,
686            language: raw.service.language.unwrap_or_else(|| "rust".into()),
687            kind,
688            web_mode,
689            port,
690            http_port,
691            health,
692            namespace: deploy_namespace,
693            mesh: deploy_mesh,
694            replicas: deploy_replicas,
695            max_replicas,
696            mcp_sidecar,
697            expose: deploy_expose,
698            cpu: raw.resources.cpu,
699            memory: raw.resources.memory,
700            image,
701            depends_on,
702            callers: explicit_callers,
703            dir,
704            database,
705            named_databases,
706            cache,
707            named_caches,
708            secrets,
709            migrations,
710            config,
711            client,
712            emitted_env,
713            selected_env: env.to_string(),
714        })
715    }
716
717    pub fn load_workspace(root: &Path) -> Result<Vec<Plan>, Error> {
718        Self::load_workspace_with_env(root, &stateful::select_env(None))
719    }
720
721    pub fn load_workspace_with_env(root: &Path, env: &str) -> Result<Vec<Plan>, Error> {
722        let mut plans: Vec<Plan> = walkdir::WalkDir::new(root)
723            .into_iter()
724            .filter_map(|e| e.ok())
725            .filter(|e| e.file_name() == "tonin.toml")
726            .map(|e| Plan::load_with_env(e.path(), env))
727            .collect::<Result<_, _>>()?;
728
729        let snapshot: Vec<(String, String, Vec<ServiceRef>)> = plans
730            .iter()
731            .map(|p| (p.name.clone(), p.namespace.clone(), p.depends_on.clone()))
732            .collect();
733        for plan in plans.iter_mut() {
734            for (caller_name, caller_ns, deps) in &snapshot {
735                if deps
736                    .iter()
737                    .any(|d| d.name == plan.name && d.namespace == plan.namespace)
738                {
739                    plan.callers.push(ServiceRef {
740                        name: caller_name.clone(),
741                        namespace: caller_ns.clone(),
742                    });
743                }
744            }
745            plan.callers.sort();
746            plan.callers.dedup();
747        }
748
749        plans.sort_by(|a, b| a.name.cmp(&b.name));
750        Ok(plans)
751    }
752}
753
754#[cfg(test)]
755mod tests {
756    use super::*;
757    use std::sync::atomic::{AtomicU32, Ordering};
758
759    static COUNTER: AtomicU32 = AtomicU32::new(0);
760
761    const BASE: &str = "\n[deploy]\nreplicas = 1\nnamespace = \"demo\"\n\n[resources]\ncpu = \"100m\"\nmemory = \"128Mi\"\n";
762
763    /// Write `<service block> + BASE` to a temp tonin.toml and load it.
764    fn load(service: &str) -> Plan {
765        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
766        let dir = std::env::temp_dir().join(format!("tonin-plan-test-{}-{n}", std::process::id()));
767        std::fs::create_dir_all(&dir).unwrap();
768        let path = dir.join("tonin.toml");
769        std::fs::write(&path, format!("{service}{BASE}")).unwrap();
770        let plan = Plan::load_with_env(&path, "prod").unwrap();
771        let _ = std::fs::remove_dir_all(&dir);
772        plan
773    }
774
775    #[test]
776    fn backend_defaults_unchanged() {
777        let p = load("[service]\nname = \"svc\"\nversion = \"0.1.0\"");
778        assert_eq!(p.kind, ServiceKind::Backend);
779        assert_eq!(p.port, 50051);
780        assert_eq!(p.http_port, None);
781        assert!(p.health.is_none());
782        assert!(p.mcp_sidecar, "backend keeps the default mcp sidecar");
783    }
784
785    #[test]
786    fn backend_port_override() {
787        let p = load("[service]\nname = \"svc\"\nversion = \"0.1.0\"\nport = 9090");
788        assert_eq!(p.port, 9090);
789    }
790
791    #[test]
792    fn http_kind_defaults_to_8080_with_default_probe_and_no_mcp() {
793        let p = load("[service]\nname = \"svc\"\nversion = \"0.1.0\"\ntype = \"http\"");
794        assert_eq!(p.kind, ServiceKind::Http);
795        assert_eq!(p.port, 8080);
796        let h = p.health.expect("http services get a default probe");
797        assert_eq!(h.path, "/health");
798        assert_eq!(h.port, 8080);
799        assert!(!p.mcp_sidecar, "http forces the mcp sidecar off");
800    }
801
802    #[test]
803    fn http_explicit_port_and_health_path() {
804        let p = load(
805            "[service]\nname = \"svc\"\nversion = \"0.1.0\"\ntype = \"http\"\nport = 7001\n[service.health]\npath = \"/healthz\"",
806        );
807        assert_eq!(p.port, 7001);
808        let h = p.health.unwrap();
809        assert_eq!(h.path, "/healthz");
810        assert_eq!(h.port, 7001);
811    }
812
813    #[test]
814    fn backend_with_http_exposes_both() {
815        let p = load(
816            "[service]\nname = \"svc\"\nversion = \"0.1.0\"\n[service.http]\nport = 8081\nhealth_path = \"/healthz\"",
817        );
818        assert_eq!(p.kind, ServiceKind::Backend);
819        assert_eq!(p.port, 50051, "gRPC primary port preserved");
820        assert_eq!(p.http_port, Some(8081));
821        let h = p.health.unwrap();
822        assert_eq!(h.path, "/healthz");
823        assert_eq!(h.port, 8081, "probe targets the http port, not gRPC");
824        assert!(p.mcp_sidecar, "a gRPC backend still gets its mcp sidecar");
825    }
826
827    // ---- depends_on per-env resolution ------------------------------------
828
829    /// A complete tonin.toml with [service] + [resources]; tests append
830    /// [deploy] and [depends_on]. Bodies use literal `{env}` (no format!),
831    /// so they're concatenated rather than interpolated.
832    const SVC: &str = "[service]\nname = \"svc\"\nversion = \"0.1.0\"\n[resources]\ncpu = \"100m\"\nmemory = \"128Mi\"\n";
833
834    fn try_load_env(body: &str, env: &str) -> Result<Plan, Error> {
835        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
836        let dir = std::env::temp_dir().join(format!("tonin-plan-dep-{}-{n}", std::process::id()));
837        std::fs::create_dir_all(&dir).unwrap();
838        let path = dir.join("tonin.toml");
839        std::fs::write(&path, body).unwrap();
840        let plan = Plan::load_with_env(&path, env);
841        let _ = std::fs::remove_dir_all(&dir);
842        plan
843    }
844
845    fn dep(plan: &Plan, name: &str) -> Option<String> {
846        plan.depends_on
847            .iter()
848            .find(|d| d.name == name)
849            .map(|d| d.namespace.clone())
850    }
851
852    #[test]
853    fn depends_on_literal_is_backward_compatible() {
854        let body = [SVC, "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nidentity = \"agnitiv-dev\"\n"].concat();
855        let p = try_load_env(&body, "prod").unwrap();
856        // No {env} → literal namespace, identical in every env (today's behaviour).
857        assert_eq!(dep(&p, "identity").as_deref(), Some("agnitiv-dev"));
858    }
859
860    #[test]
861    fn depends_on_env_placeholder_resolves_per_env() {
862        let body = [
863            SVC,
864            "[deploy]\nreplicas = 1\nnamespace =\"agnitiv-{env}\"\n[depends_on]\nidentity = \"agnitiv-{env}\"\n",
865        ]
866        .concat();
867        let dev = try_load_env(&body, "dev").unwrap();
868        assert_eq!(dev.namespace, "agnitiv-dev");
869        assert_eq!(dep(&dev, "identity").as_deref(), Some("agnitiv-dev"));
870        let prod = try_load_env(&body, "prod").unwrap();
871        assert_eq!(prod.namespace, "agnitiv-prod");
872        assert_eq!(dep(&prod, "identity").as_deref(), Some("agnitiv-prod"));
873    }
874
875    #[test]
876    fn depends_on_table_per_env_override_wins() {
877        let body = [
878            SVC,
879            "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nzradar = { namespace = \"zradar-{env}\", prod = \"zradar-shared\" }\n",
880        ]
881        .concat();
882        assert_eq!(
883            dep(&try_load_env(&body, "dev").unwrap(), "zradar").as_deref(),
884            Some("zradar-dev")
885        );
886        assert_eq!(
887            dep(&try_load_env(&body, "prod").unwrap(), "zradar").as_deref(),
888            Some("zradar-shared")
889        );
890    }
891
892    #[test]
893    fn depends_on_envs_whitelist_scopes_dependency() {
894        let body = [
895            SVC,
896            "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\naudit = { namespace = \"security-{env}\", envs = [\"prod\"] }\n",
897        ]
898        .concat();
899        assert!(
900            dep(&try_load_env(&body, "dev").unwrap(), "audit").is_none(),
901            "absent in dev"
902        );
903        assert_eq!(
904            dep(&try_load_env(&body, "prod").unwrap(), "audit").as_deref(),
905            Some("security-prod")
906        );
907    }
908
909    #[test]
910    fn depends_on_inherit_is_omitted_from_output() {
911        let body = [SVC, "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nbilling = { namespace = \"@inherit\" }\n"].concat();
912        assert!(dep(&try_load_env(&body, "prod").unwrap(), "billing").is_none());
913    }
914
915    #[test]
916    fn depends_on_unresolved_placeholder_is_error() {
917        let body = [SVC, "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nidentity = \"agnitiv-{environment}\"\n"].concat();
918        let err = try_load_env(&body, "prod").unwrap_err();
919        assert!(
920            matches!(err, Error::UnresolvedNamespace { .. }),
921            "got {err:?}"
922        );
923    }
924
925    #[test]
926    fn depends_on_missing_namespace_for_env_is_error() {
927        // Only a dev override; prod has nothing to resolve to → hard error,
928        // never a silent fallback.
929        let body = [SVC, "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nidentity = { dev = \"agnitiv-dev\" }\n"].concat();
930        let err = try_load_env(&body, "prod").unwrap_err();
931        assert!(
932            matches!(err, Error::InvalidDependency { .. }),
933            "got {err:?}"
934        );
935    }
936
937    #[test]
938    fn depends_on_bad_type_is_error() {
939        let body = [
940            SVC,
941            "[deploy]\nreplicas = 1\nnamespace =\"demo\"\n[depends_on]\nidentity = 123\n",
942        ]
943        .concat();
944        let err = try_load_env(&body, "prod").unwrap_err();
945        assert!(
946            matches!(err, Error::InvalidDependency { .. }),
947            "got {err:?}"
948        );
949    }
950
951    #[test]
952    fn deploy_namespace_unresolved_placeholder_is_error() {
953        let body = [
954            SVC,
955            "[deploy]\nreplicas = 1\nnamespace =\"agnitiv-{cluster}\"\n",
956        ]
957        .concat();
958        let err = try_load_env(&body, "prod").unwrap_err();
959        assert!(
960            matches!(err, Error::UnresolvedNamespace { .. }),
961            "got {err:?}"
962        );
963    }
964}