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/// - `fn name() -> &Ty, field, self |s| ctor` — infallible, `ctor` may reference `self` fields
89///   through the bound `s` name (see the `try fn` note below for why)
90/// - `mut fn name() -> &mut Ty, field, ctor` — infallible, returns `&mut Ty`
91/// - `try fn name() -> &Ty, field, |s| ctor` — fallible, returns `anyhow::Result<&Ty>`
92///   (`ctor` may use `?` and may reference `self` fields through the bound `s` name — a plain
93///   identifier rather than the `self` keyword, since a keyword token passed through a macro
94///   invocation can't resolve to the `self` parameter the macro's definition-site fn signature
95///   introduces; see <https://github.com/rust-lang/rust/issues/61574>)
96macro_rules! lazy_init_getter {
97    ($(#[$meta:meta])* fn $name:ident() -> &$ty:ty, $field:ident, self |$self_:ident| $ctor:expr) => {
98        $(#[$meta])*
99        pub fn $name(&mut self) -> &$ty {
100            if self.$field.is_none() {
101                let value = {
102                    let $self_ = &*self;
103                    $ctor
104                };
105                self.$field = Some(value);
106            }
107            self.$field.as_ref().unwrap()
108        }
109    };
110    ($(#[$meta:meta])* fn $name:ident() -> &$ty:ty, $field:ident, $ctor:expr) => {
111        $(#[$meta])*
112        pub fn $name(&mut self) -> &$ty {
113            if self.$field.is_none() {
114                self.$field = Some($ctor);
115            }
116            self.$field.as_ref().unwrap()
117        }
118    };
119    ($(#[$meta:meta])* mut fn $name:ident() -> &mut $ty:ty, $field:ident, $ctor:expr) => {
120        $(#[$meta])*
121        pub fn $name(&mut self) -> &mut $ty {
122            if self.$field.is_none() {
123                self.$field = Some($ctor);
124            }
125            self.$field.as_mut().unwrap()
126        }
127    };
128    ($(#[$meta:meta])* try fn $name:ident() -> &$ty:ty, $field:ident, |$self_:ident| $ctor:expr) => {
129        $(#[$meta])*
130        pub fn $name(&mut self) -> anyhow::Result<&$ty> {
131            if self.$field.is_none() {
132                let value = {
133                    let $self_ = &*self;
134                    $ctor
135                };
136                self.$field = Some(value);
137            }
138            Ok(self.$field.as_ref().unwrap())
139        }
140    };
141}
142
143impl CliContext {
144    /// Load the project configuration
145    ///
146    /// This loads the configuration from disk each time it's called.
147    /// For better performance in commands that access config multiple times,
148    /// call this once and store the result.
149    pub async fn load_project_config(&self) -> anyhow::Result<ProjectConfig> {
150        ConfigService::load_from(&self.config_path).await
151    }
152
153    /// Check if a project is initialized (mecha10.json exists)
154    pub fn is_project_initialized(&self) -> bool {
155        ConfigService::is_initialized(&self.working_dir)
156    }
157
158    /// Get a path relative to the project root
159    pub fn project_path(&self, path: &str) -> PathBuf {
160        self.working_dir.join(path)
161    }
162
163    /// Check if verbose mode is enabled
164    pub fn is_verbose(&self) -> bool {
165        self.verbose
166    }
167
168    /// Get the Redis connection URL
169    pub fn redis_url(&self) -> &str {
170        &self.redis_url
171    }
172
173    /// Get the PostgreSQL connection URL if configured
174    pub fn postgres_url(&self) -> Option<&str> {
175        self.postgres_url.as_deref()
176    }
177
178    lazy_init_getter!(
179        /// Get Redis service (creates on first access)
180        try fn redis() -> &RedisService, redis_service, |s| RedisService::new(&s.redis_url)?
181    );
182
183    lazy_init_getter!(
184        /// Get Project service (creates on first access)
185        try fn project() -> &ProjectService, project_service, |s| ProjectService::detect(&s.working_dir)?
186    );
187
188    lazy_init_getter!(
189        /// Get Simulation service (creates on first access)
190        fn simulation() -> &SimulationService, simulation_service, SimulationService::new()
191    );
192
193    lazy_init_getter!(
194        /// Get Process service (creates on first access)
195        mut fn process() -> &mut ProcessService, process_service, ProcessService::new()
196    );
197
198    /// Get shared Process service for concurrent access
199    ///
200    /// This returns an Arc<Mutex<ProcessService>> that can be safely shared
201    /// between tasks (e.g., DevSession and CommandListener).
202    /// Uses std::sync::Mutex so it works in both sync and async contexts.
203    pub fn shared_process(&mut self) -> std::sync::Arc<std::sync::Mutex<ProcessService>> {
204        if self.shared_process_service.is_none() {
205            self.shared_process_service = Some(std::sync::Arc::new(std::sync::Mutex::new(ProcessService::new())));
206        }
207        self.shared_process_service.as_ref().unwrap().clone()
208    }
209
210    lazy_init_getter!(
211        /// Get Docker service (creates on first access)
212        fn docker() -> &DockerService, docker_service, DockerService::new()
213    );
214
215    /// Get Package service (creates on first access)
216    ///
217    /// Note: This requires a valid project to be detected with name and version.
218    pub fn package(&mut self) -> anyhow::Result<&PackageService> {
219        if self.package_service.is_none() {
220            let project = self.project()?;
221            let name = project.name()?;
222            let version = project.version()?;
223            self.package_service = Some(PackageService::new(name, version, self.working_dir.clone())?);
224        }
225        Ok(self.package_service.as_ref().unwrap())
226    }
227
228    lazy_init_getter!(
229        /// Get Deployment service (creates on first access)
230        fn deployment() -> &DeploymentService, deployment_service, DeploymentService::new()
231    );
232
233    lazy_init_getter!(
234        /// Get Component Catalog service (creates on first access)
235        fn component_catalog() -> &ComponentCatalogService, component_catalog_service, ComponentCatalogService::new()
236    );
237
238    lazy_init_getter!(
239        /// Get Discovery service (creates on first access)
240        mut fn discovery() -> &mut DiscoveryService, discovery_service, DiscoveryService::new()
241    );
242
243    lazy_init_getter!(
244        /// Get Init service (creates on first access)
245        fn init_service() -> &InitService, init_service, InitService::new()
246    );
247
248    lazy_init_getter!(
249        /// Get Project Template service (creates on first access)
250        fn project_template_service() -> &ProjectTemplateService, project_template_service, ProjectTemplateService::new()
251    );
252
253    lazy_init_getter!(
254        /// Get Dev service (creates on first access)
255        fn dev() -> &DevService, dev_service, self |s| DevService::new(s.redis_url.clone())
256    );
257
258    lazy_init_getter!(
259        /// Get Topology service (creates on first access)
260        ///
261        /// The topology service performs static analysis of project structure,
262        /// extracting node definitions and pub/sub topic relationships from source code.
263        fn topology() -> &TopologyService, topology_service, self |s| TopologyService::new(s.working_dir.clone())
264    );
265
266    lazy_init_getter!(
267        /// Get Template Download service (creates on first access)
268        ///
269        /// Downloads project templates from GitHub releases on-demand.
270        /// Templates are versioned and cached locally for offline use.
271        fn template_download() -> &TemplateDownloadService, template_download_service, TemplateDownloadService::new()
272    );
273
274    /// Validate project structure
275    ///
276    /// Checks that the project has the expected directory structure
277    /// and required files.
278    pub fn validate_project_structure(&self) -> anyhow::Result<()> {
279        if !self.is_project_initialized() {
280            return Err(anyhow::anyhow!("Project not initialized. Run 'mecha10 init' first."));
281        }
282
283        let required_dirs = vec!["nodes", "drivers"];
284        for dir in required_dirs {
285            let path = self.project_path(dir);
286            if !path.exists() {
287                return Err(anyhow::anyhow!(
288                    "Required directory missing: {}. Expected at: {}",
289                    dir,
290                    path.display()
291                ));
292            }
293        }
294
295        Ok(())
296    }
297
298    /// Check if running in CI environment
299    pub fn is_ci(&self) -> bool {
300        is_ci_environment()
301    }
302
303    /// Get logs directory path
304    pub fn logs_dir(&self) -> PathBuf {
305        self.project_path("logs")
306    }
307
308    /// Get data directory path
309    pub fn data_dir(&self) -> PathBuf {
310        self.project_path("data")
311    }
312
313    /// Get recordings directory path
314    pub fn recordings_dir(&self) -> PathBuf {
315        self.project_path("data/recordings")
316    }
317
318    /// Get maps directory path
319    pub fn maps_dir(&self) -> PathBuf {
320        self.project_path("data/maps")
321    }
322
323    /// Get telemetry directory path
324    pub fn telemetry_dir(&self) -> PathBuf {
325        self.project_path("data/telemetry")
326    }
327
328    /// Get simulation directory path
329    pub fn simulation_dir(&self) -> PathBuf {
330        self.project_path(paths::project::SIMULATION_DIR)
331    }
332
333    /// Get RL task-curriculum configs directory path (`configs/rl/tasks`, LAB-2429) - deliberately
334    /// separate from `simulation_dir()` since tasks are project RL config, not a simulation/
335    /// physics/rendering asset.
336    pub fn rl_tasks_dir(&self) -> PathBuf {
337        self.project_path(paths::project::RL_TASKS_DIR)
338    }
339
340    /// Get target/debug directory path
341    pub fn target_debug_dir(&self) -> PathBuf {
342        self.project_path("target/debug")
343    }
344
345    /// Get target/release directory path
346    pub fn target_release_dir(&self) -> PathBuf {
347        self.project_path("target/release")
348    }
349
350    /// Get packages output directory path
351    pub fn packages_dir(&self) -> PathBuf {
352        self.project_path("target/packages")
353    }
354
355    /// Ensure a directory exists, creating it if necessary
356    pub fn ensure_dir(&self, path: &PathBuf) -> anyhow::Result<()> {
357        if !path.exists() {
358            std::fs::create_dir_all(path)?;
359        }
360        Ok(())
361    }
362}
363
364impl Default for CliContext {
365    fn default() -> Self {
366        CliContextBuilder::new()
367            .log_level(Level::INFO)
368            .build()
369            .expect("Failed to build default CliContext")
370    }
371}
372
373/// Builder for CliContext with proper precedence handling
374///
375/// Precedence order (highest to lowest):
376/// 1. Explicitly set builder values
377/// 2. Environment variables
378/// 3. Config file values (if project initialized)
379/// 4. Defaults
380///
381/// # Example
382///
383/// ```rust,ignore
384/// use mecha10_cli::CliContextBuilder;
385/// use tracing::Level;
386///
387/// let ctx = CliContextBuilder::new()
388///     .config_path(Some("custom/mecha10.json".into()))
389///     .log_level(Level::DEBUG)
390///     .verbose(true)
391///     .build()?;
392/// ```
393pub struct CliContextBuilder {
394    config_path: Option<PathBuf>,
395    log_level: Option<Level>,
396    working_dir: Option<PathBuf>,
397    redis_url: Option<String>,
398    postgres_url: Option<String>,
399    verbose: Option<bool>,
400}
401
402impl CliContextBuilder {
403    /// Create a new builder
404    pub fn new() -> Self {
405        Self {
406            config_path: None,
407            log_level: None,
408            working_dir: None,
409            redis_url: None,
410            postgres_url: None,
411            verbose: None,
412        }
413    }
414
415    mecha10_core::builder_setter!(
416        /// Set config file path
417        assign, config_path, config_path, Option<PathBuf>
418    );
419
420    mecha10_core::builder_setter!(
421        /// Set log level
422        into_some, log_level, log_level, Level
423    );
424
425    mecha10_core::builder_setter!(
426        /// Set working directory
427        into_some, working_dir, working_dir, PathBuf
428    );
429
430    mecha10_core::builder_setter!(
431        /// Set Redis URL
432        into_some, redis_url, redis_url, String
433    );
434
435    mecha10_core::builder_setter!(
436        /// Set PostgreSQL URL
437        assign, postgres_url, postgres_url, Option<String>
438    );
439
440    mecha10_core::builder_setter!(
441        /// Set verbose mode
442        into_some, verbose, verbose, bool
443    );
444
445    /// Build the CliContext with proper precedence
446    pub fn build(self) -> anyhow::Result<CliContext> {
447        let working_dir = self
448            .working_dir
449            .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
450
451        let config_path = self
452            .config_path
453            .unwrap_or_else(|| working_dir.join(paths::PROJECT_CONFIG));
454
455        let config_redis_url = if config_path.exists() {
456            std::fs::read_to_string(&config_path)
457                .ok()
458                .and_then(|content| serde_json::from_str::<serde_json::Value>(&content).ok())
459                .and_then(|json| json.get("redis")?.get("url")?.as_str().map(String::from))
460        } else {
461            None
462        };
463
464        let redis_url = self
465            .redis_url
466            .or_else(|| std::env::var("MECHA10_REDIS_URL").ok())
467            .or_else(|| std::env::var("REDIS_URL").ok())
468            .or(config_redis_url)
469            .unwrap_or_else(|| "redis://localhost:6379".to_string());
470
471        let postgres_url = self
472            .postgres_url
473            .or_else(|| std::env::var("DATABASE_URL").ok())
474            .or_else(|| std::env::var("POSTGRES_URL").ok());
475
476        let log_level = self.log_level.unwrap_or(Level::INFO);
477
478        let verbose = self.verbose.unwrap_or(false);
479
480        Ok(CliContext {
481            config_path,
482            log_level,
483            working_dir,
484            redis_url,
485            postgres_url,
486            verbose,
487            redis_service: None,
488            project_service: None,
489            simulation_service: None,
490            process_service: None,
491            shared_process_service: None,
492            docker_service: None,
493            package_service: None,
494            deployment_service: None,
495            component_catalog_service: None,
496            discovery_service: None,
497            init_service: None,
498            project_template_service: None,
499            dev_service: None,
500            topology_service: None,
501            template_download_service: None,
502            _phantom: std::marker::PhantomData,
503        })
504    }
505}
506
507impl Default for CliContextBuilder {
508    fn default() -> Self {
509        Self::new()
510    }
511}
512
513/// Check if running in a CI environment.
514///
515/// Standalone free function (not just `CliContext::is_ci`) so call sites that don't have a
516/// `CliContext` handy - e.g. deciding whether to gate a proactive `mecha10_auth::commands::
517/// ensure_authenticated_interactive` call behind the old hard-error, so scripted/CI usage isn't
518/// surprised by a device-code poll that blocks until it expires - can still check this.
519pub fn is_ci_environment() -> bool {
520    std::env::var("CI").is_ok()
521        || std::env::var("GITHUB_ACTIONS").is_ok()
522        || std::env::var("GITLAB_CI").is_ok()
523        || std::env::var("CIRCLECI").is_ok()
524        || std::env::var("JENKINS_HOME").is_ok()
525}