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