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(skip_serializing_if = "Option::is_none")]
17    pub language: Option<Language>,
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub runtime: Option<RuntimeInfo>,
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub framework: Option<String>,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub package_manager: Option<PackageManagerInfo>,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub language_config: Option<LanguageConfig>,
26
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub network: Option<Network>,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub exec_mode: Option<ExecMode>,
31    pub commands: Commands,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub image: Option<String>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub dockerfile: Option<String>,
36
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub output_dir: Option<String>,
39
40    #[serde(default, skip_serializing_if = "Vec::is_empty")]
41    pub env: Vec<EnvVar>,
42    #[serde(default, skip_serializing_if = "Vec::is_empty")]
43    pub system_deps: Vec<String>,
44    #[serde(default, skip_serializing_if = "Vec::is_empty")]
45    pub volumes: Vec<Volume>,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub resources: Option<Resources>,
48
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub replicas: Option<u32>,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub restart: Option<Restart>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub healthcheck: Option<String>,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub schedule: Option<String>,
57
58    #[serde(default, skip_serializing_if = "Vec::is_empty")]
59    pub detected_by: Vec<String>,
60}
61
62impl Service {
63    /// Fill None fields from directory context. Service fields win.
64    /// Env vars are merged: service vars kept, context vars added if key not present.
65    pub fn layer_context(&mut self, ctx: &DirContext) {
66        if self.language.is_none() {
67            self.language = ctx.language;
68        }
69        if self.runtime.is_none() {
70            self.runtime.clone_from(&ctx.runtime);
71        }
72        if self.framework.is_none() {
73            self.framework.clone_from(&ctx.framework);
74        }
75        if self.package_manager.is_none() {
76            self.package_manager.clone_from(&ctx.package_manager);
77        }
78        if self.language_config.is_none() {
79            self.language_config.clone_from(&ctx.language_config);
80        }
81        if self.output_dir.is_none() {
82            self.output_dir.clone_from(&ctx.output_dir);
83        }
84        self.commands.fill_from(&ctx.commands);
85        merge_env_vars(&mut self.env, &ctx.env);
86        merge_string_vecs(&mut self.system_deps, &ctx.system_deps);
87    }
88}
89
90// -- RuntimeInfo & PackageManagerInfo --
91
92/// What runtime to install and how to get the right version.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct RuntimeInfo {
95    pub name: String,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub version: Option<String>,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub source: Option<String>,
100}
101
102/// Which package manager the project uses and its pinned version.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct PackageManagerInfo {
105    pub name: String,
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub version: Option<String>,
108}
109
110// -- Language-specific config --
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
113#[serde(tag = "type", rename_all = "snake_case")]
114pub enum LanguageConfig {
115    Node(NodeConfig),
116    Python(PythonConfig),
117    Go(GoConfig),
118    Rust(RustConfig),
119    Ruby(RubyConfig),
120    Php(PhpConfig),
121    Java(JavaConfig),
122    Elixir(ElixirConfig),
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub struct NodeConfig {
127    pub corepack: bool,
128    pub has_puppeteer: bool,
129    pub has_prisma: bool,
130    pub has_sharp: bool,
131    /// Framework builds to static output (Vite SPA, Astro static, CRA, Gatsby, Angular SPA)
132    pub is_spa: bool,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct PythonConfig {
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub wsgi_app: Option<String>,
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub asgi_app: Option<String>,
141    pub has_manage_py: bool,
142    /// Detected entry file: main.py, app.py, wsgi.py, asgi.py
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub main_file: Option<String>,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct GoConfig {
149    pub cgo: bool,
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub binary_target: Option<String>,
152    pub workspace: bool,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct RustConfig {
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub edition: Option<String>,
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub binary_name: Option<String>,
161    /// [workspace] section detected in Cargo.toml
162    pub workspace: bool,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct RubyConfig {
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub asset_pipeline: Option<String>,
169    pub needs_node: bool,
170    /// bootsnap gem detected — enables boot-time caching
171    pub has_bootsnap: bool,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct ElixirConfig {
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub erlang_version: Option<String>,
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub erlang_version_source: Option<String>,
180    /// app name from `app: :my_app` in mix.exs — binary name for mix release
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub app_name: Option<String>,
183    /// mix assets.deploy alias detected
184    pub has_assets_deploy: bool,
185    /// ecto dependency detected — needs mix ecto.migrate
186    pub has_ecto: bool,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct PhpConfig {
191    #[serde(default, skip_serializing_if = "Vec::is_empty")]
192    pub extensions: Vec<String>,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct JavaConfig {
197    pub build_tool_wrapper: bool,
198}
199
200// -- Commands --
201
202#[derive(Debug, Clone, Default, Serialize, Deserialize)]
203pub struct Commands {
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub install: Option<String>,
206    #[serde(skip_serializing_if = "Option::is_none")]
207    pub build: Option<String>,
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub start: Option<String>,
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub dev: Option<String>,
212}
213
214impl Commands {
215    pub fn is_empty(&self) -> bool {
216        self.install.is_none() && self.build.is_none() && self.start.is_none() && self.dev.is_none()
217    }
218
219    /// Fill None fields from another Commands. Self wins.
220    pub fn fill_from(&mut self, other: &Commands) {
221        if self.install.is_none() {
222            self.install.clone_from(&other.install);
223        }
224        if self.build.is_none() {
225            self.build.clone_from(&other.build);
226        }
227        if self.start.is_none() {
228            self.start.clone_from(&other.start);
229        }
230        if self.dev.is_none() {
231            self.dev.clone_from(&other.dev);
232        }
233    }
234}
235
236// -- EnvVar --
237
238#[derive(Debug, Clone, Serialize, Deserialize)]
239pub struct EnvVar {
240    pub key: String,
241    #[serde(skip_serializing_if = "Option::is_none")]
242    pub default: Option<String>,
243    #[serde(default, skip_serializing_if = "Vec::is_empty")]
244    pub detected_by: Vec<String>,
245}
246
247/// Merge env vars from `other` into `base`.
248/// For duplicate keys: base wins for value/default, but detected_by is collected from both.
249/// New keys from other are appended.
250pub fn merge_env_vars(base: &mut Vec<EnvVar>, other: &[EnvVar]) {
251    for var in other {
252        if let Some(existing) = base.iter_mut().find(|e| e.key == var.key) {
253            // Key already exists — collect provenance
254            for d in &var.detected_by {
255                if !existing.detected_by.contains(d) {
256                    existing.detected_by.push(d.clone());
257                }
258            }
259        } else {
260            base.push(var.clone());
261        }
262    }
263}
264
265/// Merge string vecs with dedup. Items from `other` are appended if not already in `base`.
266pub fn merge_string_vecs(base: &mut Vec<String>, other: &[String]) {
267    for item in other {
268        if !base.contains(item) {
269            base.push(item.clone());
270        }
271    }
272}
273
274// -- Enums --
275
276#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
277#[serde(rename_all = "snake_case")]
278pub enum Language {
279    #[serde(rename = "javascript")]
280    JavaScript,
281    #[serde(rename = "typescript")]
282    TypeScript,
283    Python,
284    Go,
285    Rust,
286    Ruby,
287    Php,
288    Java,
289    Elixir,
290    Html,
291}
292
293#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
294#[serde(rename_all = "snake_case")]
295pub enum Network {
296    Private,
297    Public,
298}
299
300#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
301#[serde(rename_all = "snake_case")]
302pub enum ExecMode {
303    Daemon,
304    Scheduled,
305}
306
307#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
308#[serde(rename_all = "snake_case")]
309pub enum Restart {
310    Never,
311    Always,
312    OnFailure,
313}
314
315// -- Infrastructure types --
316
317#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct Monorepo {
319    #[serde(rename = "type")]
320    pub monorepo_type: String,
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub tool: Option<String>,
323    pub packages: HashMap<String, MonorepoPackage>,
324}
325
326#[derive(Debug, Clone, Serialize, Deserialize)]
327pub struct MonorepoPackage {
328    pub name: String,
329    pub dir: String,
330    #[serde(default, skip_serializing_if = "Vec::is_empty")]
331    pub watch_patterns: Vec<String>,
332}
333
334#[derive(Debug, Clone, Serialize, Deserialize)]
335pub struct Volume {
336    pub mount: String,
337    #[serde(skip_serializing_if = "Option::is_none")]
338    pub size_mb: Option<u32>,
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize)]
342pub struct Resources {
343    #[serde(skip_serializing_if = "Option::is_none")]
344    pub cpus: Option<u32>,
345    #[serde(skip_serializing_if = "Option::is_none")]
346    pub memory_mb: Option<u32>,
347}
348
349// -- DirContext --
350
351/// Directory-level context emitted by context signals.
352/// Describes a directory's language, runtime, framework, commands, and env vars
353/// without knowing how many services live there.
354#[derive(Debug, Clone, Default, Serialize, Deserialize)]
355pub struct DirContext {
356    pub dir: String,
357    #[serde(skip_serializing_if = "Option::is_none")]
358    pub language: Option<Language>,
359    #[serde(skip_serializing_if = "Option::is_none")]
360    pub runtime: Option<RuntimeInfo>,
361    #[serde(skip_serializing_if = "Option::is_none")]
362    pub framework: Option<String>,
363    #[serde(skip_serializing_if = "Option::is_none")]
364    pub package_manager: Option<PackageManagerInfo>,
365    #[serde(skip_serializing_if = "Option::is_none")]
366    pub language_config: Option<LanguageConfig>,
367    #[serde(skip_serializing_if = "Option::is_none")]
368    pub output_dir: Option<String>,
369    pub commands: Commands,
370    #[serde(default, skip_serializing_if = "Vec::is_empty")]
371    pub env: Vec<EnvVar>,
372    #[serde(default, skip_serializing_if = "Vec::is_empty")]
373    pub system_deps: Vec<String>,
374}
375
376impl DirContext {
377    /// Merge another context into self. First non-None wins per field.
378    /// Env vars are extended: self's keys take priority.
379    pub fn merge(&mut self, other: &DirContext) {
380        if self.language.is_none() {
381            self.language = other.language;
382        }
383        if self.runtime.is_none() {
384            self.runtime.clone_from(&other.runtime);
385        }
386        if self.framework.is_none() {
387            self.framework.clone_from(&other.framework);
388        }
389        if self.package_manager.is_none() {
390            self.package_manager.clone_from(&other.package_manager);
391        }
392        if self.language_config.is_none() {
393            self.language_config.clone_from(&other.language_config);
394        }
395        if self.output_dir.is_none() {
396            self.output_dir.clone_from(&other.output_dir);
397        }
398        self.commands.fill_from(&other.commands);
399        merge_env_vars(&mut self.env, &other.env);
400        merge_string_vecs(&mut self.system_deps, &other.system_deps);
401    }
402}