1use anyhow::{Context, Result};
6use serde::{Deserialize, Serialize};
7use std::path::PathBuf;
8
9#[derive(Debug, Serialize, Deserialize)]
10pub struct ProjectConfig {
11 pub name: String,
12 pub version: String,
13 pub robot: RobotConfig,
14 #[serde(default, skip_serializing_if = "Option::is_none")]
15 pub simulation: Option<ProjectSimulationConfig>,
16 #[serde(default, skip_serializing_if = "Option::is_none")]
17 pub lifecycle: Option<LifecycleConfig>,
18 #[serde(default)]
19 pub nodes: NodesConfig,
20 #[serde(default, skip_serializing_if = "Option::is_none")]
21 pub targets: Option<TargetsConfig>,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub behaviors: Option<BehaviorsConfig>,
24 #[serde(default)]
25 pub services: ServicesConfig,
26 #[serde(default)]
27 pub docker: DockerServicesConfig,
28 #[serde(default)]
29 pub environments: EnvironmentsConfig,
30}
31
32#[derive(Debug, Clone, PartialEq)]
38pub enum NodeSource {
39 Framework,
41 Project,
43 Registry(String),
45}
46
47#[derive(Debug, Clone)]
59pub struct NodeSpec {
60 pub name: String,
62 #[allow(dead_code)] pub identifier: String,
65 pub source: NodeSource,
67}
68
69impl NodeSpec {
70 pub fn parse(identifier: &str) -> Result<Self> {
89 let identifier = identifier.trim();
90
91 if !identifier.starts_with('@') {
93 if identifier.is_empty() {
94 anyhow::bail!("Empty node identifier");
95 }
96 return Ok(Self {
97 name: identifier.to_string(),
98 identifier: identifier.to_string(),
99 source: NodeSource::Project,
100 });
101 }
102
103 let without_at = identifier.strip_prefix('@').unwrap();
104 let parts: Vec<&str> = without_at.splitn(2, '/').collect();
105
106 if parts.len() != 2 {
107 anyhow::bail!(
108 "Invalid node identifier '{}'. Expected @<scope>/<name> format",
109 identifier
110 );
111 }
112
113 let scope = parts[0];
114 let name = parts[1].to_string();
115
116 if scope.is_empty() || name.is_empty() {
117 anyhow::bail!("Empty scope or name in identifier: {}", identifier);
118 }
119
120 let source = match scope {
121 "mecha10" => NodeSource::Framework,
122 "local" => NodeSource::Project,
123 org => NodeSource::Registry(org.to_string()),
124 };
125
126 Ok(Self {
127 name,
128 identifier: identifier.to_string(),
129 source,
130 })
131 }
132
133 pub fn is_framework(&self) -> bool {
135 matches!(self.source, NodeSource::Framework)
136 }
137
138 #[allow(dead_code)] pub fn is_project(&self) -> bool {
141 matches!(self.source, NodeSource::Project)
142 }
143
144 pub fn package_path(&self) -> String {
150 match &self.source {
151 NodeSource::Framework => format!("mecha10-nodes-{}", self.name),
152 NodeSource::Project => format!("nodes/{}", self.name),
153 NodeSource::Registry(org) => format!("node_modules/@{}/{}", org, self.name),
154 }
155 }
156
157 #[allow(dead_code)] pub fn config_dir(&self) -> String {
166 match &self.source {
167 NodeSource::Framework => format!("configs/nodes/{}", self.identifier),
168 NodeSource::Project => format!("configs/nodes/{}", self.name),
169 NodeSource::Registry(_) => format!("configs/nodes/{}", self.identifier),
170 }
171 }
172}
173
174#[derive(Debug, Serialize, Deserialize, Default, Clone)]
192#[serde(transparent)]
193pub struct NodesConfig(pub Vec<String>);
194
195impl NodesConfig {
196 #[allow(dead_code)] pub fn new() -> Self {
199 Self(Vec::new())
200 }
201
202 pub fn get_node_specs(&self) -> Vec<NodeSpec> {
204 self.0.iter().filter_map(|id| NodeSpec::parse(id).ok()).collect()
205 }
206
207 pub fn get_node_names(&self) -> Vec<String> {
209 self.get_node_specs().iter().map(|s| s.name.clone()).collect()
210 }
211
212 pub fn find_by_name(&self, name: &str) -> Option<NodeSpec> {
214 self.get_node_specs().into_iter().find(|s| s.name == name)
215 }
216
217 pub fn contains(&self, name: &str) -> bool {
219 self.find_by_name(name).is_some()
220 }
221
222 pub fn add_node(&mut self, identifier: &str) {
224 if !self.0.contains(&identifier.to_string()) {
225 self.0.push(identifier.to_string());
226 }
227 }
228
229 #[allow(dead_code)] pub fn remove_node(&mut self, name: &str) {
232 self.0
233 .retain(|id| NodeSpec::parse(id).map(|s| s.name != name).unwrap_or(true));
234 }
235
236 #[allow(dead_code)] pub fn identifiers(&self) -> &[String] {
239 &self.0
240 }
241
242 #[allow(dead_code)] pub fn is_empty(&self) -> bool {
245 self.0.is_empty()
246 }
247
248 #[allow(dead_code)] pub fn len(&self) -> usize {
251 self.0.len()
252 }
253}
254
255#[derive(Debug, Serialize, Deserialize, Clone, Default)]
275pub struct TargetsConfig {
276 #[serde(default)]
279 pub robot: Vec<String>,
280
281 #[serde(default)]
284 pub remote: Vec<String>,
285}
286
287#[derive(Debug, Clone, Copy, PartialEq, Eq)]
289#[allow(dead_code)] pub enum NodeTarget {
291 Robot,
293 Remote,
295 Default,
297}
298
299#[allow(dead_code)] impl TargetsConfig {
301 pub fn get_target(&self, node_id: &str) -> NodeTarget {
311 if self.robot.iter().any(|n| n == node_id) {
312 NodeTarget::Robot
313 } else if self.remote.iter().any(|n| n == node_id) {
314 NodeTarget::Remote
315 } else {
316 NodeTarget::Default
317 }
318 }
319
320 pub fn is_remote(&self, node_id: &str) -> bool {
322 self.remote.iter().any(|n| n == node_id)
323 }
324
325 pub fn is_robot(&self, node_id: &str) -> bool {
327 self.robot.iter().any(|n| n == node_id)
328 }
329
330 pub fn remote_nodes(&self) -> &[String] {
332 &self.remote
333 }
334
335 #[allow(dead_code)]
337 pub fn robot_nodes(&self) -> &[String] {
338 &self.robot
339 }
340
341 pub fn has_remote_nodes(&self) -> bool {
343 !self.remote.is_empty()
344 }
345
346 pub fn has_custom_remote_nodes(&self) -> bool {
348 self.remote
349 .iter()
350 .any(|n| n.starts_with("@local/") || !n.starts_with('@'))
351 }
352
353 pub fn framework_remote_nodes(&self) -> Vec<&str> {
355 self.remote
356 .iter()
357 .filter(|n| n.starts_with("@mecha10/"))
358 .map(|s| s.as_str())
359 .collect()
360 }
361
362 pub fn sorted_remote_nodes(&self) -> Vec<String> {
364 let mut nodes = self.remote.clone();
365 nodes.sort();
366 nodes
367 }
368}
369
370#[derive(Debug, Serialize, Deserialize, Clone)]
374pub struct BehaviorsConfig {
375 pub active: String,
377}
378
379#[derive(Debug, Serialize, Deserialize, Default)]
380pub struct ServicesConfig {
381 #[serde(default)]
382 pub http_api: Option<HttpApiServiceConfig>,
383 #[serde(default)]
384 pub database: Option<DatabaseServiceConfig>,
385 #[serde(default)]
386 pub job_processor: Option<JobProcessorServiceConfig>,
387 #[serde(default)]
388 pub scheduler: Option<SchedulerServiceConfig>,
389}
390
391#[derive(Debug, Serialize, Deserialize, Clone)]
392pub struct HttpApiServiceConfig {
393 pub host: String,
394 pub port: u16,
395 #[serde(default = "default_enable_cors")]
396 pub _enable_cors: bool,
397}
398
399#[derive(Debug, Serialize, Deserialize, Clone)]
400pub struct DatabaseServiceConfig {
401 pub url: String,
402 #[serde(default = "default_max_connections")]
403 pub max_connections: u32,
404 #[serde(default = "default_timeout_seconds")]
405 pub timeout_seconds: u64,
406}
407
408#[derive(Debug, Serialize, Deserialize, Clone)]
409pub struct JobProcessorServiceConfig {
410 #[serde(default = "default_worker_count")]
411 pub worker_count: usize,
412 #[serde(default = "default_max_queue_size")]
413 pub max_queue_size: usize,
414}
415
416#[derive(Debug, Serialize, Deserialize, Clone)]
417pub struct SchedulerServiceConfig {
418 pub tasks: Vec<ScheduledTaskConfig>,
419}
420
421#[derive(Debug, Serialize, Deserialize, Clone)]
422#[allow(dead_code)]
423pub struct ScheduledTaskConfig {
424 pub name: String,
425 pub cron: String,
426 pub topic: String,
427 pub payload: String,
428}
429
430#[derive(Debug, Serialize, Deserialize)]
431pub struct RobotConfig {
432 pub id: String,
433 #[serde(default)]
434 pub platform: Option<String>,
435 #[serde(default)]
436 pub description: Option<String>,
437}
438
439#[derive(Debug, Serialize, Deserialize, Clone)]
444pub struct ProjectSimulationConfig {
445 #[serde(default, skip_serializing_if = "Option::is_none")]
448 pub scenario: Option<String>,
449
450 #[serde(default, skip_serializing_if = "Option::is_none")]
451 pub model: Option<String>,
452 #[serde(default, skip_serializing_if = "Option::is_none")]
453 pub model_config: Option<String>,
454 #[serde(default, skip_serializing_if = "Option::is_none")]
455 pub environment: Option<String>,
456 #[serde(default, skip_serializing_if = "Option::is_none")]
457 pub environment_config: Option<String>,
458}
459
460#[derive(Debug, Serialize, Deserialize, Clone)]
465pub struct LifecycleConfig {
466 pub modes: std::collections::HashMap<String, ModeConfig>,
468
469 #[serde(default = "default_lifecycle_mode")]
471 pub default_mode: String,
472}
473
474#[derive(Debug, Serialize, Deserialize, Clone)]
478pub struct ModeConfig {
479 pub nodes: Vec<String>,
481}
482
483fn default_lifecycle_mode() -> String {
484 "startup".to_string()
485}
486
487impl LifecycleConfig {
489 #[allow(dead_code)] pub fn validate(&self, available_nodes: &[String]) -> Result<()> {
502 if !self.modes.contains_key(&self.default_mode) {
504 return Err(anyhow::anyhow!(
505 "Default mode '{}' not found in modes. Available modes: {}",
506 self.default_mode,
507 self.modes.keys().map(|k| k.as_str()).collect::<Vec<_>>().join(", ")
508 ));
509 }
510
511 for (mode_name, mode_config) in &self.modes {
513 mode_config.validate(mode_name, available_nodes)?;
514 }
515
516 Ok(())
517 }
518}
519
520impl ModeConfig {
521 #[allow(dead_code)] pub fn validate(&self, mode_name: &str, available_nodes: &[String]) -> Result<()> {
533 for node in &self.nodes {
535 if !available_nodes.contains(node) {
536 return Err(anyhow::anyhow!(
537 "Mode '{}': node '{}' not found in project configuration. Available nodes: {}",
538 mode_name,
539 node,
540 available_nodes.join(", ")
541 ));
542 }
543 }
544
545 Ok(())
546 }
547}
548
549#[derive(Debug, Serialize, Deserialize, Default, Clone)]
551pub struct DockerServicesConfig {
552 #[serde(default)]
554 pub robot: Vec<String>,
555
556 #[serde(default)]
558 pub edge: Vec<String>,
559
560 #[serde(default = "default_compose_file")]
562 pub compose_file: String,
563
564 #[serde(default = "default_auto_start")]
566 pub auto_start: bool,
567}
568
569#[derive(Debug, Serialize, Deserialize, Clone)]
577pub struct EnvironmentsConfig {
578 #[serde(default = "default_dev_environment")]
580 pub dev: EnvironmentConfig,
581
582 #[serde(default, skip_serializing_if = "Option::is_none")]
584 pub staging: Option<EnvironmentConfig>,
585
586 #[serde(default = "default_prod_environment")]
588 pub prod: EnvironmentConfig,
589}
590
591impl Default for EnvironmentsConfig {
592 fn default() -> Self {
593 Self {
594 dev: default_dev_environment(),
595 staging: None,
596 prod: default_prod_environment(),
597 }
598 }
599}
600
601impl EnvironmentsConfig {
602 pub fn get(&self, env: &str) -> Option<&EnvironmentConfig> {
604 match env {
605 "dev" | "development" => Some(&self.dev),
606 "staging" => self.staging.as_ref(),
607 "prod" | "production" => Some(&self.prod),
608 _ => None,
609 }
610 }
611
612 pub fn current(&self) -> &EnvironmentConfig {
614 let env = std::env::var("MECHA10_ENV").unwrap_or_else(|_| "dev".to_string());
615 self.get(&env).unwrap_or(&self.dev)
616 }
617
618 pub fn control_plane_url(&self) -> String {
620 std::env::var("MECHA10_CONTROL_PLANE_URL").unwrap_or_else(|_| self.current().control_plane.clone())
621 }
622
623 #[allow(dead_code)]
626 pub fn api_url(&self) -> String {
627 format!("{}/api", self.control_plane_url())
628 }
629
630 pub fn dashboard_url(&self) -> String {
633 std::env::var("MECHA10_DASHBOARD_URL").unwrap_or_else(|_| format!("{}/dashboard", self.control_plane_url()))
634 }
635
636 pub fn relay_url(&self) -> String {
641 if let Ok(url) = std::env::var("WEBRTC_RELAY_URL") {
642 return url;
643 }
644
645 let control_plane = self.control_plane_url();
646 let ws_url = if control_plane.starts_with("https://") {
648 control_plane.replace("https://", "wss://")
649 } else if control_plane.starts_with("http://") {
650 control_plane.replace("http://", "ws://")
651 } else {
652 control_plane
653 };
654 format!("{}/webrtc-relay", ws_url)
655 }
656
657 pub fn redis_url(&self) -> String {
659 std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://localhost:6380".to_string())
660 }
661}
662
663#[derive(Debug, Serialize, Deserialize, Clone)]
665pub struct EnvironmentConfig {
666 pub control_plane: String,
668}
669
670fn default_dev_environment() -> EnvironmentConfig {
671 EnvironmentConfig {
672 control_plane: "http://localhost:8100".to_string(),
673 }
674}
675
676fn default_prod_environment() -> EnvironmentConfig {
677 EnvironmentConfig {
678 control_plane: "https://mecha.industries".to_string(),
679 }
680}
681
682fn default_enable_cors() -> bool {
684 true
685}
686
687fn default_max_connections() -> u32 {
688 10
689}
690
691fn default_timeout_seconds() -> u64 {
692 30
693}
694
695fn default_worker_count() -> usize {
696 4
697}
698
699fn default_max_queue_size() -> usize {
700 100
701}
702
703fn default_compose_file() -> String {
704 "docker/docker-compose.yml".to_string()
705}
706
707fn default_auto_start() -> bool {
708 true
709}
710
711#[allow(dead_code)]
713pub async fn load_robot_id(config_path: &PathBuf) -> Result<String> {
714 let content = tokio::fs::read_to_string(config_path)
715 .await
716 .context("Failed to read mecha10.json")?;
717
718 let config: ProjectConfig = serde_json::from_str(&content).context("Failed to parse mecha10.json")?;
719
720 Ok(config.robot.id)
721}
722
723pub async fn load_project_config(config_path: &PathBuf) -> Result<ProjectConfig> {
725 let content = tokio::fs::read_to_string(config_path)
726 .await
727 .context("Failed to read mecha10.json")?;
728
729 let config: ProjectConfig = serde_json::from_str(&content).context("Failed to parse mecha10.json")?;
730
731 Ok(config)
732}