Skip to main content

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, 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    /// Cached services (lazy initialization)
59    redis_service: Option<RedisService>,
60    project_service: Option<ProjectService>,
61    simulation_service: Option<SimulationService>,
62    process_service: Option<ProcessService>,
63    /// Shared process service for concurrent access (used by CommandListener)
64    shared_process_service: Option<std::sync::Arc<std::sync::Mutex<ProcessService>>>,
65    docker_service: Option<DockerService>,
66    package_service: Option<PackageService>,
67    deployment_service: Option<DeploymentService>,
68    component_catalog_service: Option<ComponentCatalogService>,
69    discovery_service: Option<DiscoveryService>,
70    init_service: Option<InitService>,
71    project_template_service: Option<ProjectTemplateService>,
72    dev_service: Option<DevService>,
73    topology_service: Option<TopologyService>,
74    template_download_service: Option<TemplateDownloadService>,
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
82/// Generates a lazy-init service accessor method on `CliContext`.
83///
84/// `CliContext` caches its services in `Option<T>` fields and constructs them
85/// on first access. Three shapes cover most of these accessors:
86///
87/// - `fn name() -> &Ty, field, ctor` — infallible, returns `&Ty`
88/// - `mut fn name() -> &mut Ty, field, ctor` — infallible, returns `&mut Ty`
89/// - `try fn name() -> &Ty, field, ctor` — fallible, returns `anyhow::Result<&Ty>`
90///   (`ctor` may use `?`)
91macro_rules! lazy_init_getter {
92    ($(#[$meta:meta])* fn $name:ident() -> &$ty:ty, $field:ident, $ctor:expr) => {
93        $(#[$meta])*
94        pub fn $name(&mut self) -> &$ty {
95            if self.$field.is_none() {
96                self.$field = Some($ctor);
97            }
98            self.$field.as_ref().unwrap()
99        }
100    };
101    ($(#[$meta:meta])* mut fn $name:ident() -> &mut $ty:ty, $field:ident, $ctor:expr) => {
102        $(#[$meta])*
103        pub fn $name(&mut self) -> &mut $ty {
104            if self.$field.is_none() {
105                self.$field = Some($ctor);
106            }
107            self.$field.as_mut().unwrap()
108        }
109    };
110    ($(#[$meta:meta])* try fn $name:ident() -> &$ty:ty, $field:ident, $ctor:expr) => {
111        $(#[$meta])*
112        pub fn $name(&mut self) -> anyhow::Result<&$ty> {
113            if self.$field.is_none() {
114                self.$field = Some($ctor);
115            }
116            Ok(self.$field.as_ref().unwrap())
117        }
118    };
119}
120
121impl CliContext {
122    /// Load the project configuration
123    ///
124    /// This loads the configuration from disk each time it's called.
125    /// For better performance in commands that access config multiple times,
126    /// call this once and store the result.
127    pub async fn load_project_config(&self) -> anyhow::Result<ProjectConfig> {
128        ConfigService::load_from(&self.config_path).await
129    }
130
131    /// Check if a project is initialized (mecha10.json exists)
132    pub fn is_project_initialized(&self) -> bool {
133        ConfigService::is_initialized(&self.working_dir)
134    }
135
136    /// Get a path relative to the project root
137    pub fn project_path(&self, path: &str) -> PathBuf {
138        self.working_dir.join(path)
139    }
140
141    // ========== Convenience Methods ==========
142
143    /// Check if verbose mode is enabled
144    pub fn is_verbose(&self) -> bool {
145        self.verbose
146    }
147
148    /// Get the Redis connection URL
149    pub fn redis_url(&self) -> &str {
150        &self.redis_url
151    }
152
153    /// Get the PostgreSQL connection URL if configured
154    pub fn postgres_url(&self) -> Option<&str> {
155        self.postgres_url.as_deref()
156    }
157
158    // ========== Service Accessors ==========
159
160    // Not macro-generated: the constructor needs `self.redis_url`, and `self` inside a
161    // macro_rules! argument doesn't bind to the `self` parameter the macro itself
162    // generates (a hygiene limitation, not something fixable by tweaking the call site).
163    /// Get Redis service (creates on first access)
164    pub fn redis(&mut self) -> anyhow::Result<&RedisService> {
165        if self.redis_service.is_none() {
166            self.redis_service = Some(RedisService::new(&self.redis_url)?);
167        }
168        Ok(self.redis_service.as_ref().unwrap())
169    }
170
171    // Not macro-generated: same self-in-constructor hygiene limitation as `redis` above.
172    /// Get Project service (creates on first access)
173    pub fn project(&mut self) -> anyhow::Result<&ProjectService> {
174        if self.project_service.is_none() {
175            self.project_service = Some(ProjectService::detect(&self.working_dir)?);
176        }
177        Ok(self.project_service.as_ref().unwrap())
178    }
179
180    lazy_init_getter!(
181        /// Get Simulation service (creates on first access)
182        fn simulation() -> &SimulationService, simulation_service, SimulationService::new()
183    );
184
185    lazy_init_getter!(
186        /// Get Process service (creates on first access)
187        mut fn process() -> &mut ProcessService, process_service, ProcessService::new()
188    );
189
190    /// Get shared Process service for concurrent access
191    ///
192    /// This returns an Arc<Mutex<ProcessService>> that can be safely shared
193    /// between tasks (e.g., DevSession and CommandListener).
194    /// Uses std::sync::Mutex so it works in both sync and async contexts.
195    pub fn shared_process(&mut self) -> std::sync::Arc<std::sync::Mutex<ProcessService>> {
196        if self.shared_process_service.is_none() {
197            self.shared_process_service = Some(std::sync::Arc::new(std::sync::Mutex::new(ProcessService::new())));
198        }
199        self.shared_process_service.as_ref().unwrap().clone()
200    }
201
202    lazy_init_getter!(
203        /// Get Docker service (creates on first access)
204        fn docker() -> &DockerService, docker_service, DockerService::new()
205    );
206
207    /// Get Package service (creates on first access)
208    ///
209    /// Note: This requires a valid project to be detected with name and version.
210    pub fn package(&mut self) -> anyhow::Result<&PackageService> {
211        if self.package_service.is_none() {
212            let project = self.project()?;
213            let name = project.name()?;
214            let version = project.version()?;
215            self.package_service = Some(PackageService::new(name, version, self.working_dir.clone())?);
216        }
217        Ok(self.package_service.as_ref().unwrap())
218    }
219
220    lazy_init_getter!(
221        /// Get Deployment service (creates on first access)
222        fn deployment() -> &DeploymentService, deployment_service, DeploymentService::new()
223    );
224
225    lazy_init_getter!(
226        /// Get Component Catalog service (creates on first access)
227        fn component_catalog() -> &ComponentCatalogService, component_catalog_service, ComponentCatalogService::new()
228    );
229
230    lazy_init_getter!(
231        /// Get Discovery service (creates on first access)
232        mut fn discovery() -> &mut DiscoveryService, discovery_service, DiscoveryService::new()
233    );
234
235    lazy_init_getter!(
236        /// Get Init service (creates on first access)
237        fn init_service() -> &InitService, init_service, InitService::new()
238    );
239
240    lazy_init_getter!(
241        /// Get Project Template service (creates on first access)
242        fn project_template_service() -> &ProjectTemplateService, project_template_service, ProjectTemplateService::new()
243    );
244
245    // Not macro-generated: same self-in-constructor hygiene limitation as `redis` above.
246    /// Get Dev service (creates on first access)
247    pub fn dev(&mut self) -> &DevService {
248        if self.dev_service.is_none() {
249            self.dev_service = Some(DevService::new(self.redis_url.clone()));
250        }
251        self.dev_service.as_ref().unwrap()
252    }
253
254    /// Get Topology service (creates on first access)
255    ///
256    /// The topology service performs static analysis of project structure,
257    /// extracting node definitions and pub/sub topic relationships from source code.
258    ///
259    /// Not macro-generated: same self-in-constructor hygiene limitation as `redis` above.
260    pub fn topology(&mut self) -> &TopologyService {
261        if self.topology_service.is_none() {
262            self.topology_service = Some(TopologyService::new(self.working_dir.clone()));
263        }
264        self.topology_service.as_ref().unwrap()
265    }
266
267    lazy_init_getter!(
268        /// Get Template Download service (creates on first access)
269        ///
270        /// Downloads project templates from GitHub releases on-demand.
271        /// Templates are versioned and cached locally for offline use.
272        fn template_download() -> &TemplateDownloadService, template_download_service, TemplateDownloadService::new()
273    );
274
275    // ========== Validation Methods ==========
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    // ========== Environment Helpers ==========
303
304    /// Check if running in CI environment
305    pub fn is_ci(&self) -> bool {
306        std::env::var("CI").is_ok()
307            || std::env::var("GITHUB_ACTIONS").is_ok()
308            || std::env::var("GITLAB_CI").is_ok()
309            || std::env::var("CIRCLECI").is_ok()
310            || std::env::var("JENKINS_HOME").is_ok()
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///     .build()?;
397/// ```
398pub struct CliContextBuilder {
399    config_path: Option<PathBuf>,
400    log_level: Option<Level>,
401    working_dir: Option<PathBuf>,
402    redis_url: Option<String>,
403    postgres_url: Option<String>,
404    verbose: Option<bool>,
405}
406
407impl CliContextBuilder {
408    /// Create a new builder
409    pub fn new() -> Self {
410        Self {
411            config_path: None,
412            log_level: None,
413            working_dir: None,
414            redis_url: None,
415            postgres_url: None,
416            verbose: None,
417        }
418    }
419
420    mecha10_core::builder_setter!(
421        /// Set config file path
422        assign, config_path, config_path, Option<PathBuf>
423    );
424
425    mecha10_core::builder_setter!(
426        /// Set log level
427        into_some, log_level, log_level, Level
428    );
429
430    mecha10_core::builder_setter!(
431        /// Set working directory
432        into_some, working_dir, working_dir, PathBuf
433    );
434
435    mecha10_core::builder_setter!(
436        /// Set Redis URL
437        into_some, redis_url, redis_url, String
438    );
439
440    mecha10_core::builder_setter!(
441        /// Set PostgreSQL URL
442        assign, postgres_url, postgres_url, Option<String>
443    );
444
445    mecha10_core::builder_setter!(
446        /// Set verbose mode
447        into_some, verbose, verbose, bool
448    );
449
450    /// Build the CliContext with proper precedence
451    pub fn build(self) -> anyhow::Result<CliContext> {
452        // Working directory: explicit > current dir
453        let working_dir = self
454            .working_dir
455            .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
456
457        // Config path: explicit > working_dir/mecha10.json
458        let config_path = self
459            .config_path
460            .unwrap_or_else(|| working_dir.join(paths::PROJECT_CONFIG));
461
462        // Try to load Redis URL from config file (if it exists)
463        let config_redis_url = if config_path.exists() {
464            std::fs::read_to_string(&config_path)
465                .ok()
466                .and_then(|content| serde_json::from_str::<serde_json::Value>(&content).ok())
467                .and_then(|json| json.get("redis")?.get("url")?.as_str().map(String::from))
468        } else {
469            None
470        };
471
472        // Redis URL: explicit > env > config file > default
473        let redis_url = self
474            .redis_url
475            .or_else(|| std::env::var("MECHA10_REDIS_URL").ok())
476            .or_else(|| std::env::var("REDIS_URL").ok())
477            .or(config_redis_url)
478            .unwrap_or_else(|| "redis://localhost:6379".to_string());
479
480        // Postgres URL: explicit > env
481        let postgres_url = self
482            .postgres_url
483            .or_else(|| std::env::var("DATABASE_URL").ok())
484            .or_else(|| std::env::var("POSTGRES_URL").ok());
485
486        // Log level: explicit > INFO
487        let log_level = self.log_level.unwrap_or(Level::INFO);
488
489        // Verbose: explicit > false
490        let verbose = self.verbose.unwrap_or(false);
491
492        Ok(CliContext {
493            config_path,
494            log_level,
495            working_dir,
496            redis_url,
497            postgres_url,
498            verbose,
499            redis_service: None,
500            project_service: None,
501            simulation_service: None,
502            process_service: None,
503            shared_process_service: None,
504            docker_service: None,
505            package_service: None,
506            deployment_service: None,
507            component_catalog_service: None,
508            discovery_service: None,
509            init_service: None,
510            project_template_service: None,
511            dev_service: None,
512            topology_service: None,
513            template_download_service: None,
514            _phantom: std::marker::PhantomData,
515        })
516    }
517}
518
519impl Default for CliContextBuilder {
520    fn default() -> Self {
521        Self::new()
522    }
523}