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 pub fn language(&self) -> Option<Language> {
53 self.runtimes.first().map(|r| r.language)
54 }
55
56 pub fn framework(&self) -> Option<&str> {
58 self.runtimes
59 .iter()
60 .find_map(|r| r.framework.as_deref())
61 }
62
63 pub fn start(&self) -> Option<&str> {
65 self.runtimes
66 .iter()
67 .find_map(|r| r.start.as_deref())
68 }
69
70 pub fn install(&self) -> Option<&str> {
72 self.runtimes
73 .iter()
74 .find_map(|r| r.install.as_deref())
75 }
76
77 pub fn build(&self) -> Option<&str> {
79 self.runtimes
80 .iter()
81 .find_map(|r| r.build.as_deref())
82 }
83
84 pub fn dev(&self) -> Option<&str> {
86 self.runtimes
87 .iter()
88 .find_map(|r| r.dev.as_deref())
89 }
90
91 pub fn output_dir(&self) -> Option<&str> {
93 self.runtimes
94 .iter()
95 .find_map(|r| r.output_dir.as_deref())
96 }
97
98 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct Runtime {
160 pub language: Language,
161 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 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 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 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
273fn 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#[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#[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#[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 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 #[serde(skip_serializing_if = "Option::is_none")]
359 pub main_file: Option<String>,
360 pub has_celery: bool,
362 pub has_whitenoise: bool,
364 #[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 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 #[serde(skip_serializing_if = "Option::is_none")]
407 pub app_name: Option<String>,
408 pub has_assets_deploy: bool,
410 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#[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 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#[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
474pub 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 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
492pub 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#[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 pub fn is_js_ts(self) -> bool {
523 matches!(self, Language::JavaScript | Language::TypeScript)
524 }
525
526 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#[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#[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 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}