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}
29
30#[derive(Debug, Serialize, Deserialize, Default)]
31pub struct NodesConfig {
32    #[serde(default)]
33    pub drivers: Vec<NodeEntry>,
34    #[serde(default)]
35    pub custom: Vec<NodeEntry>,
36}
37
38/// Behavior tree configuration
39///
40/// Controls behavior tree execution for autonomous robot behaviors.
41#[derive(Debug, Serialize, Deserialize, Clone)]
42pub struct BehaviorsConfig {
43    /// Name of the active behavior to run (e.g., "idle_wander")
44    pub active: String,
45
46    /// Directory containing behavior tree templates (relative to project root)
47    #[serde(default = "default_behaviors_templates_dir")]
48    pub templates_dir: String,
49
50    /// Directory containing behavior configuration files (relative to project root)
51    #[serde(default = "default_behaviors_configs_dir")]
52    pub configs_dir: String,
53
54    /// Executor configuration
55    #[serde(default)]
56    pub executor: BehaviorExecutorConfig,
57}
58
59/// Behavior executor configuration
60///
61/// Controls how the behavior tree executor runs.
62#[derive(Debug, Serialize, Deserialize, Clone)]
63pub struct BehaviorExecutorConfig {
64    /// Tick rate in Hz (how many times per second to tick the behavior tree)
65    #[serde(default = "default_tick_rate_hz")]
66    pub tick_rate_hz: f32,
67
68    /// Maximum number of ticks before stopping (None = run forever)
69    #[serde(default)]
70    pub max_ticks: Option<usize>,
71
72    /// Whether to log execution statistics
73    #[serde(default = "default_log_stats")]
74    pub log_stats: bool,
75}
76
77impl Default for BehaviorExecutorConfig {
78    fn default() -> Self {
79        Self {
80            tick_rate_hz: default_tick_rate_hz(),
81            max_ticks: None,
82            log_stats: default_log_stats(),
83        }
84    }
85}
86
87fn default_behaviors_templates_dir() -> String {
88    "behaviors".to_string()
89}
90
91fn default_behaviors_configs_dir() -> String {
92    "configs".to_string()
93}
94
95fn default_tick_rate_hz() -> f32 {
96    10.0
97}
98
99fn default_log_stats() -> bool {
100    true
101}
102
103#[derive(Debug, Serialize, Deserialize, Default)]
104pub struct ServicesConfig {
105    #[serde(default)]
106    pub http_api: Option<HttpApiServiceConfig>,
107    #[serde(default)]
108    pub database: Option<DatabaseServiceConfig>,
109    #[serde(default)]
110    pub job_processor: Option<JobProcessorServiceConfig>,
111    #[serde(default)]
112    pub scheduler: Option<SchedulerServiceConfig>,
113}
114
115#[derive(Debug, Serialize, Deserialize, Clone)]
116pub struct HttpApiServiceConfig {
117    pub host: String,
118    pub port: u16,
119    #[serde(default = "default_enable_cors")]
120    pub _enable_cors: bool,
121}
122
123#[derive(Debug, Serialize, Deserialize, Clone)]
124pub struct DatabaseServiceConfig {
125    pub url: String,
126    #[serde(default = "default_max_connections")]
127    pub max_connections: u32,
128    #[serde(default = "default_timeout_seconds")]
129    pub timeout_seconds: u64,
130}
131
132#[derive(Debug, Serialize, Deserialize, Clone)]
133pub struct JobProcessorServiceConfig {
134    #[serde(default = "default_worker_count")]
135    pub worker_count: usize,
136    #[serde(default = "default_max_queue_size")]
137    pub max_queue_size: usize,
138}
139
140#[derive(Debug, Serialize, Deserialize, Clone)]
141pub struct SchedulerServiceConfig {
142    pub tasks: Vec<ScheduledTaskConfig>,
143}
144
145#[derive(Debug, Serialize, Deserialize, Clone)]
146#[allow(dead_code)]
147pub struct ScheduledTaskConfig {
148    pub name: String,
149    pub cron: String,
150    pub topic: String,
151    pub payload: String,
152}
153
154#[derive(Debug, Serialize, Deserialize, Clone)]
155#[allow(dead_code)]
156pub struct NodeEntry {
157    pub name: String,
158    pub path: String,
159    #[serde(default)]
160    pub config: Option<serde_json::Value>,
161    #[serde(default)]
162    pub description: Option<String>,
163    #[serde(default)]
164    pub run_target: Option<String>,
165    #[serde(default = "default_enabled")]
166    pub enabled: bool,
167}
168
169#[derive(Debug, Serialize, Deserialize)]
170pub struct RobotConfig {
171    pub id: String,
172    #[serde(default)]
173    pub platform: Option<String>,
174    #[serde(default)]
175    pub description: Option<String>,
176}
177
178/// Project-level simulation configuration stored in mecha10.json
179///
180/// This controls simulation settings for the project, distinct from
181/// the runtime simulation config loaded from configs/{profile}/simulation/.
182#[derive(Debug, Serialize, Deserialize, Clone)]
183pub struct ProjectSimulationConfig {
184    /// Whether simulation is enabled for this project
185    #[serde(default = "default_simulation_enabled")]
186    pub enabled: bool,
187
188    /// Config profile to use (dev, production, staging, etc.)
189    #[serde(default = "default_config_profile")]
190    pub config_profile: String,
191
192    /// Optional scenario name (loads configs/{profile}/simulation/{scenario}.json)
193    /// If not specified, loads configs/{profile}/simulation/config.json
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub scenario: Option<String>,
196
197    // DEPRECATED: Legacy fields for backwards compatibility
198    // These are ignored if config_profile is set
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub model: Option<String>,
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub model_config: Option<String>,
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub environment: Option<String>,
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub environment_config: Option<String>,
207}
208
209fn default_simulation_enabled() -> bool {
210    true
211}
212
213fn default_config_profile() -> String {
214    "dev".to_string()
215}
216
217/// Lifecycle configuration for mode-based node management
218///
219/// Enables dynamic node lifecycle based on operational modes.
220/// Each mode defines which nodes should run in that context.
221#[derive(Debug, Serialize, Deserialize, Clone)]
222pub struct LifecycleConfig {
223    /// Available operational modes
224    pub modes: std::collections::HashMap<String, ModeConfig>,
225
226    /// Default mode on startup
227    #[serde(default = "default_lifecycle_mode")]
228    pub default_mode: String,
229}
230
231/// Configuration for a single operational mode
232///
233/// Defines which nodes should run in this mode and which should stop.
234#[derive(Debug, Serialize, Deserialize, Clone)]
235pub struct ModeConfig {
236    /// Human-readable description of this mode
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub description: Option<String>,
239
240    /// Nodes that should run in this mode
241    pub nodes: Vec<String>,
242
243    /// Nodes that should be stopped when entering this mode (optional)
244    /// This is useful for explicitly stopping nodes that conflict with this mode.
245    #[serde(default)]
246    pub stop_nodes: Vec<String>,
247}
248
249fn default_lifecycle_mode() -> String {
250    "startup".to_string()
251}
252
253// Validation for lifecycle configuration
254impl LifecycleConfig {
255    /// Validate lifecycle configuration against available nodes
256    ///
257    /// # Arguments
258    ///
259    /// * `available_nodes` - List of node names defined in project config
260    ///
261    /// # Errors
262    ///
263    /// Returns error if:
264    /// - Default mode doesn't exist in modes
265    /// - Any mode references undefined nodes
266    #[allow(dead_code)] // Will be used in Phase 3
267    pub fn validate(&self, available_nodes: &[String]) -> Result<()> {
268        // Check that default mode exists
269        if !self.modes.contains_key(&self.default_mode) {
270            return Err(anyhow::anyhow!(
271                "Default mode '{}' not found in modes. Available modes: {}",
272                self.default_mode,
273                self.modes.keys().map(|k| k.as_str()).collect::<Vec<_>>().join(", ")
274            ));
275        }
276
277        // Validate each mode
278        for (mode_name, mode_config) in &self.modes {
279            mode_config.validate(mode_name, available_nodes)?;
280        }
281
282        Ok(())
283    }
284}
285
286impl ModeConfig {
287    /// Validate mode configuration against available nodes
288    ///
289    /// # Arguments
290    ///
291    /// * `mode_name` - Name of this mode (for error messages)
292    /// * `available_nodes` - List of node names defined in project config
293    ///
294    /// # Errors
295    ///
296    /// Returns error if any referenced node doesn't exist in project config
297    #[allow(dead_code)] // Will be used in Phase 3
298    pub fn validate(&self, mode_name: &str, available_nodes: &[String]) -> Result<()> {
299        // Check all nodes exist
300        for node in &self.nodes {
301            if !available_nodes.contains(node) {
302                return Err(anyhow::anyhow!(
303                    "Mode '{}': node '{}' not found in project configuration. Available nodes: {}",
304                    mode_name,
305                    node,
306                    available_nodes.join(", ")
307                ));
308            }
309        }
310
311        // Check stop_nodes exist
312        for node in &self.stop_nodes {
313            if !available_nodes.contains(node) {
314                return Err(anyhow::anyhow!(
315                    "Mode '{}': stop_node '{}' not found in project configuration. Available nodes: {}",
316                    mode_name,
317                    node,
318                    available_nodes.join(", ")
319                ));
320            }
321        }
322
323        Ok(())
324    }
325}
326
327#[derive(Debug, Serialize, Deserialize)]
328pub struct RedisConfig {
329    #[serde(default = "default_redis_url")]
330    pub url: String,
331}
332
333/// Docker services configuration for project-level Docker Compose management
334#[derive(Debug, Serialize, Deserialize, Default, Clone)]
335pub struct DockerServicesConfig {
336    /// Services needed for on-robot execution (typically just Redis for message passing)
337    #[serde(default)]
338    pub robot: Vec<String>,
339
340    /// Services needed for edge/remote execution (Redis + databases, etc.)
341    #[serde(default)]
342    pub edge: Vec<String>,
343
344    /// Path to docker-compose.yml relative to project root
345    #[serde(default = "default_compose_file")]
346    pub compose_file: String,
347
348    /// Whether to auto-start services in dev mode
349    #[serde(default = "default_auto_start")]
350    pub auto_start: bool,
351}
352
353impl Default for RedisConfig {
354    fn default() -> Self {
355        Self {
356            url: default_redis_url(),
357        }
358    }
359}
360
361fn default_redis_url() -> String {
362    "redis://localhost:6379".to_string()
363}
364
365// Default value functions
366fn default_enable_cors() -> bool {
367    true
368}
369
370fn default_max_connections() -> u32 {
371    10
372}
373
374fn default_timeout_seconds() -> u64 {
375    30
376}
377
378fn default_worker_count() -> usize {
379    4
380}
381
382fn default_max_queue_size() -> usize {
383    100
384}
385
386fn default_enabled() -> bool {
387    true
388}
389
390fn default_compose_file() -> String {
391    "docker-compose.yml".to_string()
392}
393
394fn default_auto_start() -> bool {
395    true
396}
397
398/// Load robot_id from mecha10.json
399#[allow(dead_code)]
400pub async fn load_robot_id(config_path: &PathBuf) -> Result<String> {
401    let content = tokio::fs::read_to_string(config_path)
402        .await
403        .context("Failed to read mecha10.json")?;
404
405    let config: ProjectConfig = serde_json::from_str(&content).context("Failed to parse mecha10.json")?;
406
407    Ok(config.robot.id)
408}
409
410/// Load full project config from mecha10.json
411pub async fn load_project_config(config_path: &PathBuf) -> Result<ProjectConfig> {
412    let content = tokio::fs::read_to_string(config_path)
413        .await
414        .context("Failed to read mecha10.json")?;
415
416    let config: ProjectConfig = serde_json::from_str(&content).context("Failed to parse mecha10.json")?;
417
418    Ok(config)
419}