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