Skip to main content

launch/
types.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4#[derive(Debug, Clone, Default, Serialize, Deserialize)]
5pub struct Discovery {
6    pub services: Vec<Service>,
7    #[serde(skip_serializing_if = "Option::is_none")]
8    pub monorepo: Option<Monorepo>,
9}
10
11#[derive(Debug, Clone, Default, Serialize, Deserialize)]
12pub struct Service {
13    pub name: String,
14    pub dir: String,
15
16    #[serde(default, skip_serializing_if = "Vec::is_empty")]
17    pub runtimes: Vec<Runtime>,
18
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub network: Option<Network>,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub exec_mode: Option<ExecMode>,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub image: Option<String>,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub dockerfile: Option<String>,
27
28    #[serde(default, skip_serializing_if = "Vec::is_empty")]
29    pub env: Vec<EnvVar>,
30    #[serde(default, skip_serializing_if = "Vec::is_empty")]
31    pub system_deps: Vec<String>,
32    #[serde(default, skip_serializing_if = "Vec::is_empty")]
33    pub volumes: Vec<Volume>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub resources: Option<Resources>,
36
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub replicas: Option<u32>,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub restart: Option<Restart>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub healthcheck: Option<String>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub schedule: Option<String>,
45
46    #[serde(default, skip_serializing_if = "Vec::is_empty")]
47    pub detected_by: Vec<String>,
48}
49
50impl Service {
51    /// Primary language (first runtime).
52    pub fn language(&self) -> Option<Language> {
53        self.runtimes.first().map(|r| r.language)
54    }
55
56    /// Primary framework.
57    pub fn framework(&self) -> Option<&str> {
58        self.runtimes
59            .iter()
60            .find_map(|r| r.framework.as_deref())
61    }
62
63    /// First available start command across runtimes.
64    pub fn start(&self) -> Option<&str> {
65        self.runtimes
66            .iter()
67            .find_map(|r| r.start.as_deref())
68    }
69
70    /// First available install command across runtimes.
71    pub fn install(&self) -> Option<&str> {
72        self.runtimes
73            .iter()
74            .find_map(|r| r.install.as_deref())
75    }
76
77    /// First available build command across runtimes.
78    pub fn build(&self) -> Option<&str> {
79        self.runtimes
80            .iter()
81            .find_map(|r| r.build.as_deref())
82    }
83
84    /// First available dev command across runtimes.
85    pub fn dev(&self) -> Option<&str> {
86        self.runtimes
87            .iter()
88            .find_map(|r| r.dev.as_deref())
89    }
90
91    /// Primary output directory.
92    pub fn output_dir(&self) -> Option<&str> {
93        self.runtimes
94            .iter()
95            .find_map(|r| r.output_dir.as_deref())
96    }
97
98    /// Layer DirContexts onto this service, converting each to a Runtime.
99    /// Service's existing runtimes win per language group.
100    pub fn layer_contexts(&mut self, contexts: &[DirContext]) {
101        for ctx in contexts {
102            if let Some(rt) = Runtime::from_context(ctx) {
103                if let Some(existing) = self
104                    .runtimes
105                    .iter_mut()
106                    .find(|r| r.language.same_group(rt.language))
107                {
108                    existing.fill_from(&rt);
109                } else {
110                    self.runtimes.push(rt);
111                }
112            } else if !ctx.commands.is_empty()
113                || ctx.framework.is_some()
114                || ctx.output_dir.is_some()
115            {
116                // Language-less context with no inferrable language:
117                // best-effort apply to first existing runtime.
118                if let Some(first) = self.runtimes.first_mut() {
119                    if first.install.is_none() {
120                        first.install.clone_from(&ctx.commands.install);
121                    }
122                    if first.build.is_none() {
123                        first.build.clone_from(&ctx.commands.build);
124                    }
125                    if first.start.is_none() {
126                        first.start.clone_from(&ctx.commands.start);
127                    }
128                    if first.dev.is_none() {
129                        first.dev.clone_from(&ctx.commands.dev);
130                    }
131                    if first.framework.is_none() {
132                        first.framework.clone_from(&ctx.framework);
133                    }
134                    if first.output_dir.is_none() {
135                        first.output_dir.clone_from(&ctx.output_dir);
136                    }
137                }
138            }
139            if self.healthcheck.is_none() {
140                self.healthcheck.clone_from(&ctx.healthcheck);
141            }
142            merge_env_vars(&mut self.env, &ctx.env);
143            merge_string_vecs(&mut self.system_deps, &ctx.system_deps);
144        }
145    }
146
147    /// Sort runtimes: non-JS/TS backends first, JS/TS last.
148    pub fn sort_runtimes(&mut self) {
149        self.runtimes
150            .sort_by_key(|r| if r.language.is_js_ts() { 1 } else { 0 });
151    }
152}
153
154// -- Runtime --
155
156/// One language runtime within a service.
157/// A Rails+React service has two: one for Ruby, one for Node.
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct Runtime {
160    pub language: Language,
161    /// Tool name for provisioning (e.g. "node", "ruby", "deno", "bun").
162    pub name: String,
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub version: Option<String>,
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub version_source: Option<String>,
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub package_manager: Option<PackageManagerInfo>,
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub framework: Option<String>,
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub language_config: Option<LanguageConfig>,
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub output_dir: Option<String>,
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub install: Option<String>,
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub build: Option<String>,
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub start: Option<String>,
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub dev: Option<String>,
183}
184
185impl Runtime {
186    /// Convert a DirContext into a Runtime.
187    /// Returns None if the context has no language and no recognizable runtime name.
188    pub fn from_context(ctx: &DirContext) -> Option<Self> {
189        let language = ctx
190            .language
191            .or_else(|| ctx.runtime.as_ref().and_then(|ri| language_from_runtime_name(&ri.name)))?;
192        let (name, version, version_source) = match &ctx.runtime {
193            Some(ri) => (ri.name.clone(), ri.version.clone(), ri.source.clone()),
194            None => (default_runtime_name(language).into(), None, None),
195        };
196        Some(Runtime {
197            language,
198            name,
199            version,
200            version_source,
201            package_manager: ctx.package_manager.clone(),
202            framework: ctx.framework.clone(),
203            language_config: ctx.language_config.clone(),
204            output_dir: ctx.output_dir.clone(),
205            install: ctx.commands.install.clone(),
206            build: ctx.commands.build.clone(),
207            start: ctx.commands.start.clone(),
208            dev: ctx.commands.dev.clone(),
209        })
210    }
211
212    /// Fill None fields from another Runtime. Self wins.
213    pub fn fill_from(&mut self, other: &Runtime) {
214        if self.version.is_none() {
215            self.version.clone_from(&other.version);
216        }
217        if self.version_source.is_none() {
218            self.version_source.clone_from(&other.version_source);
219        }
220        if self.package_manager.is_none() {
221            self.package_manager.clone_from(&other.package_manager);
222        }
223        if self.framework.is_none() {
224            self.framework.clone_from(&other.framework);
225        }
226        if self.language_config.is_none() {
227            self.language_config.clone_from(&other.language_config);
228        }
229        if self.output_dir.is_none() {
230            self.output_dir.clone_from(&other.output_dir);
231        }
232        if self.install.is_none() {
233            self.install.clone_from(&other.install);
234        }
235        if self.build.is_none() {
236            self.build.clone_from(&other.build);
237        }
238        if self.start.is_none() {
239            self.start.clone_from(&other.start);
240        }
241        if self.dev.is_none() {
242            self.dev.clone_from(&other.dev);
243        }
244    }
245
246    /// Convert back to a DirContext (for layering derived services onto explicit ones).
247    pub fn to_context(&self, dir: &str) -> DirContext {
248        DirContext {
249            dir: dir.to_string(),
250            language: Some(self.language),
251            runtime: Some(RuntimeInfo {
252                name: self.name.clone(),
253                version: self.version.clone(),
254                source: self.version_source.clone(),
255            }),
256            framework: self.framework.clone(),
257            package_manager: self.package_manager.clone(),
258            language_config: self.language_config.clone(),
259            output_dir: self.output_dir.clone(),
260            commands: Commands {
261                install: self.install.clone(),
262                build: self.build.clone(),
263                start: self.start.clone(),
264                dev: self.dev.clone(),
265            },
266            healthcheck: None,
267            env: Vec::new(),
268            system_deps: Vec::new(),
269        }
270    }
271}
272
273/// Infer language from a runtime tool name (reverse of `default_runtime_name`).
274fn language_from_runtime_name(name: &str) -> Option<Language> {
275    match name {
276        "node" | "deno" | "bun" => Some(Language::JavaScript),
277        "python" | "python3" => Some(Language::Python),
278        "go" => Some(Language::Go),
279        "rust" | "cargo" => Some(Language::Rust),
280        "ruby" => Some(Language::Ruby),
281        "php" => Some(Language::Php),
282        "java" => Some(Language::Java),
283        "elixir" => Some(Language::Elixir),
284        "static" => Some(Language::Html),
285        _ => None,
286    }
287}
288
289pub(crate) fn default_runtime_name(language: Language) -> &'static str {
290    match language {
291        Language::JavaScript | Language::TypeScript => "node",
292        Language::Python => "python",
293        Language::Go => "go",
294        Language::Rust => "rust",
295        Language::Ruby => "ruby",
296        Language::Php => "php",
297        Language::Java => "java",
298        Language::Elixir => "elixir",
299        Language::Html => "static",
300    }
301}
302
303// -- RuntimeInfo & PackageManagerInfo --
304
305/// What runtime to install and how to get the right version.
306/// Used internally in DirContext; flattened into Runtime for the public API.
307#[derive(Debug, Clone, Serialize, Deserialize)]
308pub struct RuntimeInfo {
309    pub name: String,
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub version: Option<String>,
312    #[serde(skip_serializing_if = "Option::is_none")]
313    pub source: Option<String>,
314}
315
316/// Which package manager the project uses and its pinned version.
317#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct PackageManagerInfo {
319    pub name: String,
320    #[serde(skip_serializing_if = "Option::is_none")]
321    pub version: Option<String>,
322}
323
324// -- Language-specific config --
325
326#[derive(Debug, Clone, Serialize, Deserialize)]
327#[serde(tag = "type", rename_all = "snake_case")]
328pub enum LanguageConfig {
329    Node(NodeConfig),
330    Python(PythonConfig),
331    Go(GoConfig),
332    Rust(RustConfig),
333    Ruby(RubyConfig),
334    Php(PhpConfig),
335    Java(JavaConfig),
336    Elixir(ElixirConfig),
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize)]
340pub struct NodeConfig {
341    pub corepack: bool,
342    pub has_puppeteer: bool,
343    pub has_playwright: bool,
344    pub has_prisma: bool,
345    pub has_sharp: bool,
346    /// Framework builds to static output (Vite SPA, Astro static, CRA, Gatsby, Angular SPA)
347    pub is_spa: bool,
348}
349
350#[derive(Debug, Clone, Serialize, Deserialize)]
351pub struct PythonConfig {
352    #[serde(skip_serializing_if = "Option::is_none")]
353    pub wsgi_app: Option<String>,
354    #[serde(skip_serializing_if = "Option::is_none")]
355    pub asgi_app: Option<String>,
356    pub has_manage_py: bool,
357    /// Detected entry file: main.py, app.py, wsgi.py, asgi.py
358    #[serde(skip_serializing_if = "Option::is_none")]
359    pub main_file: Option<String>,
360    /// Celery task queue detected — needs a separate worker process
361    pub has_celery: bool,
362    /// Django whitenoise detected — app serves its own static files
363    pub has_whitenoise: bool,
364    /// Build backend from pyproject.toml [build-system] (e.g. "setuptools", "hatchling", "maturin")
365    #[serde(skip_serializing_if = "Option::is_none")]
366    pub build_backend: Option<String>,
367}
368
369#[derive(Debug, Clone, Serialize, Deserialize)]
370pub struct GoConfig {
371    pub cgo: bool,
372    #[serde(skip_serializing_if = "Option::is_none")]
373    pub binary_target: Option<String>,
374    pub workspace: bool,
375}
376
377#[derive(Debug, Clone, Serialize, Deserialize)]
378pub struct RustConfig {
379    #[serde(skip_serializing_if = "Option::is_none")]
380    pub edition: Option<String>,
381    #[serde(skip_serializing_if = "Option::is_none")]
382    pub binary_name: Option<String>,
383    /// [workspace] section detected in Cargo.toml
384    pub workspace: bool,
385}
386
387#[derive(Debug, Clone, Serialize, Deserialize)]
388pub struct RubyConfig {
389    #[serde(skip_serializing_if = "Option::is_none")]
390    pub asset_pipeline: Option<String>,
391    pub needs_node: bool,
392    pub has_bootsnap: bool,
393    #[serde(skip_serializing_if = "Option::is_none")]
394    pub app_server: Option<String>,
395    pub has_sidekiq: bool,
396    pub has_good_job: bool,
397}
398
399#[derive(Debug, Clone, Serialize, Deserialize)]
400pub struct ElixirConfig {
401    #[serde(skip_serializing_if = "Option::is_none")]
402    pub erlang_version: Option<String>,
403    #[serde(skip_serializing_if = "Option::is_none")]
404    pub erlang_version_source: Option<String>,
405    /// app name from `app: :my_app` in mix.exs — binary name for mix release
406    #[serde(skip_serializing_if = "Option::is_none")]
407    pub app_name: Option<String>,
408    /// mix assets.deploy alias detected
409    pub has_assets_deploy: bool,
410    /// ecto dependency detected — needs mix ecto.migrate
411    pub has_ecto: bool,
412}
413
414#[derive(Debug, Clone, Serialize, Deserialize)]
415pub struct PhpConfig {
416    #[serde(default, skip_serializing_if = "Vec::is_empty")]
417    pub extensions: Vec<String>,
418}
419
420#[derive(Debug, Clone, Serialize, Deserialize)]
421pub struct JavaConfig {
422    pub build_tool_wrapper: bool,
423    pub has_flyway: bool,
424    pub has_liquibase: bool,
425}
426
427// -- Commands --
428
429#[derive(Debug, Clone, Default, Serialize, Deserialize)]
430pub struct Commands {
431    #[serde(skip_serializing_if = "Option::is_none")]
432    pub install: Option<String>,
433    #[serde(skip_serializing_if = "Option::is_none")]
434    pub build: Option<String>,
435    #[serde(skip_serializing_if = "Option::is_none")]
436    pub start: Option<String>,
437    #[serde(skip_serializing_if = "Option::is_none")]
438    pub dev: Option<String>,
439}
440
441impl Commands {
442    pub fn is_empty(&self) -> bool {
443        self.install.is_none() && self.build.is_none() && self.start.is_none() && self.dev.is_none()
444    }
445
446    /// Fill None fields from another Commands. Self wins.
447    pub fn fill_from(&mut self, other: &Commands) {
448        if self.install.is_none() {
449            self.install.clone_from(&other.install);
450        }
451        if self.build.is_none() {
452            self.build.clone_from(&other.build);
453        }
454        if self.start.is_none() {
455            self.start.clone_from(&other.start);
456        }
457        if self.dev.is_none() {
458            self.dev.clone_from(&other.dev);
459        }
460    }
461}
462
463// -- EnvVar --
464
465#[derive(Debug, Clone, Serialize, Deserialize)]
466pub struct EnvVar {
467    pub key: String,
468    #[serde(skip_serializing_if = "Option::is_none")]
469    pub default: Option<String>,
470    #[serde(default, skip_serializing_if = "Vec::is_empty")]
471    pub detected_by: Vec<String>,
472}
473
474/// Merge env vars from `other` into `base`.
475/// For duplicate keys: base wins for value/default, but detected_by is collected from both.
476/// New keys from other are appended.
477pub fn merge_env_vars(base: &mut Vec<EnvVar>, other: &[EnvVar]) {
478    for var in other {
479        if let Some(existing) = base.iter_mut().find(|e| e.key == var.key) {
480            // Key already exists — collect provenance
481            for d in &var.detected_by {
482                if !existing.detected_by.contains(d) {
483                    existing.detected_by.push(d.clone());
484                }
485            }
486        } else {
487            base.push(var.clone());
488        }
489    }
490}
491
492/// Merge string vecs with dedup. Items from `other` are appended if not already in `base`.
493pub fn merge_string_vecs(base: &mut Vec<String>, other: &[String]) {
494    for item in other {
495        if !base.contains(item) {
496            base.push(item.clone());
497        }
498    }
499}
500
501// -- Enums --
502
503#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
504#[serde(rename_all = "snake_case")]
505pub enum Language {
506    #[serde(rename = "javascript")]
507    JavaScript,
508    #[serde(rename = "typescript")]
509    TypeScript,
510    Python,
511    Go,
512    Rust,
513    Ruby,
514    Php,
515    Java,
516    Elixir,
517    Html,
518}
519
520impl Language {
521    /// JS and TS are the same language group (both run on Node/Deno/Bun).
522    pub fn is_js_ts(self) -> bool {
523        matches!(self, Language::JavaScript | Language::TypeScript)
524    }
525
526    /// Whether two languages belong to the same runtime group.
527    /// JS/TS share a group; everything else is its own group.
528    pub fn same_group(self, other: Language) -> bool {
529        if self.is_js_ts() && other.is_js_ts() {
530            return true;
531        }
532        self == other
533    }
534}
535
536#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
537#[serde(rename_all = "snake_case")]
538pub enum Network {
539    Private,
540    Public,
541}
542
543#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
544#[serde(rename_all = "snake_case")]
545pub enum ExecMode {
546    Daemon,
547    Scheduled,
548}
549
550#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
551#[serde(rename_all = "snake_case")]
552pub enum Restart {
553    Never,
554    Always,
555    OnFailure,
556}
557
558// -- Infrastructure types --
559
560#[derive(Debug, Clone, Serialize, Deserialize)]
561pub struct Monorepo {
562    #[serde(rename = "type")]
563    pub monorepo_type: String,
564    #[serde(skip_serializing_if = "Option::is_none")]
565    pub tool: Option<String>,
566    pub packages: HashMap<String, MonorepoPackage>,
567}
568
569#[derive(Debug, Clone, Serialize, Deserialize)]
570pub struct MonorepoPackage {
571    pub name: String,
572    pub dir: String,
573    #[serde(default, skip_serializing_if = "Vec::is_empty")]
574    pub watch_patterns: Vec<String>,
575}
576
577#[derive(Debug, Clone, Serialize, Deserialize)]
578pub struct Volume {
579    pub mount: String,
580    #[serde(skip_serializing_if = "Option::is_none")]
581    pub size_mb: Option<u32>,
582}
583
584#[derive(Debug, Clone, Serialize, Deserialize)]
585pub struct Resources {
586    #[serde(skip_serializing_if = "Option::is_none")]
587    pub cpus: Option<u32>,
588    #[serde(skip_serializing_if = "Option::is_none")]
589    pub memory_mb: Option<u32>,
590}
591
592// -- DirContext --
593
594/// Directory-level context emitted by context signals.
595/// Describes a directory's language, runtime, framework, commands, and env vars
596/// without knowing how many services live there.
597#[derive(Debug, Clone, Default, Serialize, Deserialize)]
598pub struct DirContext {
599    pub dir: String,
600    #[serde(skip_serializing_if = "Option::is_none")]
601    pub language: Option<Language>,
602    #[serde(skip_serializing_if = "Option::is_none")]
603    pub runtime: Option<RuntimeInfo>,
604    #[serde(skip_serializing_if = "Option::is_none")]
605    pub framework: Option<String>,
606    #[serde(skip_serializing_if = "Option::is_none")]
607    pub package_manager: Option<PackageManagerInfo>,
608    #[serde(skip_serializing_if = "Option::is_none")]
609    pub language_config: Option<LanguageConfig>,
610    #[serde(skip_serializing_if = "Option::is_none")]
611    pub output_dir: Option<String>,
612    pub commands: Commands,
613    #[serde(skip_serializing_if = "Option::is_none")]
614    pub healthcheck: Option<String>,
615    #[serde(default, skip_serializing_if = "Vec::is_empty")]
616    pub env: Vec<EnvVar>,
617    #[serde(default, skip_serializing_if = "Vec::is_empty")]
618    pub system_deps: Vec<String>,
619}
620
621impl DirContext {
622    /// Merge another context into self. First non-None wins per field.
623    /// Env vars are extended: self's keys take priority.
624    pub fn merge(&mut self, other: &DirContext) {
625        if self.language.is_none() {
626            self.language = other.language;
627        }
628        if self.runtime.is_none() {
629            self.runtime.clone_from(&other.runtime);
630        }
631        if self.framework.is_none() {
632            self.framework.clone_from(&other.framework);
633        }
634        if self.package_manager.is_none() {
635            self.package_manager.clone_from(&other.package_manager);
636        }
637        if self.language_config.is_none() {
638            self.language_config.clone_from(&other.language_config);
639        }
640        if self.output_dir.is_none() {
641            self.output_dir.clone_from(&other.output_dir);
642        }
643        self.commands.fill_from(&other.commands);
644        if self.healthcheck.is_none() {
645            self.healthcheck.clone_from(&other.healthcheck);
646        }
647        merge_env_vars(&mut self.env, &other.env);
648        merge_string_vecs(&mut self.system_deps, &other.system_deps);
649    }
650}