mecha10_cli/
context.rs

1#![allow(dead_code)]
2
3//! CLI execution context
4//!
5//! The CliContext is the single source of truth for runtime configuration,
6//! combining CLI flags, environment variables, and config file settings.
7
8use crate::services::{
9    ComponentCatalogService, ConfigService, DeploymentService, DevService, DiscoveryService, DockerService,
10    InitService, PackageService, ProcessService, ProjectService, ProjectTemplateService, RedisService,
11    SimulationService, TemplateDownloadService, TemplateService, TopologyService,
12};
13use crate::types::ProjectConfig;
14use std::path::PathBuf;
15use tracing::Level;
16
17/// CLI execution context
18///
19/// This provides normalized runtime configuration for the CLI, combining:
20/// - CLI flags
21/// - Environment variables
22/// - Config file settings
23/// - Computed defaults
24///
25/// Use `CliContextBuilder` to construct with proper precedence handling.
26///
27/// # Example
28///
29/// ```rust,ignore
30/// use mecha10_cli::{CliContext, CliContextBuilder};
31///
32/// let ctx = CliContextBuilder::new()
33///     .verbose(true)
34///     .build()?;
35///
36/// assert!(ctx.is_verbose());
37/// ```
38pub struct CliContext {
39    /// Path to the project config file (mecha10.json)
40    pub config_path: PathBuf,
41
42    /// Logging level
43    pub log_level: Level,
44
45    /// Project working directory
46    pub working_dir: PathBuf,
47
48    /// Redis connection URL (from env or config)
49    redis_url: String,
50
51    /// PostgreSQL connection URL (from env or config)
52    postgres_url: Option<String>,
53
54    /// Verbose output mode
55    verbose: bool,
56
57    /// Development mode (affects error display, hot reload, etc.)
58    dev_mode: bool,
59
60    /// Cached services (lazy initialization)
61    redis_service: Option<RedisService>,
62    project_service: Option<ProjectService>,
63    template_service: Option<TemplateService>,
64    simulation_service: Option<SimulationService>,
65    process_service: Option<ProcessService>,
66    docker_service: Option<DockerService>,
67    package_service: Option<PackageService>,
68    deployment_service: Option<DeploymentService>,
69    component_catalog_service: Option<ComponentCatalogService>,
70    discovery_service: Option<DiscoveryService>,
71    init_service: Option<InitService>,
72    project_template_service: Option<ProjectTemplateService>,
73    dev_service: Option<DevService>,
74    topology_service: Option<TopologyService>,
75    template_download_service: Option<TemplateDownloadService>,
76
77    /// Cached project configuration (loaded lazily)
78    /// Note: We don't store this as ProjectConfig doesn't implement Clone
79    /// Instead, commands should load it when needed
80    _phantom: std::marker::PhantomData<ProjectConfig>,
81}
82
83impl CliContext {
84    /// Load the project configuration
85    ///
86    /// This loads the configuration from disk each time it's called.
87    /// For better performance in commands that access config multiple times,
88    /// call this once and store the result.
89    pub async fn load_project_config(&self) -> anyhow::Result<ProjectConfig> {
90        ConfigService::load_from(&self.config_path).await
91    }
92
93    /// Check if a project is initialized (mecha10.json exists)
94    pub fn is_project_initialized(&self) -> bool {
95        ConfigService::is_initialized(&self.working_dir)
96    }
97
98    /// Get a path relative to the project root
99    pub fn project_path(&self, path: &str) -> PathBuf {
100        self.working_dir.join(path)
101    }
102
103    // ========== Convenience Methods ==========
104
105    /// Check if verbose mode is enabled
106    pub fn is_verbose(&self) -> bool {
107        self.verbose
108    }
109
110    /// Check if development mode is enabled
111    pub fn is_dev_mode(&self) -> bool {
112        self.dev_mode
113    }
114
115    /// Get the Redis connection URL
116    pub fn redis_url(&self) -> &str {
117        &self.redis_url
118    }
119
120    /// Get the PostgreSQL connection URL if configured
121    pub fn postgres_url(&self) -> Option<&str> {
122        self.postgres_url.as_deref()
123    }
124
125    // ========== Service Accessors ==========
126
127    /// Get Redis service (creates on first access)
128    pub fn redis(&mut self) -> anyhow::Result<&RedisService> {
129        if self.redis_service.is_none() {
130            self.redis_service = Some(RedisService::new(&self.redis_url)?);
131        }
132        Ok(self.redis_service.as_ref().unwrap())
133    }
134
135    /// Get Project service (creates on first access)
136    pub fn project(&mut self) -> anyhow::Result<&ProjectService> {
137        if self.project_service.is_none() {
138            self.project_service = Some(ProjectService::detect(&self.working_dir)?);
139        }
140        Ok(self.project_service.as_ref().unwrap())
141    }
142
143    /// Get Template service (creates on first access)
144    pub fn template(&mut self) -> &TemplateService {
145        if self.template_service.is_none() {
146            self.template_service = Some(TemplateService::new());
147        }
148        self.template_service.as_ref().unwrap()
149    }
150
151    /// Get Simulation service (creates on first access)
152    pub fn simulation(&mut self) -> &SimulationService {
153        if self.simulation_service.is_none() {
154            self.simulation_service = Some(SimulationService::new());
155        }
156        self.simulation_service.as_ref().unwrap()
157    }
158
159    /// Get Process service (creates on first access)
160    pub fn process(&mut self) -> &mut ProcessService {
161        if self.process_service.is_none() {
162            self.process_service = Some(ProcessService::new());
163        }
164        self.process_service.as_mut().unwrap()
165    }
166
167    /// Get Docker service (creates on first access)
168    pub fn docker(&mut self) -> &DockerService {
169        if self.docker_service.is_none() {
170            self.docker_service = Some(DockerService::new());
171        }
172        self.docker_service.as_ref().unwrap()
173    }
174
175    /// Get Package service (creates on first access)
176    ///
177    /// Note: This requires a valid project to be detected with name and version.
178    pub fn package(&mut self) -> anyhow::Result<&PackageService> {
179        if self.package_service.is_none() {
180            let project = self.project()?;
181            let name = project.name()?;
182            let version = project.version()?;
183            self.package_service = Some(PackageService::new(name, version, self.working_dir.clone())?);
184        }
185        Ok(self.package_service.as_ref().unwrap())
186    }
187
188    /// Get Deployment service (creates on first access)
189    pub fn deployment(&mut self) -> &DeploymentService {
190        if self.deployment_service.is_none() {
191            self.deployment_service = Some(DeploymentService::new());
192        }
193        self.deployment_service.as_ref().unwrap()
194    }
195
196    /// Get Component Catalog service (creates on first access)
197    pub fn component_catalog(&mut self) -> &ComponentCatalogService {
198        if self.component_catalog_service.is_none() {
199            self.component_catalog_service = Some(ComponentCatalogService::new());
200        }
201        self.component_catalog_service.as_ref().unwrap()
202    }
203
204    /// Get Discovery service (creates on first access)
205    pub fn discovery(&mut self) -> &mut DiscoveryService {
206        if self.discovery_service.is_none() {
207            self.discovery_service = Some(DiscoveryService::new());
208        }
209        self.discovery_service.as_mut().unwrap()
210    }
211
212    /// Get Init service (creates on first access)
213    pub fn init_service(&mut self) -> &InitService {
214        if self.init_service.is_none() {
215            self.init_service = Some(InitService::new());
216        }
217        self.init_service.as_ref().unwrap()
218    }
219
220    /// Get Project Template service (creates on first access)
221    pub fn project_template_service(&mut self) -> &ProjectTemplateService {
222        if self.project_template_service.is_none() {
223            self.project_template_service = Some(ProjectTemplateService::new());
224        }
225        self.project_template_service.as_ref().unwrap()
226    }
227
228    /// Get Dev service (creates on first access)
229    pub fn dev(&mut self) -> &DevService {
230        if self.dev_service.is_none() {
231            self.dev_service = Some(DevService::new(self.redis_url.clone()));
232        }
233        self.dev_service.as_ref().unwrap()
234    }
235
236    /// Get Topology service (creates on first access)
237    ///
238    /// The topology service performs static analysis of project structure,
239    /// extracting node definitions and pub/sub topic relationships from source code.
240    pub fn topology(&mut self) -> &TopologyService {
241        if self.topology_service.is_none() {
242            self.topology_service = Some(TopologyService::new(self.working_dir.clone()));
243        }
244        self.topology_service.as_ref().unwrap()
245    }
246
247    /// Get Template Download service (creates on first access)
248    ///
249    /// Downloads project templates from GitHub releases on-demand.
250    /// Templates are versioned and cached locally for offline use.
251    pub fn template_download(&mut self) -> &TemplateDownloadService {
252        if self.template_download_service.is_none() {
253            self.template_download_service = Some(TemplateDownloadService::new());
254        }
255        self.template_download_service.as_ref().unwrap()
256    }
257
258    // ========== Validation Methods ==========
259
260    /// Validate that Docker is installed and running
261    pub fn validate_docker(&mut self) -> anyhow::Result<()> {
262        let docker = self.docker();
263        docker.check_installation()?;
264        docker.check_daemon()?;
265        Ok(())
266    }
267
268    /// Validate that Redis is accessible
269    pub async fn validate_redis(&mut self) -> anyhow::Result<()> {
270        let redis = self.redis()?;
271        // Try to connect by performing a simple operation
272        // The service will fail to create if connection fails
273        let _ = redis;
274        Ok(())
275    }
276
277    /// Validate project structure
278    ///
279    /// Checks that the project has the expected directory structure
280    /// and required files.
281    pub fn validate_project_structure(&self) -> anyhow::Result<()> {
282        if !self.is_project_initialized() {
283            return Err(anyhow::anyhow!("Project not initialized. Run 'mecha10 init' first."));
284        }
285
286        // Check required directories
287        let required_dirs = vec!["nodes", "drivers"];
288        for dir in required_dirs {
289            let path = self.project_path(dir);
290            if !path.exists() {
291                return Err(anyhow::anyhow!(
292                    "Required directory missing: {}. Expected at: {}",
293                    dir,
294                    path.display()
295                ));
296            }
297        }
298
299        Ok(())
300    }
301
302    /// Validate that Godot is installed (for simulation)
303    pub fn validate_godot(&mut self) -> anyhow::Result<()> {
304        let sim = self.simulation();
305        sim.validate_godot()?;
306        Ok(())
307    }
308
309    // ========== Environment Helpers ==========
310
311    /// Check if running in CI environment
312    pub fn is_ci(&self) -> bool {
313        std::env::var("CI").is_ok()
314            || std::env::var("GITHUB_ACTIONS").is_ok()
315            || std::env::var("GITLAB_CI").is_ok()
316            || std::env::var("CIRCLECI").is_ok()
317            || std::env::var("JENKINS_HOME").is_ok()
318    }
319
320    /// Check if running in interactive terminal
321    pub fn is_interactive(&self) -> bool {
322        atty::is(atty::Stream::Stdout) && !self.is_ci()
323    }
324
325    /// Get logs directory path
326    pub fn logs_dir(&self) -> PathBuf {
327        self.project_path("logs")
328    }
329
330    /// Get data directory path
331    pub fn data_dir(&self) -> PathBuf {
332        self.project_path("data")
333    }
334
335    /// Get recordings directory path
336    pub fn recordings_dir(&self) -> PathBuf {
337        self.project_path("data/recordings")
338    }
339
340    /// Get maps directory path
341    pub fn maps_dir(&self) -> PathBuf {
342        self.project_path("data/maps")
343    }
344
345    /// Get telemetry directory path
346    pub fn telemetry_dir(&self) -> PathBuf {
347        self.project_path("data/telemetry")
348    }
349
350    /// Get simulation directory path
351    pub fn simulation_dir(&self) -> PathBuf {
352        self.project_path("simulation")
353    }
354
355    /// Get target/debug directory path
356    pub fn target_debug_dir(&self) -> PathBuf {
357        self.project_path("target/debug")
358    }
359
360    /// Get target/release directory path
361    pub fn target_release_dir(&self) -> PathBuf {
362        self.project_path("target/release")
363    }
364
365    /// Get packages output directory path
366    pub fn packages_dir(&self) -> PathBuf {
367        self.project_path("target/packages")
368    }
369
370    /// Ensure a directory exists, creating it if necessary
371    pub fn ensure_dir(&self, path: &PathBuf) -> anyhow::Result<()> {
372        if !path.exists() {
373            std::fs::create_dir_all(path)?;
374        }
375        Ok(())
376    }
377}
378
379impl Default for CliContext {
380    fn default() -> Self {
381        CliContextBuilder::new()
382            .log_level(Level::INFO)
383            .build()
384            .expect("Failed to build default CliContext")
385    }
386}
387
388// ========== Builder Pattern ==========
389
390/// Builder for CliContext with proper precedence handling
391///
392/// Precedence order (highest to lowest):
393/// 1. Explicitly set builder values
394/// 2. Environment variables
395/// 3. Config file values (if project initialized)
396/// 4. Defaults
397///
398/// # Example
399///
400/// ```rust,ignore
401/// use mecha10_cli::CliContextBuilder;
402/// use tracing::Level;
403///
404/// let ctx = CliContextBuilder::new()
405///     .config_path(Some("custom/mecha10.json".into()))
406///     .log_level(Level::DEBUG)
407///     .verbose(true)
408///     .dev_mode(true)
409///     .build()?;
410/// ```
411pub struct CliContextBuilder {
412    config_path: Option<PathBuf>,
413    log_level: Option<Level>,
414    working_dir: Option<PathBuf>,
415    redis_url: Option<String>,
416    postgres_url: Option<String>,
417    verbose: Option<bool>,
418    dev_mode: Option<bool>,
419}
420
421impl CliContextBuilder {
422    /// Create a new builder
423    pub fn new() -> Self {
424        Self {
425            config_path: None,
426            log_level: None,
427            working_dir: None,
428            redis_url: None,
429            postgres_url: None,
430            verbose: None,
431            dev_mode: None,
432        }
433    }
434
435    /// Set config file path
436    pub fn config_path(mut self, path: Option<PathBuf>) -> Self {
437        self.config_path = path;
438        self
439    }
440
441    /// Set log level
442    pub fn log_level(mut self, level: Level) -> Self {
443        self.log_level = Some(level);
444        self
445    }
446
447    /// Set working directory
448    pub fn working_dir(mut self, dir: PathBuf) -> Self {
449        self.working_dir = Some(dir);
450        self
451    }
452
453    /// Set Redis URL
454    pub fn redis_url(mut self, url: String) -> Self {
455        self.redis_url = Some(url);
456        self
457    }
458
459    /// Set PostgreSQL URL
460    pub fn postgres_url(mut self, url: Option<String>) -> Self {
461        self.postgres_url = url;
462        self
463    }
464
465    /// Set verbose mode
466    pub fn verbose(mut self, verbose: bool) -> Self {
467        self.verbose = Some(verbose);
468        self
469    }
470
471    /// Set development mode
472    pub fn dev_mode(mut self, dev: bool) -> Self {
473        self.dev_mode = Some(dev);
474        self
475    }
476
477    /// Build the CliContext with proper precedence
478    pub fn build(self) -> anyhow::Result<CliContext> {
479        // Working directory: explicit > current dir
480        let working_dir = self
481            .working_dir
482            .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
483
484        // Config path: explicit > working_dir/mecha10.json
485        let config_path = self.config_path.unwrap_or_else(|| working_dir.join("mecha10.json"));
486
487        // Try to load Redis URL from config file (if it exists)
488        let config_redis_url = if config_path.exists() {
489            std::fs::read_to_string(&config_path)
490                .ok()
491                .and_then(|content| serde_json::from_str::<serde_json::Value>(&content).ok())
492                .and_then(|json| json.get("redis")?.get("url")?.as_str().map(String::from))
493        } else {
494            None
495        };
496
497        // Redis URL: explicit > env > config file > default
498        let redis_url = self
499            .redis_url
500            .or_else(|| std::env::var("MECHA10_REDIS_URL").ok())
501            .or_else(|| std::env::var("REDIS_URL").ok())
502            .or(config_redis_url)
503            .unwrap_or_else(|| "redis://localhost:6380".to_string());
504
505        // Postgres URL: explicit > env
506        let postgres_url = self
507            .postgres_url
508            .or_else(|| std::env::var("DATABASE_URL").ok())
509            .or_else(|| std::env::var("POSTGRES_URL").ok());
510
511        // Log level: explicit > INFO
512        let log_level = self.log_level.unwrap_or(Level::INFO);
513
514        // Verbose: explicit > false
515        let verbose = self.verbose.unwrap_or(false);
516
517        // Dev mode: explicit > env > false
518        let dev_mode = self.dev_mode.unwrap_or_else(|| {
519            std::env::var("MECHA10_DEV_MODE")
520                .or_else(|_| std::env::var("DEV_MODE"))
521                .map(|v| v == "1" || v.to_lowercase() == "true")
522                .unwrap_or(false)
523        });
524
525        Ok(CliContext {
526            config_path,
527            log_level,
528            working_dir,
529            redis_url,
530            postgres_url,
531            verbose,
532            dev_mode,
533            redis_service: None,
534            project_service: None,
535            template_service: None,
536            simulation_service: None,
537            process_service: None,
538            docker_service: None,
539            package_service: None,
540            deployment_service: None,
541            component_catalog_service: None,
542            discovery_service: None,
543            init_service: None,
544            project_template_service: None,
545            dev_service: None,
546            topology_service: None,
547            template_download_service: None,
548            _phantom: std::marker::PhantomData,
549        })
550    }
551}
552
553impl Default for CliContextBuilder {
554    fn default() -> Self {
555        Self::new()
556    }
557}