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 redis: RedisConfig,
28 #[serde(default)]
29 pub dashboard: DashboardConfig,
30 #[serde(default)]
31 pub networking: NetworkingConfig,
32 #[serde(default)]
33 pub environments: EnvironmentsConfig,
34}
35
36#[derive(Debug, Clone, PartialEq)]
42pub enum NodeSource {
43 Framework,
45 Project,
47 Registry(String),
49}
50
51#[derive(Debug, Clone)]
62pub struct NodeSpec {
63 pub name: String,
65 #[allow(dead_code)] pub identifier: String,
68 pub source: NodeSource,
70}
71
72impl NodeSpec {
73 pub fn parse(identifier: &str) -> Result<Self> {
87 let identifier = identifier.trim();
88
89 if !identifier.starts_with('@') {
90 anyhow::bail!(
91 "Invalid node identifier '{}'. Expected @mecha10/<name>, @local/<name>, or @<org>/<name>",
92 identifier
93 );
94 }
95
96 let without_at = identifier.strip_prefix('@').unwrap();
97 let parts: Vec<&str> = without_at.splitn(2, '/').collect();
98
99 if parts.len() != 2 {
100 anyhow::bail!(
101 "Invalid node identifier '{}'. Expected @<scope>/<name> format",
102 identifier
103 );
104 }
105
106 let scope = parts[0];
107 let name = parts[1].to_string();
108
109 if scope.is_empty() || name.is_empty() {
110 anyhow::bail!("Empty scope or name in identifier: {}", identifier);
111 }
112
113 let source = match scope {
114 "mecha10" => NodeSource::Framework,
115 "local" => NodeSource::Project,
116 org => NodeSource::Registry(org.to_string()),
117 };
118
119 Ok(Self {
120 name,
121 identifier: identifier.to_string(),
122 source,
123 })
124 }
125
126 pub fn is_framework(&self) -> bool {
128 matches!(self.source, NodeSource::Framework)
129 }
130
131 #[allow(dead_code)] pub fn is_project(&self) -> bool {
134 matches!(self.source, NodeSource::Project)
135 }
136
137 pub fn package_path(&self) -> String {
143 match &self.source {
144 NodeSource::Framework => format!("mecha10-nodes-{}", self.name),
145 NodeSource::Project => format!("nodes/{}", self.name),
146 NodeSource::Registry(org) => format!("node_modules/@{}/{}", org, self.name),
147 }
148 }
149
150 #[allow(dead_code)] pub fn config_dir(&self) -> String {
158 format!("configs/nodes/{}", self.identifier)
159 }
160}
161
162#[derive(Debug, Serialize, Deserialize, Default, Clone)]
180#[serde(transparent)]
181pub struct NodesConfig(pub Vec<String>);
182
183impl NodesConfig {
184 #[allow(dead_code)] pub fn new() -> Self {
187 Self(Vec::new())
188 }
189
190 pub fn get_node_specs(&self) -> Vec<NodeSpec> {
192 self.0.iter().filter_map(|id| NodeSpec::parse(id).ok()).collect()
193 }
194
195 pub fn get_node_names(&self) -> Vec<String> {
197 self.get_node_specs().iter().map(|s| s.name.clone()).collect()
198 }
199
200 pub fn find_by_name(&self, name: &str) -> Option<NodeSpec> {
202 self.get_node_specs().into_iter().find(|s| s.name == name)
203 }
204
205 pub fn contains(&self, name: &str) -> bool {
207 self.find_by_name(name).is_some()
208 }
209
210 pub fn add_node(&mut self, identifier: &str) {
212 if !self.0.contains(&identifier.to_string()) {
213 self.0.push(identifier.to_string());
214 }
215 }
216
217 #[allow(dead_code)] pub fn remove_node(&mut self, name: &str) {
220 self.0
221 .retain(|id| NodeSpec::parse(id).map(|s| s.name != name).unwrap_or(true));
222 }
223
224 #[allow(dead_code)] pub fn identifiers(&self) -> &[String] {
227 &self.0
228 }
229
230 #[allow(dead_code)] pub fn is_empty(&self) -> bool {
233 self.0.is_empty()
234 }
235
236 #[allow(dead_code)] pub fn len(&self) -> usize {
239 self.0.len()
240 }
241}
242
243#[derive(Debug, Serialize, Deserialize, Clone)]
247pub struct BehaviorsConfig {
248 pub active: String,
250
251 #[serde(default = "default_behaviors_templates_dir")]
253 pub templates_dir: String,
254
255 #[serde(default = "default_behaviors_configs_dir")]
257 pub configs_dir: String,
258
259 #[serde(default)]
261 pub executor: BehaviorExecutorConfig,
262}
263
264#[derive(Debug, Serialize, Deserialize, Clone)]
268pub struct BehaviorExecutorConfig {
269 #[serde(default = "default_tick_rate_hz")]
271 pub tick_rate_hz: f32,
272
273 #[serde(default)]
275 pub max_ticks: Option<usize>,
276
277 #[serde(default = "default_log_stats")]
279 pub log_stats: bool,
280}
281
282impl Default for BehaviorExecutorConfig {
283 fn default() -> Self {
284 Self {
285 tick_rate_hz: default_tick_rate_hz(),
286 max_ticks: None,
287 log_stats: default_log_stats(),
288 }
289 }
290}
291
292fn default_behaviors_templates_dir() -> String {
293 "behaviors".to_string()
294}
295
296fn default_behaviors_configs_dir() -> String {
297 "configs".to_string()
298}
299
300fn default_tick_rate_hz() -> f32 {
301 10.0
302}
303
304fn default_log_stats() -> bool {
305 true
306}
307
308#[derive(Debug, Serialize, Deserialize, Default)]
309pub struct ServicesConfig {
310 #[serde(default)]
311 pub http_api: Option<HttpApiServiceConfig>,
312 #[serde(default)]
313 pub database: Option<DatabaseServiceConfig>,
314 #[serde(default)]
315 pub job_processor: Option<JobProcessorServiceConfig>,
316 #[serde(default)]
317 pub scheduler: Option<SchedulerServiceConfig>,
318}
319
320#[derive(Debug, Serialize, Deserialize, Clone)]
321pub struct HttpApiServiceConfig {
322 pub host: String,
323 pub port: u16,
324 #[serde(default = "default_enable_cors")]
325 pub _enable_cors: bool,
326}
327
328#[derive(Debug, Serialize, Deserialize, Clone)]
329pub struct DatabaseServiceConfig {
330 pub url: String,
331 #[serde(default = "default_max_connections")]
332 pub max_connections: u32,
333 #[serde(default = "default_timeout_seconds")]
334 pub timeout_seconds: u64,
335}
336
337#[derive(Debug, Serialize, Deserialize, Clone)]
338pub struct JobProcessorServiceConfig {
339 #[serde(default = "default_worker_count")]
340 pub worker_count: usize,
341 #[serde(default = "default_max_queue_size")]
342 pub max_queue_size: usize,
343}
344
345#[derive(Debug, Serialize, Deserialize, Clone)]
346pub struct SchedulerServiceConfig {
347 pub tasks: Vec<ScheduledTaskConfig>,
348}
349
350#[derive(Debug, Serialize, Deserialize, Clone)]
351#[allow(dead_code)]
352pub struct ScheduledTaskConfig {
353 pub name: String,
354 pub cron: String,
355 pub topic: String,
356 pub payload: String,
357}
358
359#[derive(Debug, Serialize, Deserialize)]
360pub struct RobotConfig {
361 pub id: String,
362 #[serde(default)]
363 pub platform: Option<String>,
364 #[serde(default)]
365 pub description: Option<String>,
366}
367
368#[derive(Debug, Serialize, Deserialize, Clone)]
373pub struct ProjectSimulationConfig {
374 #[serde(default = "default_simulation_enabled")]
376 pub enabled: bool,
377
378 #[serde(default = "default_config_profile")]
380 pub config_profile: String,
381
382 #[serde(default, skip_serializing_if = "Option::is_none")]
385 pub scenario: Option<String>,
386
387 #[serde(default, skip_serializing_if = "Option::is_none")]
390 pub model: Option<String>,
391 #[serde(default, skip_serializing_if = "Option::is_none")]
392 pub model_config: Option<String>,
393 #[serde(default, skip_serializing_if = "Option::is_none")]
394 pub environment: Option<String>,
395 #[serde(default, skip_serializing_if = "Option::is_none")]
396 pub environment_config: Option<String>,
397}
398
399fn default_simulation_enabled() -> bool {
400 true
401}
402
403fn default_config_profile() -> String {
404 "dev".to_string()
405}
406
407#[derive(Debug, Serialize, Deserialize, Clone)]
412pub struct LifecycleConfig {
413 pub modes: std::collections::HashMap<String, ModeConfig>,
415
416 #[serde(default = "default_lifecycle_mode")]
418 pub default_mode: String,
419}
420
421#[derive(Debug, Serialize, Deserialize, Clone)]
425pub struct ModeConfig {
426 #[serde(default, skip_serializing_if = "Option::is_none")]
428 pub description: Option<String>,
429
430 pub nodes: Vec<String>,
432
433 #[serde(default)]
436 pub stop_nodes: Vec<String>,
437}
438
439fn default_lifecycle_mode() -> String {
440 "startup".to_string()
441}
442
443impl LifecycleConfig {
445 #[allow(dead_code)] pub fn validate(&self, available_nodes: &[String]) -> Result<()> {
458 if !self.modes.contains_key(&self.default_mode) {
460 return Err(anyhow::anyhow!(
461 "Default mode '{}' not found in modes. Available modes: {}",
462 self.default_mode,
463 self.modes.keys().map(|k| k.as_str()).collect::<Vec<_>>().join(", ")
464 ));
465 }
466
467 for (mode_name, mode_config) in &self.modes {
469 mode_config.validate(mode_name, available_nodes)?;
470 }
471
472 Ok(())
473 }
474}
475
476impl ModeConfig {
477 #[allow(dead_code)] pub fn validate(&self, mode_name: &str, available_nodes: &[String]) -> Result<()> {
489 for node in &self.nodes {
491 if !available_nodes.contains(node) {
492 return Err(anyhow::anyhow!(
493 "Mode '{}': node '{}' not found in project configuration. Available nodes: {}",
494 mode_name,
495 node,
496 available_nodes.join(", ")
497 ));
498 }
499 }
500
501 for node in &self.stop_nodes {
503 if !available_nodes.contains(node) {
504 return Err(anyhow::anyhow!(
505 "Mode '{}': stop_node '{}' not found in project configuration. Available nodes: {}",
506 mode_name,
507 node,
508 available_nodes.join(", ")
509 ));
510 }
511 }
512
513 Ok(())
514 }
515}
516
517#[derive(Debug, Serialize, Deserialize)]
518pub struct RedisConfig {
519 #[serde(default = "default_redis_url")]
520 pub url: String,
521}
522
523#[derive(Debug, Serialize, Deserialize, Default, Clone)]
525pub struct DockerServicesConfig {
526 #[serde(default)]
528 pub robot: Vec<String>,
529
530 #[serde(default)]
532 pub edge: Vec<String>,
533
534 #[serde(default = "default_compose_file")]
536 pub compose_file: String,
537
538 #[serde(default = "default_auto_start")]
540 pub auto_start: bool,
541}
542
543impl Default for RedisConfig {
544 fn default() -> Self {
545 Self {
546 url: default_redis_url(),
547 }
548 }
549}
550
551fn default_redis_url() -> String {
552 "redis://localhost:6379".to_string()
553}
554
555#[derive(Debug, Serialize, Deserialize, Clone)]
560pub struct DashboardConfig {
561 #[serde(default = "default_dashboard_url")]
565 pub url: String,
566}
567
568impl Default for DashboardConfig {
569 fn default() -> Self {
570 Self {
571 url: default_dashboard_url(),
572 }
573 }
574}
575
576impl DashboardConfig {
577 pub fn effective_url(&self) -> String {
584 std::env::var("MECHA10_DASHBOARD_URL").unwrap_or_else(|_| self.url.clone())
585 }
586}
587
588fn default_dashboard_url() -> String {
589 "https://dashboard.mecha.industries".to_string()
590}
591
592#[derive(Debug, Serialize, Deserialize, Clone, Default)]
596pub struct NetworkingConfig {
597 #[serde(default)]
599 pub relay: RelayConfig,
600}
601
602#[derive(Debug, Serialize, Deserialize, Clone)]
607pub struct RelayConfig {
608 #[serde(default = "default_relay_url")]
612 pub url: String,
613}
614
615impl Default for RelayConfig {
616 fn default() -> Self {
617 Self {
618 url: default_relay_url(),
619 }
620 }
621}
622
623impl RelayConfig {
624 pub fn effective_url(&self) -> String {
631 std::env::var("WEBRTC_RELAY_URL").unwrap_or_else(|_| self.url.clone())
632 }
633}
634
635fn default_relay_url() -> String {
636 "wss://api.mecha.industries/webrtc-relay".to_string()
637}
638
639#[derive(Debug, Serialize, Deserialize, Clone)]
647pub struct EnvironmentsConfig {
648 #[serde(default = "default_dev_environment")]
650 pub dev: EnvironmentConfig,
651
652 #[serde(default, skip_serializing_if = "Option::is_none")]
654 pub staging: Option<EnvironmentConfig>,
655
656 #[serde(default = "default_prod_environment")]
658 pub prod: EnvironmentConfig,
659}
660
661impl Default for EnvironmentsConfig {
662 fn default() -> Self {
663 Self {
664 dev: default_dev_environment(),
665 staging: None,
666 prod: default_prod_environment(),
667 }
668 }
669}
670
671impl EnvironmentsConfig {
672 pub fn get(&self, env: &str) -> Option<&EnvironmentConfig> {
674 match env {
675 "dev" | "development" => Some(&self.dev),
676 "staging" => self.staging.as_ref(),
677 "prod" | "production" => Some(&self.prod),
678 _ => None,
679 }
680 }
681
682 pub fn current(&self) -> &EnvironmentConfig {
684 let env = std::env::var("MECHA10_ENV").unwrap_or_else(|_| "dev".to_string());
685 self.get(&env).unwrap_or(&self.dev)
686 }
687
688 pub fn control_plane_url(&self) -> String {
690 std::env::var("MECHA10_CONTROL_PLANE_URL").unwrap_or_else(|_| self.current().control_plane.clone())
691 }
692}
693
694#[derive(Debug, Serialize, Deserialize, Clone)]
696pub struct EnvironmentConfig {
697 pub control_plane: String,
699}
700
701fn default_dev_environment() -> EnvironmentConfig {
702 EnvironmentConfig {
703 control_plane: "ws://localhost:8000".to_string(),
704 }
705}
706
707fn default_prod_environment() -> EnvironmentConfig {
708 EnvironmentConfig {
709 control_plane: "wss://api.mecha.industries".to_string(),
710 }
711}
712
713fn default_enable_cors() -> bool {
715 true
716}
717
718fn default_max_connections() -> u32 {
719 10
720}
721
722fn default_timeout_seconds() -> u64 {
723 30
724}
725
726fn default_worker_count() -> usize {
727 4
728}
729
730fn default_max_queue_size() -> usize {
731 100
732}
733
734fn default_compose_file() -> String {
735 "docker-compose.yml".to_string()
736}
737
738fn default_auto_start() -> bool {
739 true
740}
741
742#[allow(dead_code)]
744pub async fn load_robot_id(config_path: &PathBuf) -> Result<String> {
745 let content = tokio::fs::read_to_string(config_path)
746 .await
747 .context("Failed to read mecha10.json")?;
748
749 let config: ProjectConfig = serde_json::from_str(&content).context("Failed to parse mecha10.json")?;
750
751 Ok(config.robot.id)
752}
753
754pub async fn load_project_config(config_path: &PathBuf) -> Result<ProjectConfig> {
756 let content = tokio::fs::read_to_string(config_path)
757 .await
758 .context("Failed to read mecha10.json")?;
759
760 let config: ProjectConfig = serde_json::from_str(&content).context("Failed to parse mecha10.json")?;
761
762 Ok(config)
763}