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