mecha10_cli/types/
project.rs

1//! Project configuration types
2//!
3//! Types for loading and managing mecha10.json project configuration files.
4
5use 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, Serialize, Deserialize, Default)]
37pub struct NodesConfig {
38    #[serde(default)]
39    pub drivers: Vec<NodeEntry>,
40    #[serde(default)]
41    pub custom: Vec<NodeEntry>,
42}
43
44/// Behavior tree configuration
45///
46/// Controls behavior tree execution for autonomous robot behaviors.
47#[derive(Debug, Serialize, Deserialize, Clone)]
48pub struct BehaviorsConfig {
49    /// Name of the active behavior to run (e.g., "idle_wander")
50    pub active: String,
51
52    /// Directory containing behavior tree templates (relative to project root)
53    #[serde(default = "default_behaviors_templates_dir")]
54    pub templates_dir: String,
55
56    /// Directory containing behavior configuration files (relative to project root)
57    #[serde(default = "default_behaviors_configs_dir")]
58    pub configs_dir: String,
59
60    /// Executor configuration
61    #[serde(default)]
62    pub executor: BehaviorExecutorConfig,
63}
64
65/// Behavior executor configuration
66///
67/// Controls how the behavior tree executor runs.
68#[derive(Debug, Serialize, Deserialize, Clone)]
69pub struct BehaviorExecutorConfig {
70    /// Tick rate in Hz (how many times per second to tick the behavior tree)
71    #[serde(default = "default_tick_rate_hz")]
72    pub tick_rate_hz: f32,
73
74    /// Maximum number of ticks before stopping (None = run forever)
75    #[serde(default)]
76    pub max_ticks: Option<usize>,
77
78    /// Whether to log execution statistics
79    #[serde(default = "default_log_stats")]
80    pub log_stats: bool,
81}
82
83impl Default for BehaviorExecutorConfig {
84    fn default() -> Self {
85        Self {
86            tick_rate_hz: default_tick_rate_hz(),
87            max_ticks: None,
88            log_stats: default_log_stats(),
89        }
90    }
91}
92
93fn default_behaviors_templates_dir() -> String {
94    "behaviors".to_string()
95}
96
97fn default_behaviors_configs_dir() -> String {
98    "configs".to_string()
99}
100
101fn default_tick_rate_hz() -> f32 {
102    10.0
103}
104
105fn default_log_stats() -> bool {
106    true
107}
108
109#[derive(Debug, Serialize, Deserialize, Default)]
110pub struct ServicesConfig {
111    #[serde(default)]
112    pub http_api: Option<HttpApiServiceConfig>,
113    #[serde(default)]
114    pub database: Option<DatabaseServiceConfig>,
115    #[serde(default)]
116    pub job_processor: Option<JobProcessorServiceConfig>,
117    #[serde(default)]
118    pub scheduler: Option<SchedulerServiceConfig>,
119}
120
121#[derive(Debug, Serialize, Deserialize, Clone)]
122pub struct HttpApiServiceConfig {
123    pub host: String,
124    pub port: u16,
125    #[serde(default = "default_enable_cors")]
126    pub _enable_cors: bool,
127}
128
129#[derive(Debug, Serialize, Deserialize, Clone)]
130pub struct DatabaseServiceConfig {
131    pub url: String,
132    #[serde(default = "default_max_connections")]
133    pub max_connections: u32,
134    #[serde(default = "default_timeout_seconds")]
135    pub timeout_seconds: u64,
136}
137
138#[derive(Debug, Serialize, Deserialize, Clone)]
139pub struct JobProcessorServiceConfig {
140    #[serde(default = "default_worker_count")]
141    pub worker_count: usize,
142    #[serde(default = "default_max_queue_size")]
143    pub max_queue_size: usize,
144}
145
146#[derive(Debug, Serialize, Deserialize, Clone)]
147pub struct SchedulerServiceConfig {
148    pub tasks: Vec<ScheduledTaskConfig>,
149}
150
151#[derive(Debug, Serialize, Deserialize, Clone)]
152#[allow(dead_code)]
153pub struct ScheduledTaskConfig {
154    pub name: String,
155    pub cron: String,
156    pub topic: String,
157    pub payload: String,
158}
159
160#[derive(Debug, Serialize, Deserialize, Clone)]
161#[allow(dead_code)]
162pub struct NodeEntry {
163    pub name: String,
164    pub path: String,
165    #[serde(default)]
166    pub config: Option<serde_json::Value>,
167    #[serde(default)]
168    pub description: Option<String>,
169    #[serde(default)]
170    pub run_target: Option<String>,
171    #[serde(default = "default_enabled")]
172    pub enabled: bool,
173}
174
175#[derive(Debug, Serialize, Deserialize)]
176pub struct RobotConfig {
177    pub id: String,
178    #[serde(default)]
179    pub platform: Option<String>,
180    #[serde(default)]
181    pub description: Option<String>,
182}
183
184/// Project-level simulation configuration stored in mecha10.json
185///
186/// This controls simulation settings for the project, distinct from
187/// the runtime simulation config loaded from configs/{profile}/simulation/.
188#[derive(Debug, Serialize, Deserialize, Clone)]
189pub struct ProjectSimulationConfig {
190    /// Whether simulation is enabled for this project
191    #[serde(default = "default_simulation_enabled")]
192    pub enabled: bool,
193
194    /// Config profile to use (dev, production, staging, etc.)
195    #[serde(default = "default_config_profile")]
196    pub config_profile: String,
197
198    /// Optional scenario name (loads configs/{profile}/simulation/{scenario}.json)
199    /// If not specified, loads configs/{profile}/simulation/config.json
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub scenario: Option<String>,
202
203    // DEPRECATED: Legacy fields for backwards compatibility
204    // These are ignored if config_profile is set
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub model: Option<String>,
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub model_config: Option<String>,
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub environment: Option<String>,
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub environment_config: Option<String>,
213}
214
215fn default_simulation_enabled() -> bool {
216    true
217}
218
219fn default_config_profile() -> String {
220    "dev".to_string()
221}
222
223/// Lifecycle configuration for mode-based node management
224///
225/// Enables dynamic node lifecycle based on operational modes.
226/// Each mode defines which nodes should run in that context.
227#[derive(Debug, Serialize, Deserialize, Clone)]
228pub struct LifecycleConfig {
229    /// Available operational modes
230    pub modes: std::collections::HashMap<String, ModeConfig>,
231
232    /// Default mode on startup
233    #[serde(default = "default_lifecycle_mode")]
234    pub default_mode: String,
235}
236
237/// Configuration for a single operational mode
238///
239/// Defines which nodes should run in this mode and which should stop.
240#[derive(Debug, Serialize, Deserialize, Clone)]
241pub struct ModeConfig {
242    /// Human-readable description of this mode
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub description: Option<String>,
245
246    /// Nodes that should run in this mode
247    pub nodes: Vec<String>,
248
249    /// Nodes that should be stopped when entering this mode (optional)
250    /// This is useful for explicitly stopping nodes that conflict with this mode.
251    #[serde(default)]
252    pub stop_nodes: Vec<String>,
253}
254
255fn default_lifecycle_mode() -> String {
256    "startup".to_string()
257}
258
259// Validation for lifecycle configuration
260impl LifecycleConfig {
261    /// Validate lifecycle configuration against available nodes
262    ///
263    /// # Arguments
264    ///
265    /// * `available_nodes` - List of node names defined in project config
266    ///
267    /// # Errors
268    ///
269    /// Returns error if:
270    /// - Default mode doesn't exist in modes
271    /// - Any mode references undefined nodes
272    #[allow(dead_code)] // Will be used in Phase 3
273    pub fn validate(&self, available_nodes: &[String]) -> Result<()> {
274        // Check that default mode exists
275        if !self.modes.contains_key(&self.default_mode) {
276            return Err(anyhow::anyhow!(
277                "Default mode '{}' not found in modes. Available modes: {}",
278                self.default_mode,
279                self.modes.keys().map(|k| k.as_str()).collect::<Vec<_>>().join(", ")
280            ));
281        }
282
283        // Validate each mode
284        for (mode_name, mode_config) in &self.modes {
285            mode_config.validate(mode_name, available_nodes)?;
286        }
287
288        Ok(())
289    }
290}
291
292impl ModeConfig {
293    /// Validate mode configuration against available nodes
294    ///
295    /// # Arguments
296    ///
297    /// * `mode_name` - Name of this mode (for error messages)
298    /// * `available_nodes` - List of node names defined in project config
299    ///
300    /// # Errors
301    ///
302    /// Returns error if any referenced node doesn't exist in project config
303    #[allow(dead_code)] // Will be used in Phase 3
304    pub fn validate(&self, mode_name: &str, available_nodes: &[String]) -> Result<()> {
305        // Check all nodes exist
306        for node in &self.nodes {
307            if !available_nodes.contains(node) {
308                return Err(anyhow::anyhow!(
309                    "Mode '{}': node '{}' not found in project configuration. Available nodes: {}",
310                    mode_name,
311                    node,
312                    available_nodes.join(", ")
313                ));
314            }
315        }
316
317        // Check stop_nodes exist
318        for node in &self.stop_nodes {
319            if !available_nodes.contains(node) {
320                return Err(anyhow::anyhow!(
321                    "Mode '{}': stop_node '{}' not found in project configuration. Available nodes: {}",
322                    mode_name,
323                    node,
324                    available_nodes.join(", ")
325                ));
326            }
327        }
328
329        Ok(())
330    }
331}
332
333#[derive(Debug, Serialize, Deserialize)]
334pub struct RedisConfig {
335    #[serde(default = "default_redis_url")]
336    pub url: String,
337}
338
339/// Docker services configuration for project-level Docker Compose management
340#[derive(Debug, Serialize, Deserialize, Default, Clone)]
341pub struct DockerServicesConfig {
342    /// Services needed for on-robot execution (typically just Redis for message passing)
343    #[serde(default)]
344    pub robot: Vec<String>,
345
346    /// Services needed for edge/remote execution (Redis + databases, etc.)
347    #[serde(default)]
348    pub edge: Vec<String>,
349
350    /// Path to docker-compose.yml relative to project root
351    #[serde(default = "default_compose_file")]
352    pub compose_file: String,
353
354    /// Whether to auto-start services in dev mode
355    #[serde(default = "default_auto_start")]
356    pub auto_start: bool,
357}
358
359impl Default for RedisConfig {
360    fn default() -> Self {
361        Self {
362            url: default_redis_url(),
363        }
364    }
365}
366
367fn default_redis_url() -> String {
368    "redis://localhost:6379".to_string()
369}
370
371/// Dashboard configuration
372///
373/// Controls the dashboard URL for the project.
374/// Can be overridden via MECHA10_DASHBOARD_URL environment variable.
375#[derive(Debug, Serialize, Deserialize, Clone)]
376pub struct DashboardConfig {
377    /// Dashboard URL
378    /// Default: https://dashboard.mecha.industries
379    /// Override with MECHA10_DASHBOARD_URL env var for local development
380    #[serde(default = "default_dashboard_url")]
381    pub url: String,
382}
383
384impl Default for DashboardConfig {
385    fn default() -> Self {
386        Self {
387            url: default_dashboard_url(),
388        }
389    }
390}
391
392impl DashboardConfig {
393    /// Get the effective dashboard URL
394    ///
395    /// Priority:
396    /// 1. MECHA10_DASHBOARD_URL environment variable
397    /// 2. Config file value
398    /// 3. Default (https://dashboard.mecha.industries)
399    pub fn effective_url(&self) -> String {
400        std::env::var("MECHA10_DASHBOARD_URL").unwrap_or_else(|_| self.url.clone())
401    }
402}
403
404fn default_dashboard_url() -> String {
405    "https://dashboard.mecha.industries".to_string()
406}
407
408/// Networking configuration for WebRTC relay and signaling
409///
410/// Centralizes relay URL configuration for simulation-bridge and other nodes.
411#[derive(Debug, Serialize, Deserialize, Clone, Default)]
412pub struct NetworkingConfig {
413    /// WebRTC relay configuration
414    #[serde(default)]
415    pub relay: RelayConfig,
416}
417
418/// WebRTC relay configuration
419///
420/// The relay URL is used by simulation-bridge in client mode to connect
421/// to a remote signaling server for WebRTC camera streaming.
422#[derive(Debug, Serialize, Deserialize, Clone)]
423pub struct RelayConfig {
424    /// WebRTC relay URL for signaling
425    /// Default: wss://api.mecha.industries/webrtc-relay
426    /// Override with WEBRTC_RELAY_URL env var
427    #[serde(default = "default_relay_url")]
428    pub url: String,
429}
430
431impl Default for RelayConfig {
432    fn default() -> Self {
433        Self {
434            url: default_relay_url(),
435        }
436    }
437}
438
439impl RelayConfig {
440    /// Get the effective relay URL
441    ///
442    /// Priority:
443    /// 1. WEBRTC_RELAY_URL environment variable
444    /// 2. Config file value
445    /// 3. Default (wss://api.mecha.industries/webrtc-relay)
446    pub fn effective_url(&self) -> String {
447        std::env::var("WEBRTC_RELAY_URL").unwrap_or_else(|_| self.url.clone())
448    }
449}
450
451fn default_relay_url() -> String {
452    "wss://api.mecha.industries/webrtc-relay".to_string()
453}
454
455/// Environment-specific configuration
456///
457/// Allows configuring different URLs for dev, staging, and production environments.
458/// The active environment is determined by:
459/// 1. `MECHA10_ENV` environment variable
460/// 2. `--env` CLI flag
461/// 3. Default: "dev" when running `mecha10 dev`, "prod" otherwise
462#[derive(Debug, Serialize, Deserialize, Clone)]
463pub struct EnvironmentsConfig {
464    /// Development environment (local control plane)
465    #[serde(default = "default_dev_environment")]
466    pub dev: EnvironmentConfig,
467
468    /// Staging environment (optional)
469    #[serde(default, skip_serializing_if = "Option::is_none")]
470    pub staging: Option<EnvironmentConfig>,
471
472    /// Production environment (cloud control plane)
473    #[serde(default = "default_prod_environment")]
474    pub prod: EnvironmentConfig,
475}
476
477impl Default for EnvironmentsConfig {
478    fn default() -> Self {
479        Self {
480            dev: default_dev_environment(),
481            staging: None,
482            prod: default_prod_environment(),
483        }
484    }
485}
486
487impl EnvironmentsConfig {
488    /// Get the environment config for the given environment name
489    pub fn get(&self, env: &str) -> Option<&EnvironmentConfig> {
490        match env {
491            "dev" | "development" => Some(&self.dev),
492            "staging" => self.staging.as_ref(),
493            "prod" | "production" => Some(&self.prod),
494            _ => None,
495        }
496    }
497
498    /// Get the current environment config based on MECHA10_ENV
499    pub fn current(&self) -> &EnvironmentConfig {
500        let env = std::env::var("MECHA10_ENV").unwrap_or_else(|_| "dev".to_string());
501        self.get(&env).unwrap_or(&self.dev)
502    }
503
504    /// Get the control plane URL for the current environment
505    pub fn control_plane_url(&self) -> String {
506        std::env::var("MECHA10_CONTROL_PLANE_URL").unwrap_or_else(|_| self.current().control_plane.clone())
507    }
508}
509
510/// Configuration for a specific environment
511#[derive(Debug, Serialize, Deserialize, Clone)]
512pub struct EnvironmentConfig {
513    /// Control plane base URL (e.g., "ws://localhost:8100" or "wss://api.mecha.industries")
514    pub control_plane: String,
515}
516
517fn default_dev_environment() -> EnvironmentConfig {
518    EnvironmentConfig {
519        control_plane: "ws://localhost:8000".to_string(),
520    }
521}
522
523fn default_prod_environment() -> EnvironmentConfig {
524    EnvironmentConfig {
525        control_plane: "wss://api.mecha.industries".to_string(),
526    }
527}
528
529// Default value functions
530fn default_enable_cors() -> bool {
531    true
532}
533
534fn default_max_connections() -> u32 {
535    10
536}
537
538fn default_timeout_seconds() -> u64 {
539    30
540}
541
542fn default_worker_count() -> usize {
543    4
544}
545
546fn default_max_queue_size() -> usize {
547    100
548}
549
550fn default_enabled() -> bool {
551    true
552}
553
554fn default_compose_file() -> String {
555    "docker-compose.yml".to_string()
556}
557
558fn default_auto_start() -> bool {
559    true
560}
561
562/// Load robot_id from mecha10.json
563#[allow(dead_code)]
564pub async fn load_robot_id(config_path: &PathBuf) -> Result<String> {
565    let content = tokio::fs::read_to_string(config_path)
566        .await
567        .context("Failed to read mecha10.json")?;
568
569    let config: ProjectConfig = serde_json::from_str(&content).context("Failed to parse mecha10.json")?;
570
571    Ok(config.robot.id)
572}
573
574/// Load full project config from mecha10.json
575pub async fn load_project_config(config_path: &PathBuf) -> Result<ProjectConfig> {
576    let content = tokio::fs::read_to_string(config_path)
577        .await
578        .context("Failed to read mecha10.json")?;
579
580    let config: ProjectConfig = serde_json::from_str(&content).context("Failed to parse mecha10.json")?;
581
582    Ok(config)
583}