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 environments: EnvironmentsConfig,
28}
29
30// ============================================================================
31// Node Configuration Types
32// ============================================================================
33
34/// Node source type based on namespace prefix
35#[derive(Debug, Clone, PartialEq)]
36pub enum NodeSource {
37    /// Framework node bundled in CLI (@mecha10/*)
38    Framework,
39    /// Local project node (@local/*)
40    Project,
41    /// Third-party registry node (@<org>/*)
42    Registry(String),
43}
44
45/// Parsed node specification from namespaced string format
46///
47/// Node identifiers follow this format:
48/// - `@mecha10/listener` - Framework node (bundled in CLI)
49/// - `@local/my-custom` - Project node (built from local workspace)
50/// - `@someorg/cool-node` - Registry node (future, from npm-like registry)
51///
52/// Config paths follow the identifier:
53/// - `configs/nodes/@mecha10/listener/config.json`
54/// - `configs/nodes/@local/my-custom/config.json`
55#[derive(Debug, Clone)]
56pub struct NodeSpec {
57    /// Node name (derived from identifier, used in lifecycle modes)
58    pub name: String,
59    /// Full namespaced identifier
60    #[allow(dead_code)] // API for future use (registry lookups)
61    pub identifier: String,
62    /// Node source type
63    pub source: NodeSource,
64}
65
66impl NodeSpec {
67    /// Parse a node identifier string into a NodeSpec
68    ///
69    /// # Examples
70    ///
71    /// ```
72    /// let spec = NodeSpec::parse("@mecha10/listener").unwrap();
73    /// assert_eq!(spec.name, "listener");
74    /// assert_eq!(spec.source, NodeSource::Framework);
75    ///
76    /// let spec = NodeSpec::parse("@local/my-custom").unwrap();
77    /// assert_eq!(spec.name, "my-custom");
78    /// assert_eq!(spec.source, NodeSource::Project);
79    /// ```
80    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    /// Check if this is a framework node
121    pub fn is_framework(&self) -> bool {
122        matches!(self.source, NodeSource::Framework)
123    }
124
125    /// Check if this is a project node
126    #[allow(dead_code)] // API for future use
127    pub fn is_project(&self) -> bool {
128        matches!(self.source, NodeSource::Project)
129    }
130
131    /// Get the package/crate path for this node
132    ///
133    /// - Framework: mecha10-nodes-{name}
134    /// - Project: nodes/{name}
135    /// - Registry: node_modules/@{org}/{name} (future)
136    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    /// Get the config directory path for this node (relative to project root)
145    ///
146    /// Config path follows the identifier structure:
147    /// - `@mecha10/listener` → `configs/nodes/@mecha10/listener/`
148    /// - `@local/my-custom` → `configs/nodes/@local/my-custom/`
149    /// - `@someorg/node` → `configs/nodes/@someorg/node/`
150    #[allow(dead_code)] // API for future use
151    pub fn config_dir(&self) -> String {
152        format!("configs/nodes/{}", self.identifier)
153    }
154}
155
156/// Nodes configuration - array of namespaced node identifiers
157///
158/// Node identifiers follow this format:
159/// - `@mecha10/listener` - Framework node (bundled in CLI)
160/// - `@local/my-custom` - Project node (built from local workspace)
161/// - `@someorg/cool-node` - Registry node (future, from npm-like registry)
162///
163/// Example:
164/// ```json
165/// {
166///   "nodes": [
167///     "@mecha10/listener",
168///     "@mecha10/speaker",
169///     "@local/my-custom"
170///   ]
171/// }
172/// ```
173#[derive(Debug, Serialize, Deserialize, Default, Clone)]
174#[serde(transparent)]
175pub struct NodesConfig(pub Vec<String>);
176
177impl NodesConfig {
178    /// Create a new empty nodes config
179    #[allow(dead_code)] // API for future use
180    pub fn new() -> Self {
181        Self(Vec::new())
182    }
183
184    /// Get all node specs from the configuration
185    pub fn get_node_specs(&self) -> Vec<NodeSpec> {
186        self.0.iter().filter_map(|id| NodeSpec::parse(id).ok()).collect()
187    }
188
189    /// Get all node names (for lifecycle mode validation)
190    pub fn get_node_names(&self) -> Vec<String> {
191        self.get_node_specs().iter().map(|s| s.name.clone()).collect()
192    }
193
194    /// Find a node by name
195    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    /// Check if a node exists
200    pub fn contains(&self, name: &str) -> bool {
201        self.find_by_name(name).is_some()
202    }
203
204    /// Add a node to the configuration
205    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    /// Remove a node from the configuration
212    #[allow(dead_code)] // API for future use
213    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    /// Get the raw list of identifiers
219    #[allow(dead_code)] // API for future use
220    pub fn identifiers(&self) -> &[String] {
221        &self.0
222    }
223
224    /// Check if empty
225    #[allow(dead_code)] // API for future use
226    pub fn is_empty(&self) -> bool {
227        self.0.is_empty()
228    }
229
230    /// Get count of nodes
231    #[allow(dead_code)] // API for future use
232    pub fn len(&self) -> usize {
233        self.0.len()
234    }
235}
236
237/// Behavior tree configuration
238///
239/// Controls behavior tree execution for autonomous robot behaviors.
240#[derive(Debug, Serialize, Deserialize, Clone)]
241pub struct BehaviorsConfig {
242    /// Name of the active behavior to run (e.g., "idle_wander")
243    pub active: String,
244
245    /// Directory containing behavior tree templates (relative to project root)
246    #[serde(default = "default_behaviors_templates_dir")]
247    pub templates_dir: String,
248
249    /// Directory containing behavior configuration files (relative to project root)
250    #[serde(default = "default_behaviors_configs_dir")]
251    pub configs_dir: String,
252
253    /// Executor configuration
254    #[serde(default)]
255    pub executor: BehaviorExecutorConfig,
256}
257
258/// Behavior executor configuration
259///
260/// Controls how the behavior tree executor runs.
261#[derive(Debug, Serialize, Deserialize, Clone)]
262pub struct BehaviorExecutorConfig {
263    /// Tick rate in Hz (how many times per second to tick the behavior tree)
264    #[serde(default = "default_tick_rate_hz")]
265    pub tick_rate_hz: f32,
266
267    /// Maximum number of ticks before stopping (None = run forever)
268    #[serde(default)]
269    pub max_ticks: Option<usize>,
270
271    /// Whether to log execution statistics
272    #[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/// Project-level simulation configuration stored in mecha10.json
363///
364/// This controls simulation settings for the project, distinct from
365/// the runtime simulation config loaded from configs/{profile}/simulation/.
366#[derive(Debug, Serialize, Deserialize, Clone)]
367pub struct ProjectSimulationConfig {
368    /// Whether simulation is enabled for this project
369    #[serde(default = "default_simulation_enabled")]
370    pub enabled: bool,
371
372    /// Config profile to use (dev, production, staging, etc.)
373    #[serde(default = "default_config_profile")]
374    pub config_profile: String,
375
376    /// Optional scenario name (loads configs/{profile}/simulation/{scenario}.json)
377    /// If not specified, loads configs/{profile}/simulation/config.json
378    #[serde(default, skip_serializing_if = "Option::is_none")]
379    pub scenario: Option<String>,
380
381    // DEPRECATED: Legacy fields for backwards compatibility
382    // These are ignored if config_profile is set
383    #[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/// Lifecycle configuration for mode-based node management
402///
403/// Enables dynamic node lifecycle based on operational modes.
404/// Each mode defines which nodes should run in that context.
405#[derive(Debug, Serialize, Deserialize, Clone)]
406pub struct LifecycleConfig {
407    /// Available operational modes
408    pub modes: std::collections::HashMap<String, ModeConfig>,
409
410    /// Default mode on startup
411    #[serde(default = "default_lifecycle_mode")]
412    pub default_mode: String,
413}
414
415/// Configuration for a single operational mode
416///
417/// Defines which nodes should run in this mode and which should stop.
418#[derive(Debug, Serialize, Deserialize, Clone)]
419pub struct ModeConfig {
420    /// Human-readable description of this mode
421    #[serde(default, skip_serializing_if = "Option::is_none")]
422    pub description: Option<String>,
423
424    /// Nodes that should run in this mode
425    pub nodes: Vec<String>,
426
427    /// Nodes that should be stopped when entering this mode (optional)
428    /// This is useful for explicitly stopping nodes that conflict with this mode.
429    #[serde(default)]
430    pub stop_nodes: Vec<String>,
431}
432
433fn default_lifecycle_mode() -> String {
434    "startup".to_string()
435}
436
437// Validation for lifecycle configuration
438impl LifecycleConfig {
439    /// Validate lifecycle configuration against available nodes
440    ///
441    /// # Arguments
442    ///
443    /// * `available_nodes` - List of node names defined in project config
444    ///
445    /// # Errors
446    ///
447    /// Returns error if:
448    /// - Default mode doesn't exist in modes
449    /// - Any mode references undefined nodes
450    #[allow(dead_code)] // Will be used in Phase 3
451    pub fn validate(&self, available_nodes: &[String]) -> Result<()> {
452        // Check that default mode exists
453        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        // Validate each mode
462        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    /// Validate mode configuration against available nodes
472    ///
473    /// # Arguments
474    ///
475    /// * `mode_name` - Name of this mode (for error messages)
476    /// * `available_nodes` - List of node names defined in project config
477    ///
478    /// # Errors
479    ///
480    /// Returns error if any referenced node doesn't exist in project config
481    #[allow(dead_code)] // Will be used in Phase 3
482    pub fn validate(&self, mode_name: &str, available_nodes: &[String]) -> Result<()> {
483        // Check all nodes exist
484        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        // Check stop_nodes exist
496        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/// Docker services configuration for project-level Docker Compose management
512#[derive(Debug, Serialize, Deserialize, Default, Clone)]
513pub struct DockerServicesConfig {
514    /// Services needed for on-robot execution (typically just Redis for message passing)
515    #[serde(default)]
516    pub robot: Vec<String>,
517
518    /// Services needed for edge/remote execution (Redis + databases, etc.)
519    #[serde(default)]
520    pub edge: Vec<String>,
521
522    /// Path to docker-compose.yml relative to project root
523    #[serde(default = "default_compose_file")]
524    pub compose_file: String,
525
526    /// Whether to auto-start services in dev mode
527    #[serde(default = "default_auto_start")]
528    pub auto_start: bool,
529}
530
531/// Environment-specific configuration
532///
533/// Allows configuring different URLs for dev, staging, and production environments.
534/// The active environment is determined by:
535/// 1. `MECHA10_ENV` environment variable
536/// 2. `--env` CLI flag
537/// 3. Default: "dev" when running `mecha10 dev`, "prod" otherwise
538#[derive(Debug, Serialize, Deserialize, Clone)]
539pub struct EnvironmentsConfig {
540    /// Development environment (local control plane)
541    #[serde(default = "default_dev_environment")]
542    pub dev: EnvironmentConfig,
543
544    /// Staging environment (optional)
545    #[serde(default, skip_serializing_if = "Option::is_none")]
546    pub staging: Option<EnvironmentConfig>,
547
548    /// Production environment (cloud control plane)
549    #[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    /// Get the environment config for the given environment name
565    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    /// Get the current environment config based on MECHA10_ENV
575    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    /// Get the control plane URL for the current environment
581    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    /// Get the API URL for the current environment
586    /// Derived: {control_plane}/api
587    #[allow(dead_code)]
588    pub fn api_url(&self) -> String {
589        format!("{}/api", self.control_plane_url())
590    }
591
592    /// Get the dashboard URL for the current environment
593    /// Derived: {control_plane}/dashboard
594    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    /// Get the WebRTC relay URL for the current environment
599    /// Derived: ws(s)://{control_plane_host}/webrtc-relay
600    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        // Convert http(s) to ws(s)
607        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    /// Get the Redis URL (always local, env var override)
618    pub fn redis_url(&self) -> String {
619        std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://localhost:6379".to_string())
620    }
621}
622
623/// Configuration for a specific environment
624#[derive(Debug, Serialize, Deserialize, Clone)]
625pub struct EnvironmentConfig {
626    /// Control plane base URL (e.g., "http://localhost:8100" or "https://mecha.industries")
627    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
642// Default value functions
643fn 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/// Load robot_id from mecha10.json
672#[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
683/// Load full project config from mecha10.json
684pub 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}