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