paraglide-launch 0.1.2

Analyze a project and detect deployable services, languages, frameworks, commands, and env vars
Documentation
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Discovery {
    pub services: Vec<Service>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub monorepo: Option<Monorepo>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Service {
    pub name: String,
    pub dir: String,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub language: Option<Language>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub runtime: Option<RuntimeInfo>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub framework: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub package_manager: Option<PackageManagerInfo>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub language_config: Option<LanguageConfig>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub network: Option<Network>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub exec_mode: Option<ExecMode>,
    pub commands: Commands,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub image: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub dockerfile: Option<String>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub output_dir: Option<String>,

    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub env: Vec<EnvVar>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub system_deps: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub volumes: Vec<Volume>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub resources: Option<Resources>,

    #[serde(skip_serializing_if = "Option::is_none")]
    pub replicas: Option<u32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub restart: Option<Restart>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub healthcheck: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub schedule: Option<String>,

    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub detected_by: Vec<String>,
}

impl Service {
    /// Fill None fields from directory context. Service fields win.
    /// Env vars are merged: service vars kept, context vars added if key not present.
    pub fn layer_context(&mut self, ctx: &DirContext) {
        if self.language.is_none() {
            self.language = ctx.language;
        }
        if self.runtime.is_none() {
            self.runtime.clone_from(&ctx.runtime);
        }
        if self.framework.is_none() {
            self.framework.clone_from(&ctx.framework);
        }
        if self.package_manager.is_none() {
            self.package_manager.clone_from(&ctx.package_manager);
        }
        if self.language_config.is_none() {
            self.language_config.clone_from(&ctx.language_config);
        }
        if self.output_dir.is_none() {
            self.output_dir.clone_from(&ctx.output_dir);
        }
        self.commands.fill_from(&ctx.commands);
        merge_env_vars(&mut self.env, &ctx.env);
        merge_string_vecs(&mut self.system_deps, &ctx.system_deps);
    }
}

// -- RuntimeInfo & PackageManagerInfo --

/// What runtime to install and how to get the right version.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuntimeInfo {
    pub name: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub version: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub source: Option<String>,
}

/// Which package manager the project uses and its pinned version.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackageManagerInfo {
    pub name: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub version: Option<String>,
}

// -- Language-specific config --

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum LanguageConfig {
    Node(NodeConfig),
    Python(PythonConfig),
    Go(GoConfig),
    Rust(RustConfig),
    Ruby(RubyConfig),
    Php(PhpConfig),
    Java(JavaConfig),
    Elixir(ElixirConfig),
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeConfig {
    pub corepack: bool,
    pub has_puppeteer: bool,
    pub has_prisma: bool,
    pub has_sharp: bool,
    /// Framework builds to static output (Vite SPA, Astro static, CRA, Gatsby, Angular SPA)
    pub is_spa: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PythonConfig {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub wsgi_app: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub asgi_app: Option<String>,
    pub has_manage_py: bool,
    /// Detected entry file: main.py, app.py, wsgi.py, asgi.py
    #[serde(skip_serializing_if = "Option::is_none")]
    pub main_file: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GoConfig {
    pub cgo: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub binary_target: Option<String>,
    pub workspace: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RustConfig {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub edition: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub binary_name: Option<String>,
    /// [workspace] section detected in Cargo.toml
    pub workspace: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RubyConfig {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub asset_pipeline: Option<String>,
    pub needs_node: bool,
    /// bootsnap gem detected — enables boot-time caching
    pub has_bootsnap: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ElixirConfig {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub erlang_version: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub erlang_version_source: Option<String>,
    /// app name from `app: :my_app` in mix.exs — binary name for mix release
    #[serde(skip_serializing_if = "Option::is_none")]
    pub app_name: Option<String>,
    /// mix assets.deploy alias detected
    pub has_assets_deploy: bool,
    /// ecto dependency detected — needs mix ecto.migrate
    pub has_ecto: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PhpConfig {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub extensions: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JavaConfig {
    pub build_tool_wrapper: bool,
}

// -- Commands --

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Commands {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub install: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub build: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub start: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub dev: Option<String>,
}

impl Commands {
    pub fn is_empty(&self) -> bool {
        self.install.is_none() && self.build.is_none() && self.start.is_none() && self.dev.is_none()
    }

    /// Fill None fields from another Commands. Self wins.
    pub fn fill_from(&mut self, other: &Commands) {
        if self.install.is_none() {
            self.install.clone_from(&other.install);
        }
        if self.build.is_none() {
            self.build.clone_from(&other.build);
        }
        if self.start.is_none() {
            self.start.clone_from(&other.start);
        }
        if self.dev.is_none() {
            self.dev.clone_from(&other.dev);
        }
    }
}

// -- EnvVar --

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnvVar {
    pub key: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub default: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub detected_by: Vec<String>,
}

/// Merge env vars from `other` into `base`.
/// For duplicate keys: base wins for value/default, but detected_by is collected from both.
/// New keys from other are appended.
pub fn merge_env_vars(base: &mut Vec<EnvVar>, other: &[EnvVar]) {
    for var in other {
        if let Some(existing) = base.iter_mut().find(|e| e.key == var.key) {
            // Key already exists — collect provenance
            for d in &var.detected_by {
                if !existing.detected_by.contains(d) {
                    existing.detected_by.push(d.clone());
                }
            }
        } else {
            base.push(var.clone());
        }
    }
}

/// Merge string vecs with dedup. Items from `other` are appended if not already in `base`.
pub fn merge_string_vecs(base: &mut Vec<String>, other: &[String]) {
    for item in other {
        if !base.contains(item) {
            base.push(item.clone());
        }
    }
}

// -- Enums --

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Language {
    #[serde(rename = "javascript")]
    JavaScript,
    #[serde(rename = "typescript")]
    TypeScript,
    Python,
    Go,
    Rust,
    Ruby,
    Php,
    Java,
    Elixir,
    Html,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Network {
    Private,
    Public,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExecMode {
    Daemon,
    Scheduled,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Restart {
    Never,
    Always,
    OnFailure,
}

// -- Infrastructure types --

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Monorepo {
    #[serde(rename = "type")]
    pub monorepo_type: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tool: Option<String>,
    pub packages: HashMap<String, MonorepoPackage>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MonorepoPackage {
    pub name: String,
    pub dir: String,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub watch_patterns: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Volume {
    pub mount: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub size_mb: Option<u32>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Resources {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cpus: Option<u32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub memory_mb: Option<u32>,
}

// -- DirContext --

/// Directory-level context emitted by context signals.
/// Describes a directory's language, runtime, framework, commands, and env vars
/// without knowing how many services live there.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DirContext {
    pub dir: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub language: Option<Language>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub runtime: Option<RuntimeInfo>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub framework: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub package_manager: Option<PackageManagerInfo>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub language_config: Option<LanguageConfig>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub output_dir: Option<String>,
    pub commands: Commands,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub env: Vec<EnvVar>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub system_deps: Vec<String>,
}

impl DirContext {
    /// Merge another context into self. First non-None wins per field.
    /// Env vars are extended: self's keys take priority.
    pub fn merge(&mut self, other: &DirContext) {
        if self.language.is_none() {
            self.language = other.language;
        }
        if self.runtime.is_none() {
            self.runtime.clone_from(&other.runtime);
        }
        if self.framework.is_none() {
            self.framework.clone_from(&other.framework);
        }
        if self.package_manager.is_none() {
            self.package_manager.clone_from(&other.package_manager);
        }
        if self.language_config.is_none() {
            self.language_config.clone_from(&other.language_config);
        }
        if self.output_dir.is_none() {
            self.output_dir.clone_from(&other.output_dir);
        }
        self.commands.fill_from(&other.commands);
        merge_env_vars(&mut self.env, &other.env);
        merge_string_vecs(&mut self.system_deps, &other.system_deps);
    }
}