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 behaviors: Option<BehaviorsConfig>,
22 #[serde(default)]
23 pub services: ServicesConfig,
24 #[serde(default)]
25 pub docker: DockerServicesConfig,
26 #[serde(default)]
27 pub environments: EnvironmentsConfig,
28}
29
30#[derive(Debug, Clone, PartialEq)]
36pub enum NodeSource {
37 Framework,
39 Project,
41 Registry(String),
43}
44
45#[derive(Debug, Clone)]
56pub struct NodeSpec {
57 pub name: String,
59 #[allow(dead_code)] pub identifier: String,
62 pub source: NodeSource,
64}
65
66impl NodeSpec {
67 pub fn parse(identifier: &str) -> Result<Self> {
81 let identifier = identifier.trim();
82
83 if !identifier.starts_with('@') {
84 anyhow::bail!(
85 "Invalid node identifier '{}'. Expected @mecha10/<name>, @local/<name>, or @<org>/<name>",
86 identifier
87 );
88 }
89
90 let without_at = identifier.strip_prefix('@').unwrap();
91 let parts: Vec<&str> = without_at.splitn(2, '/').collect();
92
93 if parts.len() != 2 {
94 anyhow::bail!(
95 "Invalid node identifier '{}'. Expected @<scope>/<name> format",
96 identifier
97 );
98 }
99
100 let scope = parts[0];
101 let name = parts[1].to_string();
102
103 if scope.is_empty() || name.is_empty() {
104 anyhow::bail!("Empty scope or name in identifier: {}", identifier);
105 }
106
107 let source = match scope {
108 "mecha10" => NodeSource::Framework,
109 "local" => NodeSource::Project,
110 org => NodeSource::Registry(org.to_string()),
111 };
112
113 Ok(Self {
114 name,
115 identifier: identifier.to_string(),
116 source,
117 })
118 }
119
120 pub fn is_framework(&self) -> bool {
122 matches!(self.source, NodeSource::Framework)
123 }
124
125 #[allow(dead_code)] pub fn is_project(&self) -> bool {
128 matches!(self.source, NodeSource::Project)
129 }
130
131 pub fn package_path(&self) -> String {
137 match &self.source {
138 NodeSource::Framework => format!("mecha10-nodes-{}", self.name),
139 NodeSource::Project => format!("nodes/{}", self.name),
140 NodeSource::Registry(org) => format!("node_modules/@{}/{}", org, self.name),
141 }
142 }
143
144 #[allow(dead_code)] pub fn config_dir(&self) -> String {
152 format!("configs/nodes/{}", self.identifier)
153 }
154}
155
156#[derive(Debug, Serialize, Deserialize, Default, Clone)]
174#[serde(transparent)]
175pub struct NodesConfig(pub Vec<String>);
176
177impl NodesConfig {
178 #[allow(dead_code)] pub fn new() -> Self {
181 Self(Vec::new())
182 }
183
184 pub fn get_node_specs(&self) -> Vec<NodeSpec> {
186 self.0.iter().filter_map(|id| NodeSpec::parse(id).ok()).collect()
187 }
188
189 pub fn get_node_names(&self) -> Vec<String> {
191 self.get_node_specs().iter().map(|s| s.name.clone()).collect()
192 }
193
194 pub fn find_by_name(&self, name: &str) -> Option<NodeSpec> {
196 self.get_node_specs().into_iter().find(|s| s.name == name)
197 }
198
199 pub fn contains(&self, name: &str) -> bool {
201 self.find_by_name(name).is_some()
202 }
203
204 pub fn add_node(&mut self, identifier: &str) {
206 if !self.0.contains(&identifier.to_string()) {
207 self.0.push(identifier.to_string());
208 }
209 }
210
211 #[allow(dead_code)] pub fn remove_node(&mut self, name: &str) {
214 self.0
215 .retain(|id| NodeSpec::parse(id).map(|s| s.name != name).unwrap_or(true));
216 }
217
218 #[allow(dead_code)] pub fn identifiers(&self) -> &[String] {
221 &self.0
222 }
223
224 #[allow(dead_code)] pub fn is_empty(&self) -> bool {
227 self.0.is_empty()
228 }
229
230 #[allow(dead_code)] pub fn len(&self) -> usize {
233 self.0.len()
234 }
235}
236
237#[derive(Debug, Serialize, Deserialize, Clone)]
241pub struct BehaviorsConfig {
242 pub active: String,
244
245 #[serde(default = "default_behaviors_templates_dir")]
247 pub templates_dir: String,
248
249 #[serde(default = "default_behaviors_configs_dir")]
251 pub configs_dir: String,
252
253 #[serde(default)]
255 pub executor: BehaviorExecutorConfig,
256}
257
258#[derive(Debug, Serialize, Deserialize, Clone)]
262pub struct BehaviorExecutorConfig {
263 #[serde(default = "default_tick_rate_hz")]
265 pub tick_rate_hz: f32,
266
267 #[serde(default)]
269 pub max_ticks: Option<usize>,
270
271 #[serde(default = "default_log_stats")]
273 pub log_stats: bool,
274}
275
276impl Default for BehaviorExecutorConfig {
277 fn default() -> Self {
278 Self {
279 tick_rate_hz: default_tick_rate_hz(),
280 max_ticks: None,
281 log_stats: default_log_stats(),
282 }
283 }
284}
285
286fn default_behaviors_templates_dir() -> String {
287 "behaviors".to_string()
288}
289
290fn default_behaviors_configs_dir() -> String {
291 "configs".to_string()
292}
293
294fn default_tick_rate_hz() -> f32 {
295 10.0
296}
297
298fn default_log_stats() -> bool {
299 true
300}
301
302#[derive(Debug, Serialize, Deserialize, Default)]
303pub struct ServicesConfig {
304 #[serde(default)]
305 pub http_api: Option<HttpApiServiceConfig>,
306 #[serde(default)]
307 pub database: Option<DatabaseServiceConfig>,
308 #[serde(default)]
309 pub job_processor: Option<JobProcessorServiceConfig>,
310 #[serde(default)]
311 pub scheduler: Option<SchedulerServiceConfig>,
312}
313
314#[derive(Debug, Serialize, Deserialize, Clone)]
315pub struct HttpApiServiceConfig {
316 pub host: String,
317 pub port: u16,
318 #[serde(default = "default_enable_cors")]
319 pub _enable_cors: bool,
320}
321
322#[derive(Debug, Serialize, Deserialize, Clone)]
323pub struct DatabaseServiceConfig {
324 pub url: String,
325 #[serde(default = "default_max_connections")]
326 pub max_connections: u32,
327 #[serde(default = "default_timeout_seconds")]
328 pub timeout_seconds: u64,
329}
330
331#[derive(Debug, Serialize, Deserialize, Clone)]
332pub struct JobProcessorServiceConfig {
333 #[serde(default = "default_worker_count")]
334 pub worker_count: usize,
335 #[serde(default = "default_max_queue_size")]
336 pub max_queue_size: usize,
337}
338
339#[derive(Debug, Serialize, Deserialize, Clone)]
340pub struct SchedulerServiceConfig {
341 pub tasks: Vec<ScheduledTaskConfig>,
342}
343
344#[derive(Debug, Serialize, Deserialize, Clone)]
345#[allow(dead_code)]
346pub struct ScheduledTaskConfig {
347 pub name: String,
348 pub cron: String,
349 pub topic: String,
350 pub payload: String,
351}
352
353#[derive(Debug, Serialize, Deserialize)]
354pub struct RobotConfig {
355 pub id: String,
356 #[serde(default)]
357 pub platform: Option<String>,
358 #[serde(default)]
359 pub description: Option<String>,
360}
361
362#[derive(Debug, Serialize, Deserialize, Clone)]
367pub struct ProjectSimulationConfig {
368 #[serde(default = "default_simulation_enabled")]
370 pub enabled: bool,
371
372 #[serde(default = "default_config_profile")]
374 pub config_profile: String,
375
376 #[serde(default, skip_serializing_if = "Option::is_none")]
379 pub scenario: Option<String>,
380
381 #[serde(default, skip_serializing_if = "Option::is_none")]
384 pub model: Option<String>,
385 #[serde(default, skip_serializing_if = "Option::is_none")]
386 pub model_config: Option<String>,
387 #[serde(default, skip_serializing_if = "Option::is_none")]
388 pub environment: Option<String>,
389 #[serde(default, skip_serializing_if = "Option::is_none")]
390 pub environment_config: Option<String>,
391}
392
393fn default_simulation_enabled() -> bool {
394 true
395}
396
397fn default_config_profile() -> String {
398 "dev".to_string()
399}
400
401#[derive(Debug, Serialize, Deserialize, Clone)]
406pub struct LifecycleConfig {
407 pub modes: std::collections::HashMap<String, ModeConfig>,
409
410 #[serde(default = "default_lifecycle_mode")]
412 pub default_mode: String,
413}
414
415#[derive(Debug, Serialize, Deserialize, Clone)]
419pub struct ModeConfig {
420 #[serde(default, skip_serializing_if = "Option::is_none")]
422 pub description: Option<String>,
423
424 pub nodes: Vec<String>,
426
427 #[serde(default)]
430 pub stop_nodes: Vec<String>,
431}
432
433fn default_lifecycle_mode() -> String {
434 "startup".to_string()
435}
436
437impl LifecycleConfig {
439 #[allow(dead_code)] pub fn validate(&self, available_nodes: &[String]) -> Result<()> {
452 if !self.modes.contains_key(&self.default_mode) {
454 return Err(anyhow::anyhow!(
455 "Default mode '{}' not found in modes. Available modes: {}",
456 self.default_mode,
457 self.modes.keys().map(|k| k.as_str()).collect::<Vec<_>>().join(", ")
458 ));
459 }
460
461 for (mode_name, mode_config) in &self.modes {
463 mode_config.validate(mode_name, available_nodes)?;
464 }
465
466 Ok(())
467 }
468}
469
470impl ModeConfig {
471 #[allow(dead_code)] pub fn validate(&self, mode_name: &str, available_nodes: &[String]) -> Result<()> {
483 for node in &self.nodes {
485 if !available_nodes.contains(node) {
486 return Err(anyhow::anyhow!(
487 "Mode '{}': node '{}' not found in project configuration. Available nodes: {}",
488 mode_name,
489 node,
490 available_nodes.join(", ")
491 ));
492 }
493 }
494
495 for node in &self.stop_nodes {
497 if !available_nodes.contains(node) {
498 return Err(anyhow::anyhow!(
499 "Mode '{}': stop_node '{}' not found in project configuration. Available nodes: {}",
500 mode_name,
501 node,
502 available_nodes.join(", ")
503 ));
504 }
505 }
506
507 Ok(())
508 }
509}
510
511#[derive(Debug, Serialize, Deserialize, Default, Clone)]
513pub struct DockerServicesConfig {
514 #[serde(default)]
516 pub robot: Vec<String>,
517
518 #[serde(default)]
520 pub edge: Vec<String>,
521
522 #[serde(default = "default_compose_file")]
524 pub compose_file: String,
525
526 #[serde(default = "default_auto_start")]
528 pub auto_start: bool,
529}
530
531#[derive(Debug, Serialize, Deserialize, Clone)]
539pub struct EnvironmentsConfig {
540 #[serde(default = "default_dev_environment")]
542 pub dev: EnvironmentConfig,
543
544 #[serde(default, skip_serializing_if = "Option::is_none")]
546 pub staging: Option<EnvironmentConfig>,
547
548 #[serde(default = "default_prod_environment")]
550 pub prod: EnvironmentConfig,
551}
552
553impl Default for EnvironmentsConfig {
554 fn default() -> Self {
555 Self {
556 dev: default_dev_environment(),
557 staging: None,
558 prod: default_prod_environment(),
559 }
560 }
561}
562
563impl EnvironmentsConfig {
564 pub fn get(&self, env: &str) -> Option<&EnvironmentConfig> {
566 match env {
567 "dev" | "development" => Some(&self.dev),
568 "staging" => self.staging.as_ref(),
569 "prod" | "production" => Some(&self.prod),
570 _ => None,
571 }
572 }
573
574 pub fn current(&self) -> &EnvironmentConfig {
576 let env = std::env::var("MECHA10_ENV").unwrap_or_else(|_| "dev".to_string());
577 self.get(&env).unwrap_or(&self.dev)
578 }
579
580 pub fn control_plane_url(&self) -> String {
582 std::env::var("MECHA10_CONTROL_PLANE_URL").unwrap_or_else(|_| self.current().control_plane.clone())
583 }
584
585 #[allow(dead_code)]
588 pub fn api_url(&self) -> String {
589 format!("{}/api", self.control_plane_url())
590 }
591
592 pub fn dashboard_url(&self) -> String {
595 std::env::var("MECHA10_DASHBOARD_URL").unwrap_or_else(|_| format!("{}/dashboard", self.control_plane_url()))
596 }
597
598 pub fn relay_url(&self) -> String {
601 if let Ok(url) = std::env::var("WEBRTC_RELAY_URL") {
602 return url;
603 }
604
605 let control_plane = self.control_plane_url();
606 let ws_url = if control_plane.starts_with("https://") {
608 control_plane.replace("https://", "wss://")
609 } else if control_plane.starts_with("http://") {
610 control_plane.replace("http://", "ws://")
611 } else {
612 control_plane
613 };
614 format!("{}/webrtc-relay", ws_url)
615 }
616
617 pub fn redis_url(&self) -> String {
619 std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://localhost:6379".to_string())
620 }
621}
622
623#[derive(Debug, Serialize, Deserialize, Clone)]
625pub struct EnvironmentConfig {
626 pub control_plane: String,
628}
629
630fn default_dev_environment() -> EnvironmentConfig {
631 EnvironmentConfig {
632 control_plane: "http://localhost:8100".to_string(),
633 }
634}
635
636fn default_prod_environment() -> EnvironmentConfig {
637 EnvironmentConfig {
638 control_plane: "https://mecha.industries".to_string(),
639 }
640}
641
642fn default_enable_cors() -> bool {
644 true
645}
646
647fn default_max_connections() -> u32 {
648 10
649}
650
651fn default_timeout_seconds() -> u64 {
652 30
653}
654
655fn default_worker_count() -> usize {
656 4
657}
658
659fn default_max_queue_size() -> usize {
660 100
661}
662
663fn default_compose_file() -> String {
664 "docker-compose.yml".to_string()
665}
666
667fn default_auto_start() -> bool {
668 true
669}
670
671#[allow(dead_code)]
673pub async fn load_robot_id(config_path: &PathBuf) -> Result<String> {
674 let content = tokio::fs::read_to_string(config_path)
675 .await
676 .context("Failed to read mecha10.json")?;
677
678 let config: ProjectConfig = serde_json::from_str(&content).context("Failed to parse mecha10.json")?;
679
680 Ok(config.robot.id)
681}
682
683pub async fn load_project_config(config_path: &PathBuf) -> Result<ProjectConfig> {
685 let content = tokio::fs::read_to_string(config_path)
686 .await
687 .context("Failed to read mecha10.json")?;
688
689 let config: ProjectConfig = serde_json::from_str(&content).context("Failed to parse mecha10.json")?;
690
691 Ok(config)
692}