use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Serialize, Deserialize)]
pub struct ProjectConfig {
pub name: String,
pub version: String,
pub robot: RobotConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub simulation: Option<ProjectSimulationConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lifecycle: Option<LifecycleConfig>,
#[serde(default)]
pub nodes: NodesConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub targets: Option<TargetsConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub behaviors: Option<BehaviorsConfig>,
#[serde(default)]
pub services: ServicesConfig,
#[serde(default)]
pub docker: DockerServicesConfig,
#[serde(default)]
pub environments: EnvironmentsConfig,
}
#[derive(Debug, Clone, PartialEq)]
pub enum NodeSource {
Framework,
Project,
Registry(String),
}
#[derive(Debug, Clone)]
pub struct NodeSpec {
pub name: String,
#[allow(dead_code)] pub identifier: String,
pub source: NodeSource,
}
impl NodeSpec {
pub fn parse(identifier: &str) -> Result<Self> {
let identifier = identifier.trim();
if !identifier.starts_with('@') {
if identifier.is_empty() {
anyhow::bail!("Empty node identifier");
}
return Ok(Self {
name: identifier.to_string(),
identifier: identifier.to_string(),
source: NodeSource::Project,
});
}
let without_at = identifier.strip_prefix('@').unwrap();
let parts: Vec<&str> = without_at.splitn(2, '/').collect();
if parts.len() != 2 {
anyhow::bail!(
"Invalid node identifier '{}'. Expected @<scope>/<name> format",
identifier
);
}
let scope = parts[0];
let name = parts[1].to_string();
if scope.is_empty() || name.is_empty() {
anyhow::bail!("Empty scope or name in identifier: {}", identifier);
}
let source = match scope {
"mecha10" => NodeSource::Framework,
"local" => NodeSource::Project,
org => NodeSource::Registry(org.to_string()),
};
Ok(Self {
name,
identifier: identifier.to_string(),
source,
})
}
pub fn is_framework(&self) -> bool {
matches!(self.source, NodeSource::Framework)
}
#[allow(dead_code)] pub fn is_project(&self) -> bool {
matches!(self.source, NodeSource::Project)
}
pub fn package_path(&self) -> String {
match &self.source {
NodeSource::Framework => format!("mecha10-nodes-{}", self.name),
NodeSource::Project => format!("nodes/{}", self.name),
NodeSource::Registry(org) => format!("node_modules/@{}/{}", org, self.name),
}
}
#[allow(dead_code)] pub fn config_dir(&self) -> String {
match &self.source {
NodeSource::Framework => format!("configs/nodes/{}", self.identifier),
NodeSource::Project => format!("configs/nodes/{}", self.name),
NodeSource::Registry(_) => format!("configs/nodes/{}", self.identifier),
}
}
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
#[serde(transparent)]
pub struct NodesConfig(pub Vec<String>);
impl NodesConfig {
#[allow(dead_code)] pub fn new() -> Self {
Self(Vec::new())
}
pub fn get_node_specs(&self) -> Vec<NodeSpec> {
self.0.iter().filter_map(|id| NodeSpec::parse(id).ok()).collect()
}
pub fn get_node_names(&self) -> Vec<String> {
self.get_node_specs().iter().map(|s| s.name.clone()).collect()
}
pub fn find_by_name(&self, name: &str) -> Option<NodeSpec> {
self.get_node_specs().into_iter().find(|s| s.name == name)
}
pub fn contains(&self, name: &str) -> bool {
self.find_by_name(name).is_some()
}
pub fn add_node(&mut self, identifier: &str) {
if !self.0.contains(&identifier.to_string()) {
self.0.push(identifier.to_string());
}
}
#[allow(dead_code)] pub fn remove_node(&mut self, name: &str) {
self.0
.retain(|id| NodeSpec::parse(id).map(|s| s.name != name).unwrap_or(true));
}
#[allow(dead_code)] pub fn identifiers(&self) -> &[String] {
&self.0
}
#[allow(dead_code)] pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
#[allow(dead_code)] pub fn len(&self) -> usize {
self.0.len()
}
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct TargetsConfig {
#[serde(default)]
pub robot: Vec<String>,
#[serde(default)]
pub remote: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)] pub enum NodeTarget {
Robot,
Remote,
Default,
}
#[allow(dead_code)] impl TargetsConfig {
pub fn get_target(&self, node_id: &str) -> NodeTarget {
if self.robot.iter().any(|n| n == node_id) {
NodeTarget::Robot
} else if self.remote.iter().any(|n| n == node_id) {
NodeTarget::Remote
} else {
NodeTarget::Default
}
}
pub fn is_remote(&self, node_id: &str) -> bool {
self.remote.iter().any(|n| n == node_id)
}
pub fn is_robot(&self, node_id: &str) -> bool {
self.robot.iter().any(|n| n == node_id)
}
pub fn remote_nodes(&self) -> &[String] {
&self.remote
}
#[allow(dead_code)]
pub fn robot_nodes(&self) -> &[String] {
&self.robot
}
pub fn has_remote_nodes(&self) -> bool {
!self.remote.is_empty()
}
pub fn has_custom_remote_nodes(&self) -> bool {
self.remote
.iter()
.any(|n| n.starts_with("@local/") || !n.starts_with('@'))
}
pub fn framework_remote_nodes(&self) -> Vec<&str> {
self.remote
.iter()
.filter(|n| n.starts_with("@mecha10/"))
.map(|s| s.as_str())
.collect()
}
pub fn sorted_remote_nodes(&self) -> Vec<String> {
let mut nodes = self.remote.clone();
nodes.sort();
nodes
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BehaviorsConfig {
pub active: String,
}
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct ServicesConfig {
#[serde(default)]
pub http_api: Option<HttpApiServiceConfig>,
#[serde(default)]
pub database: Option<DatabaseServiceConfig>,
#[serde(default)]
pub job_processor: Option<JobProcessorServiceConfig>,
#[serde(default)]
pub scheduler: Option<SchedulerServiceConfig>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct HttpApiServiceConfig {
pub host: String,
pub port: u16,
#[serde(default = "default_enable_cors")]
pub _enable_cors: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DatabaseServiceConfig {
pub url: String,
#[serde(default = "default_max_connections")]
pub max_connections: u32,
#[serde(default = "default_timeout_seconds")]
pub timeout_seconds: u64,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct JobProcessorServiceConfig {
#[serde(default = "default_worker_count")]
pub worker_count: usize,
#[serde(default = "default_max_queue_size")]
pub max_queue_size: usize,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SchedulerServiceConfig {
pub tasks: Vec<ScheduledTaskConfig>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[allow(dead_code)]
pub struct ScheduledTaskConfig {
pub name: String,
pub cron: String,
pub topic: String,
pub payload: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct RobotConfig {
pub id: String,
#[serde(default)]
pub platform: Option<String>,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ProjectSimulationConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scenario: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_config: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub environment: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub environment_config: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct LifecycleConfig {
pub modes: std::collections::HashMap<String, ModeConfig>,
#[serde(default = "default_lifecycle_mode")]
pub default_mode: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ModeConfig {
pub nodes: Vec<String>,
}
fn default_lifecycle_mode() -> String {
"startup".to_string()
}
impl LifecycleConfig {
#[allow(dead_code)] pub fn validate(&self, available_nodes: &[String]) -> Result<()> {
if !self.modes.contains_key(&self.default_mode) {
return Err(anyhow::anyhow!(
"Default mode '{}' not found in modes. Available modes: {}",
self.default_mode,
self.modes.keys().map(|k| k.as_str()).collect::<Vec<_>>().join(", ")
));
}
for (mode_name, mode_config) in &self.modes {
mode_config.validate(mode_name, available_nodes)?;
}
Ok(())
}
}
impl ModeConfig {
#[allow(dead_code)] pub fn validate(&self, mode_name: &str, available_nodes: &[String]) -> Result<()> {
for node in &self.nodes {
if !available_nodes.contains(node) {
return Err(anyhow::anyhow!(
"Mode '{}': node '{}' not found in project configuration. Available nodes: {}",
mode_name,
node,
available_nodes.join(", ")
));
}
}
Ok(())
}
}
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
pub struct DockerServicesConfig {
#[serde(default)]
pub robot: Vec<String>,
#[serde(default)]
pub edge: Vec<String>,
#[serde(default = "default_compose_file")]
pub compose_file: String,
#[serde(default = "default_auto_start")]
pub auto_start: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct EnvironmentsConfig {
#[serde(default = "default_dev_environment")]
pub dev: EnvironmentConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub staging: Option<EnvironmentConfig>,
#[serde(default = "default_prod_environment")]
pub prod: EnvironmentConfig,
}
impl Default for EnvironmentsConfig {
fn default() -> Self {
Self {
dev: default_dev_environment(),
staging: None,
prod: default_prod_environment(),
}
}
}
impl EnvironmentsConfig {
pub fn get(&self, env: &str) -> Option<&EnvironmentConfig> {
match env {
"dev" | "development" => Some(&self.dev),
"staging" => self.staging.as_ref(),
"prod" | "production" => Some(&self.prod),
_ => None,
}
}
pub fn current(&self) -> &EnvironmentConfig {
let env = std::env::var("MECHA10_ENV").unwrap_or_else(|_| "dev".to_string());
self.get(&env).unwrap_or(&self.dev)
}
pub fn control_plane_url(&self) -> String {
std::env::var("MECHA10_CONTROL_PLANE_URL").unwrap_or_else(|_| self.current().control_plane.clone())
}
#[allow(dead_code)]
pub fn api_url(&self) -> String {
format!("{}/api", self.control_plane_url())
}
pub fn dashboard_url(&self) -> String {
std::env::var("MECHA10_DASHBOARD_URL").unwrap_or_else(|_| format!("{}/dashboard", self.control_plane_url()))
}
pub fn relay_url(&self) -> String {
if let Ok(url) = std::env::var("WEBRTC_RELAY_URL") {
return url;
}
let control_plane = self.control_plane_url();
let ws_url = if control_plane.starts_with("https://") {
control_plane.replace("https://", "wss://")
} else if control_plane.starts_with("http://") {
control_plane.replace("http://", "ws://")
} else {
control_plane
};
format!("{}/webrtc-relay", ws_url)
}
pub fn redis_url(&self) -> String {
std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://localhost:6380".to_string())
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct EnvironmentConfig {
pub control_plane: String,
}
fn default_dev_environment() -> EnvironmentConfig {
EnvironmentConfig {
control_plane: "http://localhost:8100".to_string(),
}
}
fn default_prod_environment() -> EnvironmentConfig {
EnvironmentConfig {
control_plane: "https://mecha.industries".to_string(),
}
}
fn default_enable_cors() -> bool {
true
}
fn default_max_connections() -> u32 {
10
}
fn default_timeout_seconds() -> u64 {
30
}
fn default_worker_count() -> usize {
4
}
fn default_max_queue_size() -> usize {
100
}
fn default_compose_file() -> String {
"docker/docker-compose.yml".to_string()
}
fn default_auto_start() -> bool {
true
}
#[allow(dead_code)]
pub async fn load_robot_id(config_path: &PathBuf) -> Result<String> {
let content = tokio::fs::read_to_string(config_path)
.await
.context("Failed to read mecha10.json")?;
let config: ProjectConfig = serde_json::from_str(&content).context("Failed to parse mecha10.json")?;
Ok(config.robot.id)
}
pub async fn load_project_config(config_path: &PathBuf) -> Result<ProjectConfig> {
let content = tokio::fs::read_to_string(config_path)
.await
.context("Failed to read mecha10.json")?;
let config: ProjectConfig = serde_json::from_str(&content).context("Failed to parse mecha10.json")?;
Ok(config)
}