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            merge_env_vars(&mut self.env, &ctx.env);
140            merge_string_vecs(&mut self.system_deps, &ctx.system_deps);
141        }
142    }
143
144    /// Sort runtimes: non-JS/TS backends first, JS/TS last.
145    pub fn sort_runtimes(&mut self) {
146        self.runtimes
147            .sort_by_key(|r| if r.language.is_js_ts() { 1 } else { 0 });
148    }
149}
150
151// -- Runtime --
152
153/// One language runtime within a service.
154/// A Rails+React service has two: one for Ruby, one for Node.
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct Runtime {
157    pub language: Language,
158    /// Tool name for provisioning (e.g. "node", "ruby", "deno", "bun").
159    pub name: String,
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub version: Option<String>,
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub version_source: Option<String>,
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub package_manager: Option<PackageManagerInfo>,
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub framework: Option<String>,
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub language_config: Option<LanguageConfig>,
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub output_dir: Option<String>,
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub install: Option<String>,
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub build: Option<String>,
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub start: Option<String>,
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub dev: Option<String>,
180}
181
182impl Runtime {
183    /// Convert a DirContext into a Runtime.
184    /// Returns None if the context has no language and no recognizable runtime name.
185    pub fn from_context(ctx: &DirContext) -> Option<Self> {
186        let language = ctx
187            .language
188            .or_else(|| ctx.runtime.as_ref().and_then(|ri| language_from_runtime_name(&ri.name)))?;
189        let (name, version, version_source) = match &ctx.runtime {
190            Some(ri) => (ri.name.clone(), ri.version.clone(), ri.source.clone()),
191            None => (default_runtime_name(language).into(), None, None),
192        };
193        Some(Runtime {
194            language,
195            name,
196            version,
197            version_source,
198            package_manager: ctx.package_manager.clone(),
199            framework: ctx.framework.clone(),
200            language_config: ctx.language_config.clone(),
201            output_dir: ctx.output_dir.clone(),
202            install: ctx.commands.install.clone(),
203            build: ctx.commands.build.clone(),
204            start: ctx.commands.start.clone(),
205            dev: ctx.commands.dev.clone(),
206        })
207    }
208
209    /// Fill None fields from another Runtime. Self wins.
210    pub fn fill_from(&mut self, other: &Runtime) {
211        if self.version.is_none() {
212            self.version.clone_from(&other.version);
213        }
214        if self.version_source.is_none() {
215            self.version_source.clone_from(&other.version_source);
216        }
217        if self.package_manager.is_none() {
218            self.package_manager.clone_from(&other.package_manager);
219        }
220        if self.framework.is_none() {
221            self.framework.clone_from(&other.framework);
222        }
223        if self.language_config.is_none() {
224            self.language_config.clone_from(&other.language_config);
225        }
226        if self.output_dir.is_none() {
227            self.output_dir.clone_from(&other.output_dir);
228        }
229        if self.install.is_none() {
230            self.install.clone_from(&other.install);
231        }
232        if self.build.is_none() {
233            self.build.clone_from(&other.build);
234        }
235        if self.start.is_none() {
236            self.start.clone_from(&other.start);
237        }
238        if self.dev.is_none() {
239            self.dev.clone_from(&other.dev);
240        }
241    }
242
243    /// Convert back to a DirContext (for layering derived services onto explicit ones).
244    pub fn to_context(&self, dir: &str) -> DirContext {
245        DirContext {
246            dir: dir.to_string(),
247            language: Some(self.language),
248            runtime: Some(RuntimeInfo {
249                name: self.name.clone(),
250                version: self.version.clone(),
251                source: self.version_source.clone(),
252            }),
253            framework: self.framework.clone(),
254            package_manager: self.package_manager.clone(),
255            language_config: self.language_config.clone(),
256            output_dir: self.output_dir.clone(),
257            commands: Commands {
258                install: self.install.clone(),
259                build: self.build.clone(),
260                start: self.start.clone(),
261                dev: self.dev.clone(),
262            },
263            env: Vec::new(),
264            system_deps: Vec::new(),
265        }
266    }
267}
268
269/// Infer language from a runtime tool name (reverse of `default_runtime_name`).
270fn language_from_runtime_name(name: &str) -> Option<Language> {
271    match name {
272        "node" | "deno" | "bun" => Some(Language::JavaScript),
273        "python" | "python3" => Some(Language::Python),
274        "go" => Some(Language::Go),
275        "rust" | "cargo" => Some(Language::Rust),
276        "ruby" => Some(Language::Ruby),
277        "php" => Some(Language::Php),
278        "java" => Some(Language::Java),
279        "elixir" => Some(Language::Elixir),
280        "static" => Some(Language::Html),
281        _ => None,
282    }
283}
284
285pub(crate) fn default_runtime_name(language: Language) -> &'static str {
286    match language {
287        Language::JavaScript | Language::TypeScript => "node",
288        Language::Python => "python",
289        Language::Go => "go",
290        Language::Rust => "rust",
291        Language::Ruby => "ruby",
292        Language::Php => "php",
293        Language::Java => "java",
294        Language::Elixir => "elixir",
295        Language::Html => "static",
296    }
297}
298
299// -- RuntimeInfo & PackageManagerInfo --
300
301/// What runtime to install and how to get the right version.
302/// Used internally in DirContext; flattened into Runtime for the public API.
303#[derive(Debug, Clone, Serialize, Deserialize)]
304pub struct RuntimeInfo {
305    pub name: String,
306    #[serde(skip_serializing_if = "Option::is_none")]
307    pub version: Option<String>,
308    #[serde(skip_serializing_if = "Option::is_none")]
309    pub source: Option<String>,
310}
311
312/// Which package manager the project uses and its pinned version.
313#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct PackageManagerInfo {
315    pub name: String,
316    #[serde(skip_serializing_if = "Option::is_none")]
317    pub version: Option<String>,
318}
319
320// -- Language-specific config --
321
322#[derive(Debug, Clone, Serialize, Deserialize)]
323#[serde(tag = "type", rename_all = "snake_case")]
324pub enum LanguageConfig {
325    Node(NodeConfig),
326    Python(PythonConfig),
327    Go(GoConfig),
328    Rust(RustConfig),
329    Ruby(RubyConfig),
330    Php(PhpConfig),
331    Java(JavaConfig),
332    Elixir(ElixirConfig),
333}
334
335#[derive(Debug, Clone, Serialize, Deserialize)]
336pub struct NodeConfig {
337    pub corepack: bool,
338    pub has_puppeteer: bool,
339    pub has_prisma: bool,
340    pub has_sharp: bool,
341    /// Framework builds to static output (Vite SPA, Astro static, CRA, Gatsby, Angular SPA)
342    pub is_spa: bool,
343}
344
345#[derive(Debug, Clone, Serialize, Deserialize)]
346pub struct PythonConfig {
347    #[serde(skip_serializing_if = "Option::is_none")]
348    pub wsgi_app: Option<String>,
349    #[serde(skip_serializing_if = "Option::is_none")]
350    pub asgi_app: Option<String>,
351    pub has_manage_py: bool,
352    /// Detected entry file: main.py, app.py, wsgi.py, asgi.py
353    #[serde(skip_serializing_if = "Option::is_none")]
354    pub main_file: Option<String>,
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize)]
358pub struct GoConfig {
359    pub cgo: bool,
360    #[serde(skip_serializing_if = "Option::is_none")]
361    pub binary_target: Option<String>,
362    pub workspace: bool,
363}
364
365#[derive(Debug, Clone, Serialize, Deserialize)]
366pub struct RustConfig {
367    #[serde(skip_serializing_if = "Option::is_none")]
368    pub edition: Option<String>,
369    #[serde(skip_serializing_if = "Option::is_none")]
370    pub binary_name: Option<String>,
371    /// [workspace] section detected in Cargo.toml
372    pub workspace: bool,
373}
374
375#[derive(Debug, Clone, Serialize, Deserialize)]
376pub struct RubyConfig {
377    #[serde(skip_serializing_if = "Option::is_none")]
378    pub asset_pipeline: Option<String>,
379    pub needs_node: bool,
380    /// bootsnap gem detected — enables boot-time caching
381    pub has_bootsnap: bool,
382}
383
384#[derive(Debug, Clone, Serialize, Deserialize)]
385pub struct ElixirConfig {
386    #[serde(skip_serializing_if = "Option::is_none")]
387    pub erlang_version: Option<String>,
388    #[serde(skip_serializing_if = "Option::is_none")]
389    pub erlang_version_source: Option<String>,
390    /// app name from `app: :my_app` in mix.exs — binary name for mix release
391    #[serde(skip_serializing_if = "Option::is_none")]
392    pub app_name: Option<String>,
393    /// mix assets.deploy alias detected
394    pub has_assets_deploy: bool,
395    /// ecto dependency detected — needs mix ecto.migrate
396    pub has_ecto: bool,
397}
398
399#[derive(Debug, Clone, Serialize, Deserialize)]
400pub struct PhpConfig {
401    #[serde(default, skip_serializing_if = "Vec::is_empty")]
402    pub extensions: Vec<String>,
403}
404
405#[derive(Debug, Clone, Serialize, Deserialize)]
406pub struct JavaConfig {
407    pub build_tool_wrapper: bool,
408}
409
410// -- Commands --
411
412#[derive(Debug, Clone, Default, Serialize, Deserialize)]
413pub struct Commands {
414    #[serde(skip_serializing_if = "Option::is_none")]
415    pub install: Option<String>,
416    #[serde(skip_serializing_if = "Option::is_none")]
417    pub build: Option<String>,
418    #[serde(skip_serializing_if = "Option::is_none")]
419    pub start: Option<String>,
420    #[serde(skip_serializing_if = "Option::is_none")]
421    pub dev: Option<String>,
422}
423
424impl Commands {
425    pub fn is_empty(&self) -> bool {
426        self.install.is_none() && self.build.is_none() && self.start.is_none() && self.dev.is_none()
427    }
428
429    /// Fill None fields from another Commands. Self wins.
430    pub fn fill_from(&mut self, other: &Commands) {
431        if self.install.is_none() {
432            self.install.clone_from(&other.install);
433        }
434        if self.build.is_none() {
435            self.build.clone_from(&other.build);
436        }
437        if self.start.is_none() {
438            self.start.clone_from(&other.start);
439        }
440        if self.dev.is_none() {
441            self.dev.clone_from(&other.dev);
442        }
443    }
444}
445
446// -- EnvVar --
447
448#[derive(Debug, Clone, Serialize, Deserialize)]
449pub struct EnvVar {
450    pub key: String,
451    #[serde(skip_serializing_if = "Option::is_none")]
452    pub default: Option<String>,
453    #[serde(default, skip_serializing_if = "Vec::is_empty")]
454    pub detected_by: Vec<String>,
455}
456
457/// Merge env vars from `other` into `base`.
458/// For duplicate keys: base wins for value/default, but detected_by is collected from both.
459/// New keys from other are appended.
460pub fn merge_env_vars(base: &mut Vec<EnvVar>, other: &[EnvVar]) {
461    for var in other {
462        if let Some(existing) = base.iter_mut().find(|e| e.key == var.key) {
463            // Key already exists — collect provenance
464            for d in &var.detected_by {
465                if !existing.detected_by.contains(d) {
466                    existing.detected_by.push(d.clone());
467                }
468            }
469        } else {
470            base.push(var.clone());
471        }
472    }
473}
474
475/// Merge string vecs with dedup. Items from `other` are appended if not already in `base`.
476pub fn merge_string_vecs(base: &mut Vec<String>, other: &[String]) {
477    for item in other {
478        if !base.contains(item) {
479            base.push(item.clone());
480        }
481    }
482}
483
484// -- Enums --
485
486#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
487#[serde(rename_all = "snake_case")]
488pub enum Language {
489    #[serde(rename = "javascript")]
490    JavaScript,
491    #[serde(rename = "typescript")]
492    TypeScript,
493    Python,
494    Go,
495    Rust,
496    Ruby,
497    Php,
498    Java,
499    Elixir,
500    Html,
501}
502
503impl Language {
504    /// JS and TS are the same language group (both run on Node/Deno/Bun).
505    pub fn is_js_ts(self) -> bool {
506        matches!(self, Language::JavaScript | Language::TypeScript)
507    }
508
509    /// Whether two languages belong to the same runtime group.
510    /// JS/TS share a group; everything else is its own group.
511    pub fn same_group(self, other: Language) -> bool {
512        if self.is_js_ts() && other.is_js_ts() {
513            return true;
514        }
515        self == other
516    }
517}
518
519#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
520#[serde(rename_all = "snake_case")]
521pub enum Network {
522    Private,
523    Public,
524}
525
526#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
527#[serde(rename_all = "snake_case")]
528pub enum ExecMode {
529    Daemon,
530    Scheduled,
531}
532
533#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
534#[serde(rename_all = "snake_case")]
535pub enum Restart {
536    Never,
537    Always,
538    OnFailure,
539}
540
541// -- Infrastructure types --
542
543#[derive(Debug, Clone, Serialize, Deserialize)]
544pub struct Monorepo {
545    #[serde(rename = "type")]
546    pub monorepo_type: String,
547    #[serde(skip_serializing_if = "Option::is_none")]
548    pub tool: Option<String>,
549    pub packages: HashMap<String, MonorepoPackage>,
550}
551
552#[derive(Debug, Clone, Serialize, Deserialize)]
553pub struct MonorepoPackage {
554    pub name: String,
555    pub dir: String,
556    #[serde(default, skip_serializing_if = "Vec::is_empty")]
557    pub watch_patterns: Vec<String>,
558}
559
560#[derive(Debug, Clone, Serialize, Deserialize)]
561pub struct Volume {
562    pub mount: String,
563    #[serde(skip_serializing_if = "Option::is_none")]
564    pub size_mb: Option<u32>,
565}
566
567#[derive(Debug, Clone, Serialize, Deserialize)]
568pub struct Resources {
569    #[serde(skip_serializing_if = "Option::is_none")]
570    pub cpus: Option<u32>,
571    #[serde(skip_serializing_if = "Option::is_none")]
572    pub memory_mb: Option<u32>,
573}
574
575// -- DirContext --
576
577/// Directory-level context emitted by context signals.
578/// Describes a directory's language, runtime, framework, commands, and env vars
579/// without knowing how many services live there.
580#[derive(Debug, Clone, Default, Serialize, Deserialize)]
581pub struct DirContext {
582    pub dir: String,
583    #[serde(skip_serializing_if = "Option::is_none")]
584    pub language: Option<Language>,
585    #[serde(skip_serializing_if = "Option::is_none")]
586    pub runtime: Option<RuntimeInfo>,
587    #[serde(skip_serializing_if = "Option::is_none")]
588    pub framework: Option<String>,
589    #[serde(skip_serializing_if = "Option::is_none")]
590    pub package_manager: Option<PackageManagerInfo>,
591    #[serde(skip_serializing_if = "Option::is_none")]
592    pub language_config: Option<LanguageConfig>,
593    #[serde(skip_serializing_if = "Option::is_none")]
594    pub output_dir: Option<String>,
595    pub commands: Commands,
596    #[serde(default, skip_serializing_if = "Vec::is_empty")]
597    pub env: Vec<EnvVar>,
598    #[serde(default, skip_serializing_if = "Vec::is_empty")]
599    pub system_deps: Vec<String>,
600}
601
602impl DirContext {
603    /// Merge another context into self. First non-None wins per field.
604    /// Env vars are extended: self's keys take priority.
605    pub fn merge(&mut self, other: &DirContext) {
606        if self.language.is_none() {
607            self.language = other.language;
608        }
609        if self.runtime.is_none() {
610            self.runtime.clone_from(&other.runtime);
611        }
612        if self.framework.is_none() {
613            self.framework.clone_from(&other.framework);
614        }
615        if self.package_manager.is_none() {
616            self.package_manager.clone_from(&other.package_manager);
617        }
618        if self.language_config.is_none() {
619            self.language_config.clone_from(&other.language_config);
620        }
621        if self.output_dir.is_none() {
622            self.output_dir.clone_from(&other.output_dir);
623        }
624        self.commands.fill_from(&other.commands);
625        merge_env_vars(&mut self.env, &other.env);
626        merge_string_vecs(&mut self.system_deps, &other.system_deps);
627    }
628}