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 merge_env_vars(&mut self.env, &ctx.env);
140 merge_string_vecs(&mut self.system_deps, &ctx.system_deps);
141 }
142 }
143
144 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#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct Runtime {
157 pub language: Language,
158 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 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 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 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
269fn 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#[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#[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#[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 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 #[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 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 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 #[serde(skip_serializing_if = "Option::is_none")]
392 pub app_name: Option<String>,
393 pub has_assets_deploy: bool,
395 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#[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 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#[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
457pub 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 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
475pub 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#[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 pub fn is_js_ts(self) -> bool {
506 matches!(self, Language::JavaScript | Language::TypeScript)
507 }
508
509 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#[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#[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 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}