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 targets: Option<TargetsConfig>,
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub behaviors: Option<BehaviorsConfig>,
24    #[serde(default)]
25    pub services: ServicesConfig,
26    #[serde(default)]
27    pub docker: DockerServicesConfig,
28    #[serde(default)]
29    pub environments: EnvironmentsConfig,
30}
31
32// ============================================================================
33// Node Configuration Types
34// ============================================================================
35
36/// Node source type based on namespace prefix
37#[derive(Debug, Clone, PartialEq)]
38pub enum NodeSource {
39    /// Framework node bundled in CLI (@mecha10/*)
40    Framework,
41    /// Local project node (@local/*)
42    Project,
43    /// Third-party registry node (@<org>/*)
44    Registry(String),
45}
46
47/// Parsed node specification from namespaced string format
48///
49/// Node identifiers follow this format:
50/// - `@mecha10/listener` - Framework node (bundled in CLI)
51/// - `my-custom` - Project node (built from local workspace, no prefix needed)
52/// - `@local/my-custom` - Project node (legacy format, still supported)
53/// - `@someorg/cool-node` - Registry node (future, from npm-like registry)
54///
55/// Config paths follow the identifier:
56/// - `configs/nodes/@mecha10/listener/config.json`
57/// - `configs/nodes/my-custom/config.json`
58#[derive(Debug, Clone)]
59pub struct NodeSpec {
60    /// Node name (derived from identifier, used in lifecycle modes)
61    pub name: String,
62    /// Full namespaced identifier
63    #[allow(dead_code)] // API for future use (registry lookups)
64    pub identifier: String,
65    /// Node source type
66    pub source: NodeSource,
67}
68
69impl NodeSpec {
70    /// Parse a node identifier string into a NodeSpec
71    ///
72    /// # Examples
73    ///
74    /// ```
75    /// let spec = NodeSpec::parse("@mecha10/listener").unwrap();
76    /// assert_eq!(spec.name, "listener");
77    /// assert_eq!(spec.source, NodeSource::Framework);
78    ///
79    /// let spec = NodeSpec::parse("my-custom").unwrap();
80    /// assert_eq!(spec.name, "my-custom");
81    /// assert_eq!(spec.source, NodeSource::Project);
82    ///
83    /// // Legacy format still supported
84    /// let spec = NodeSpec::parse("@local/my-custom").unwrap();
85    /// assert_eq!(spec.name, "my-custom");
86    /// assert_eq!(spec.source, NodeSource::Project);
87    /// ```
88    pub fn parse(identifier: &str) -> Result<Self> {
89        let identifier = identifier.trim();
90
91        // Plain name without @ prefix = project node
92        if !identifier.starts_with('@') {
93            if identifier.is_empty() {
94                anyhow::bail!("Empty node identifier");
95            }
96            return Ok(Self {
97                name: identifier.to_string(),
98                identifier: identifier.to_string(),
99                source: NodeSource::Project,
100            });
101        }
102
103        let without_at = identifier.strip_prefix('@').unwrap();
104        let parts: Vec<&str> = without_at.splitn(2, '/').collect();
105
106        if parts.len() != 2 {
107            anyhow::bail!(
108                "Invalid node identifier '{}'. Expected @<scope>/<name> format",
109                identifier
110            );
111        }
112
113        let scope = parts[0];
114        let name = parts[1].to_string();
115
116        if scope.is_empty() || name.is_empty() {
117            anyhow::bail!("Empty scope or name in identifier: {}", identifier);
118        }
119
120        let source = match scope {
121            "mecha10" => NodeSource::Framework,
122            "local" => NodeSource::Project,
123            org => NodeSource::Registry(org.to_string()),
124        };
125
126        Ok(Self {
127            name,
128            identifier: identifier.to_string(),
129            source,
130        })
131    }
132
133    /// Check if this is a framework node
134    pub fn is_framework(&self) -> bool {
135        matches!(self.source, NodeSource::Framework)
136    }
137
138    /// Check if this is a project node
139    #[allow(dead_code)] // API for future use
140    pub fn is_project(&self) -> bool {
141        matches!(self.source, NodeSource::Project)
142    }
143
144    /// Get the package/crate path for this node
145    ///
146    /// - Framework: mecha10-nodes-{name}
147    /// - Project: nodes/{name}
148    /// - Registry: node_modules/@{org}/{name} (future)
149    pub fn package_path(&self) -> String {
150        match &self.source {
151            NodeSource::Framework => format!("mecha10-nodes-{}", self.name),
152            NodeSource::Project => format!("nodes/{}", self.name),
153            NodeSource::Registry(org) => format!("node_modules/@{}/{}", org, self.name),
154        }
155    }
156
157    /// Get the config directory path for this node (relative to project root)
158    ///
159    /// Config path follows the identifier structure:
160    /// - `@mecha10/listener` → `configs/nodes/@mecha10/listener/`
161    /// - `my-custom` → `configs/nodes/my-custom/`
162    /// - `@local/my-custom` → `configs/nodes/my-custom/` (legacy: uses name only)
163    /// - `@someorg/node` → `configs/nodes/@someorg/node/`
164    #[allow(dead_code)] // API for future use
165    pub fn config_dir(&self) -> String {
166        match &self.source {
167            NodeSource::Framework => format!("configs/nodes/{}", self.identifier),
168            NodeSource::Project => format!("configs/nodes/{}", self.name),
169            NodeSource::Registry(_) => format!("configs/nodes/{}", self.identifier),
170        }
171    }
172}
173
174/// Nodes configuration - array of namespaced node identifiers
175///
176/// Node identifiers follow this format:
177/// - `@mecha10/listener` - Framework node (bundled in CLI)
178/// - `@local/my-custom` - Project node (built from local workspace)
179/// - `@someorg/cool-node` - Registry node (future, from npm-like registry)
180///
181/// Example:
182/// ```json
183/// {
184///   "nodes": [
185///     "@mecha10/listener",
186///     "@mecha10/speaker",
187///     "@local/my-custom"
188///   ]
189/// }
190/// ```
191#[derive(Debug, Serialize, Deserialize, Default, Clone)]
192#[serde(transparent)]
193pub struct NodesConfig(pub Vec<String>);
194
195impl NodesConfig {
196    /// Create a new empty nodes config
197    #[allow(dead_code)] // API for future use
198    pub fn new() -> Self {
199        Self(Vec::new())
200    }
201
202    /// Get all node specs from the configuration
203    pub fn get_node_specs(&self) -> Vec<NodeSpec> {
204        self.0.iter().filter_map(|id| NodeSpec::parse(id).ok()).collect()
205    }
206
207    /// Get all node names (for lifecycle mode validation)
208    pub fn get_node_names(&self) -> Vec<String> {
209        self.get_node_specs().iter().map(|s| s.name.clone()).collect()
210    }
211
212    /// Find a node by name
213    pub fn find_by_name(&self, name: &str) -> Option<NodeSpec> {
214        self.get_node_specs().into_iter().find(|s| s.name == name)
215    }
216
217    /// Check if a node exists
218    pub fn contains(&self, name: &str) -> bool {
219        self.find_by_name(name).is_some()
220    }
221
222    /// Add a node to the configuration
223    pub fn add_node(&mut self, identifier: &str) {
224        if !self.0.contains(&identifier.to_string()) {
225            self.0.push(identifier.to_string());
226        }
227    }
228
229    /// Remove a node from the configuration
230    #[allow(dead_code)] // API for future use
231    pub fn remove_node(&mut self, name: &str) {
232        self.0
233            .retain(|id| NodeSpec::parse(id).map(|s| s.name != name).unwrap_or(true));
234    }
235
236    /// Get the raw list of identifiers
237    #[allow(dead_code)] // API for future use
238    pub fn identifiers(&self) -> &[String] {
239        &self.0
240    }
241
242    /// Check if empty
243    #[allow(dead_code)] // API for future use
244    pub fn is_empty(&self) -> bool {
245        self.0.is_empty()
246    }
247
248    /// Get count of nodes
249    #[allow(dead_code)] // API for future use
250    pub fn len(&self) -> usize {
251        self.0.len()
252    }
253}
254
255// ============================================================================
256// Targets Configuration Types
257// ============================================================================
258
259/// Target execution configuration - defines where nodes run
260///
261/// Nodes can run either on the robot (as local processes) or remotely
262/// (in Docker containers). This configuration determines the execution
263/// target for each node.
264///
265/// Example:
266/// ```json
267/// {
268///   "targets": {
269///     "robot": ["@mecha10/motor", "@mecha10/camera", "@mecha10/websocket-bridge"],
270///     "remote": ["@mecha10/object-detector", "@mecha10/image-classifier"]
271///   }
272/// }
273/// ```
274#[derive(Debug, Serialize, Deserialize, Clone, Default)]
275pub struct TargetsConfig {
276    /// Nodes that run on the robot as local processes
277    /// These are typically hardware drivers and low-latency control nodes
278    #[serde(default)]
279    pub robot: Vec<String>,
280
281    /// Nodes that run remotely in Docker containers
282    /// These are typically AI/ML nodes with platform-specific dependencies
283    #[serde(default)]
284    pub remote: Vec<String>,
285}
286
287/// Target type for node execution
288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
289#[allow(dead_code)] // Will be used by remote CLI commands
290pub enum NodeTarget {
291    /// Run as local process on robot
292    Robot,
293    /// Run in remote Docker container
294    Remote,
295    /// No explicit target - runs as local process (default)
296    Default,
297}
298
299#[allow(dead_code)] // Will be used by remote CLI commands
300impl TargetsConfig {
301    /// Get the target for a given node identifier
302    ///
303    /// # Arguments
304    ///
305    /// * `node_id` - Node identifier (e.g., "@mecha10/object-detector")
306    ///
307    /// # Returns
308    ///
309    /// The target type for the node
310    pub fn get_target(&self, node_id: &str) -> NodeTarget {
311        if self.robot.iter().any(|n| n == node_id) {
312            NodeTarget::Robot
313        } else if self.remote.iter().any(|n| n == node_id) {
314            NodeTarget::Remote
315        } else {
316            NodeTarget::Default
317        }
318    }
319
320    /// Check if a node should run remotely
321    pub fn is_remote(&self, node_id: &str) -> bool {
322        self.remote.iter().any(|n| n == node_id)
323    }
324
325    /// Check if a node should run on robot
326    pub fn is_robot(&self, node_id: &str) -> bool {
327        self.robot.iter().any(|n| n == node_id)
328    }
329
330    /// Get all remote node identifiers
331    pub fn remote_nodes(&self) -> &[String] {
332        &self.remote
333    }
334
335    /// Get all robot node identifiers
336    #[allow(dead_code)]
337    pub fn robot_nodes(&self) -> &[String] {
338        &self.robot
339    }
340
341    /// Check if there are any remote nodes configured
342    pub fn has_remote_nodes(&self) -> bool {
343        !self.remote.is_empty()
344    }
345
346    /// Check if any remote nodes are custom (@local/*) vs framework (@mecha10/*)
347    pub fn has_custom_remote_nodes(&self) -> bool {
348        self.remote
349            .iter()
350            .any(|n| n.starts_with("@local/") || !n.starts_with('@'))
351    }
352
353    /// Get only the framework remote nodes (@mecha10/*)
354    pub fn framework_remote_nodes(&self) -> Vec<&str> {
355        self.remote
356            .iter()
357            .filter(|n| n.starts_with("@mecha10/"))
358            .map(|s| s.as_str())
359            .collect()
360    }
361
362    /// Get sorted remote nodes for hashing
363    pub fn sorted_remote_nodes(&self) -> Vec<String> {
364        let mut nodes = self.remote.clone();
365        nodes.sort();
366        nodes
367    }
368}
369
370/// Behavior tree configuration
371///
372/// Controls behavior tree execution for autonomous robot behaviors.
373#[derive(Debug, Serialize, Deserialize, Clone)]
374pub struct BehaviorsConfig {
375    /// Name of the active behavior to run (e.g., "idle_wander")
376    pub active: String,
377}
378
379#[derive(Debug, Serialize, Deserialize, Default)]
380pub struct ServicesConfig {
381    #[serde(default)]
382    pub http_api: Option<HttpApiServiceConfig>,
383    #[serde(default)]
384    pub database: Option<DatabaseServiceConfig>,
385    #[serde(default)]
386    pub job_processor: Option<JobProcessorServiceConfig>,
387    #[serde(default)]
388    pub scheduler: Option<SchedulerServiceConfig>,
389}
390
391#[derive(Debug, Serialize, Deserialize, Clone)]
392pub struct HttpApiServiceConfig {
393    pub host: String,
394    pub port: u16,
395    #[serde(default = "default_enable_cors")]
396    pub _enable_cors: bool,
397}
398
399#[derive(Debug, Serialize, Deserialize, Clone)]
400pub struct DatabaseServiceConfig {
401    pub url: String,
402    #[serde(default = "default_max_connections")]
403    pub max_connections: u32,
404    #[serde(default = "default_timeout_seconds")]
405    pub timeout_seconds: u64,
406}
407
408#[derive(Debug, Serialize, Deserialize, Clone)]
409pub struct JobProcessorServiceConfig {
410    #[serde(default = "default_worker_count")]
411    pub worker_count: usize,
412    #[serde(default = "default_max_queue_size")]
413    pub max_queue_size: usize,
414}
415
416#[derive(Debug, Serialize, Deserialize, Clone)]
417pub struct SchedulerServiceConfig {
418    pub tasks: Vec<ScheduledTaskConfig>,
419}
420
421#[derive(Debug, Serialize, Deserialize, Clone)]
422#[allow(dead_code)]
423pub struct ScheduledTaskConfig {
424    pub name: String,
425    pub cron: String,
426    pub topic: String,
427    pub payload: String,
428}
429
430#[derive(Debug, Serialize, Deserialize)]
431pub struct RobotConfig {
432    pub id: String,
433    #[serde(default)]
434    pub platform: Option<String>,
435    #[serde(default)]
436    pub description: Option<String>,
437}
438
439/// Project-level simulation configuration stored in mecha10.json
440///
441/// This controls simulation settings for the project, distinct from
442/// the runtime simulation config loaded from configs/{profile}/simulation/.
443#[derive(Debug, Serialize, Deserialize, Clone)]
444pub struct ProjectSimulationConfig {
445    /// Optional scenario name (loads configs/{profile}/simulation/{scenario}.json)
446    /// If not specified, loads configs/{profile}/simulation/config.json
447    #[serde(default, skip_serializing_if = "Option::is_none")]
448    pub scenario: Option<String>,
449
450    #[serde(default, skip_serializing_if = "Option::is_none")]
451    pub model: Option<String>,
452    #[serde(default, skip_serializing_if = "Option::is_none")]
453    pub model_config: Option<String>,
454    #[serde(default, skip_serializing_if = "Option::is_none")]
455    pub environment: Option<String>,
456    #[serde(default, skip_serializing_if = "Option::is_none")]
457    pub environment_config: Option<String>,
458}
459
460/// Lifecycle configuration for mode-based node management
461///
462/// Enables dynamic node lifecycle based on operational modes.
463/// Each mode defines which nodes should run in that context.
464#[derive(Debug, Serialize, Deserialize, Clone)]
465pub struct LifecycleConfig {
466    /// Available operational modes
467    pub modes: std::collections::HashMap<String, ModeConfig>,
468
469    /// Default mode on startup
470    #[serde(default = "default_lifecycle_mode")]
471    pub default_mode: String,
472}
473
474/// Configuration for a single operational mode
475///
476/// Defines which nodes should run in this mode.
477#[derive(Debug, Serialize, Deserialize, Clone)]
478pub struct ModeConfig {
479    /// Nodes that should run in this mode
480    pub nodes: Vec<String>,
481}
482
483fn default_lifecycle_mode() -> String {
484    "startup".to_string()
485}
486
487// Validation for lifecycle configuration
488impl LifecycleConfig {
489    /// Validate lifecycle configuration against available nodes
490    ///
491    /// # Arguments
492    ///
493    /// * `available_nodes` - List of node names defined in project config
494    ///
495    /// # Errors
496    ///
497    /// Returns error if:
498    /// - Default mode doesn't exist in modes
499    /// - Any mode references undefined nodes
500    #[allow(dead_code)] // Will be used in Phase 3
501    pub fn validate(&self, available_nodes: &[String]) -> Result<()> {
502        // Check that default mode exists
503        if !self.modes.contains_key(&self.default_mode) {
504            return Err(anyhow::anyhow!(
505                "Default mode '{}' not found in modes. Available modes: {}",
506                self.default_mode,
507                self.modes.keys().map(|k| k.as_str()).collect::<Vec<_>>().join(", ")
508            ));
509        }
510
511        // Validate each mode
512        for (mode_name, mode_config) in &self.modes {
513            mode_config.validate(mode_name, available_nodes)?;
514        }
515
516        Ok(())
517    }
518}
519
520impl ModeConfig {
521    /// Validate mode configuration against available nodes
522    ///
523    /// # Arguments
524    ///
525    /// * `mode_name` - Name of this mode (for error messages)
526    /// * `available_nodes` - List of node names defined in project config
527    ///
528    /// # Errors
529    ///
530    /// Returns error if any referenced node doesn't exist in project config
531    #[allow(dead_code)] // Will be used in Phase 3
532    pub fn validate(&self, mode_name: &str, available_nodes: &[String]) -> Result<()> {
533        // Check all nodes exist
534        for node in &self.nodes {
535            if !available_nodes.contains(node) {
536                return Err(anyhow::anyhow!(
537                    "Mode '{}': node '{}' not found in project configuration. Available nodes: {}",
538                    mode_name,
539                    node,
540                    available_nodes.join(", ")
541                ));
542            }
543        }
544
545        Ok(())
546    }
547}
548
549/// Docker services configuration for project-level Docker Compose management
550#[derive(Debug, Serialize, Deserialize, Default, Clone)]
551pub struct DockerServicesConfig {
552    /// Services needed for on-robot execution (typically just Redis for message passing)
553    #[serde(default)]
554    pub robot: Vec<String>,
555
556    /// Services needed for edge/remote execution (Redis + databases, etc.)
557    #[serde(default)]
558    pub edge: Vec<String>,
559
560    /// Path to docker-compose.yml relative to project root
561    #[serde(default = "default_compose_file")]
562    pub compose_file: String,
563
564    /// Whether to auto-start services in dev mode
565    #[serde(default = "default_auto_start")]
566    pub auto_start: bool,
567}
568
569/// Environment-specific configuration
570///
571/// Allows configuring different URLs for dev, staging, and production environments.
572/// The active environment is determined by:
573/// 1. `MECHA10_ENV` environment variable
574/// 2. `--env` CLI flag
575/// 3. Default: "dev" when running `mecha10 dev`, "prod" otherwise
576#[derive(Debug, Serialize, Deserialize, Clone)]
577pub struct EnvironmentsConfig {
578    /// Development environment (local control plane)
579    #[serde(default = "default_dev_environment")]
580    pub dev: EnvironmentConfig,
581
582    /// Staging environment (optional)
583    #[serde(default, skip_serializing_if = "Option::is_none")]
584    pub staging: Option<EnvironmentConfig>,
585
586    /// Production environment (cloud control plane)
587    #[serde(default = "default_prod_environment")]
588    pub prod: EnvironmentConfig,
589}
590
591impl Default for EnvironmentsConfig {
592    fn default() -> Self {
593        Self {
594            dev: default_dev_environment(),
595            staging: None,
596            prod: default_prod_environment(),
597        }
598    }
599}
600
601impl EnvironmentsConfig {
602    /// Get the environment config for the given environment name
603    pub fn get(&self, env: &str) -> Option<&EnvironmentConfig> {
604        match env {
605            "dev" | "development" => Some(&self.dev),
606            "staging" => self.staging.as_ref(),
607            "prod" | "production" => Some(&self.prod),
608            _ => None,
609        }
610    }
611
612    /// Get the current environment config based on MECHA10_ENV
613    pub fn current(&self) -> &EnvironmentConfig {
614        let env = std::env::var("MECHA10_ENV").unwrap_or_else(|_| "dev".to_string());
615        self.get(&env).unwrap_or(&self.dev)
616    }
617
618    /// Get the control plane URL for the current environment
619    pub fn control_plane_url(&self) -> String {
620        std::env::var("MECHA10_CONTROL_PLANE_URL").unwrap_or_else(|_| self.current().control_plane.clone())
621    }
622
623    /// Get the API URL for the current environment
624    /// Derived: {control_plane}/api
625    #[allow(dead_code)]
626    pub fn api_url(&self) -> String {
627        format!("{}/api", self.control_plane_url())
628    }
629
630    /// Get the dashboard URL for the current environment
631    /// Derived: {control_plane}/dashboard
632    pub fn dashboard_url(&self) -> String {
633        std::env::var("MECHA10_DASHBOARD_URL").unwrap_or_else(|_| format!("{}/dashboard", self.control_plane_url()))
634    }
635
636    /// Get the WebRTC relay URL for the current environment
637    /// Derived: ws(s)://{control_plane_host}/webrtc-relay
638    /// Note: This is for WebRTC camera signaling (simulation-bridge).
639    /// The websocket-bridge node config uses ${CONTROL_PLANE_URL}/ws-relay for general messaging.
640    pub fn relay_url(&self) -> String {
641        if let Ok(url) = std::env::var("WEBRTC_RELAY_URL") {
642            return url;
643        }
644
645        let control_plane = self.control_plane_url();
646        // Convert http(s) to ws(s)
647        let ws_url = if control_plane.starts_with("https://") {
648            control_plane.replace("https://", "wss://")
649        } else if control_plane.starts_with("http://") {
650            control_plane.replace("http://", "ws://")
651        } else {
652            control_plane
653        };
654        format!("{}/webrtc-relay", ws_url)
655    }
656
657    /// Get the Redis URL (always local, env var override)
658    pub fn redis_url(&self) -> String {
659        std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://localhost:6380".to_string())
660    }
661}
662
663/// Configuration for a specific environment
664#[derive(Debug, Serialize, Deserialize, Clone)]
665pub struct EnvironmentConfig {
666    /// Control plane base URL (e.g., "http://localhost:8100" or "https://mecha.industries/api")
667    pub control_plane: String,
668}
669
670fn default_dev_environment() -> EnvironmentConfig {
671    EnvironmentConfig {
672        control_plane: "http://localhost:8100".to_string(),
673    }
674}
675
676fn default_prod_environment() -> EnvironmentConfig {
677    EnvironmentConfig {
678        control_plane: "https://mecha.industries".to_string(),
679    }
680}
681
682// Default value functions
683fn default_enable_cors() -> bool {
684    true
685}
686
687fn default_max_connections() -> u32 {
688    10
689}
690
691fn default_timeout_seconds() -> u64 {
692    30
693}
694
695fn default_worker_count() -> usize {
696    4
697}
698
699fn default_max_queue_size() -> usize {
700    100
701}
702
703fn default_compose_file() -> String {
704    "docker/docker-compose.yml".to_string()
705}
706
707fn default_auto_start() -> bool {
708    true
709}
710
711/// Load robot_id from mecha10.json
712#[allow(dead_code)]
713pub async fn load_robot_id(config_path: &PathBuf) -> Result<String> {
714    let content = tokio::fs::read_to_string(config_path)
715        .await
716        .context("Failed to read mecha10.json")?;
717
718    let config: ProjectConfig = serde_json::from_str(&content).context("Failed to parse mecha10.json")?;
719
720    Ok(config.robot.id)
721}
722
723/// Load full project config from mecha10.json
724pub async fn load_project_config(config_path: &PathBuf) -> Result<ProjectConfig> {
725    let content = tokio::fs::read_to_string(config_path)
726        .await
727        .context("Failed to read mecha10.json")?;
728
729    let config: ProjectConfig = serde_json::from_str(&content).context("Failed to parse mecha10.json")?;
730
731    Ok(config)
732}