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    AssetsDownloadService, ComponentCatalogService, ConfigService, DeploymentService, DevService, DiscoveryService,
11    DockerService, InitService, PackageService, ProcessService, ProjectService, ProjectTemplateService, RedisService,
12    SimulationService, TemplateDownloadService, TopologyService,
13};
14use crate::types::ProjectConfig;
15use std::path::PathBuf;
16use tracing::Level;
17
18pub struct CliContext {
19    pub config_path: PathBuf,
20
21    pub log_level: Level,
22
23    pub working_dir: PathBuf,
24
25    redis_url: String,
26
27    postgres_url: Option<String>,
28
29    verbose: bool,
30
31    redis_service: Option<RedisService>,
32    project_service: Option<ProjectService>,
33    simulation_service: Option<SimulationService>,
34    process_service: Option<ProcessService>,
35    shared_process_service: Option<std::sync::Arc<std::sync::Mutex<ProcessService>>>,
36    docker_service: Option<DockerService>,
37    package_service: Option<PackageService>,
38    deployment_service: Option<DeploymentService>,
39    component_catalog_service: Option<ComponentCatalogService>,
40    discovery_service: Option<DiscoveryService>,
41    init_service: Option<InitService>,
42    project_template_service: Option<ProjectTemplateService>,
43    dev_service: Option<DevService>,
44    topology_service: Option<TopologyService>,
45    template_download_service: Option<TemplateDownloadService>,
46    assets_download_service: Option<AssetsDownloadService>,
47
48    _phantom: std::marker::PhantomData<ProjectConfig>,
49}
50
51macro_rules! lazy_init_getter {
52    ($(#[$meta:meta])* fn $name:ident() -> &$ty:ty, $field:ident, self |$self_:ident| $ctor:expr) => {
53        $(#[$meta])*
54        pub fn $name(&mut self) -> &$ty {
55            if self.$field.is_none() {
56                let value = {
57                    let $self_ = &*self;
58                    $ctor
59                };
60                self.$field = Some(value);
61            }
62            self.$field.as_ref().unwrap()
63        }
64    };
65    ($(#[$meta:meta])* fn $name:ident() -> &$ty:ty, $field:ident, $ctor:expr) => {
66        $(#[$meta])*
67        pub fn $name(&mut self) -> &$ty {
68            if self.$field.is_none() {
69                self.$field = Some($ctor);
70            }
71            self.$field.as_ref().unwrap()
72        }
73    };
74    ($(#[$meta:meta])* mut fn $name:ident() -> &mut $ty:ty, $field:ident, $ctor:expr) => {
75        $(#[$meta])*
76        pub fn $name(&mut self) -> &mut $ty {
77            if self.$field.is_none() {
78                self.$field = Some($ctor);
79            }
80            self.$field.as_mut().unwrap()
81        }
82    };
83    ($(#[$meta:meta])* try fn $name:ident() -> &$ty:ty, $field:ident, |$self_:ident| $ctor:expr) => {
84        $(#[$meta])*
85        pub fn $name(&mut self) -> anyhow::Result<&$ty> {
86            if self.$field.is_none() {
87                let value = {
88                    let $self_ = &*self;
89                    $ctor
90                };
91                self.$field = Some(value);
92            }
93            Ok(self.$field.as_ref().unwrap())
94        }
95    };
96}
97
98impl CliContext {
99    /// Load the project configuration
100    ///
101    /// This loads the configuration from disk each time it's called.
102    /// For better performance in commands that access config multiple times,
103    /// call this once and store the result.
104    pub async fn load_project_config(&self) -> anyhow::Result<ProjectConfig> {
105        ConfigService::load_from(&self.config_path).await
106    }
107
108    /// Check if a project is initialized (mecha10.json exists)
109    pub fn is_project_initialized(&self) -> bool {
110        ConfigService::is_initialized(&self.working_dir)
111    }
112
113    /// Get a path relative to the project root
114    pub fn project_path(&self, path: &str) -> PathBuf {
115        self.working_dir.join(path)
116    }
117
118    /// Check if verbose mode is enabled
119    pub fn is_verbose(&self) -> bool {
120        self.verbose
121    }
122
123    /// Get the Redis connection URL
124    pub fn redis_url(&self) -> &str {
125        &self.redis_url
126    }
127
128    /// Get the PostgreSQL connection URL if configured
129    pub fn postgres_url(&self) -> Option<&str> {
130        self.postgres_url.as_deref()
131    }
132
133    lazy_init_getter!(
134        /// Get Redis service (creates on first access)
135        try fn redis() -> &RedisService, redis_service, |s| RedisService::new(&s.redis_url)?
136    );
137
138    lazy_init_getter!(
139        /// Get Project service (creates on first access)
140        try fn project() -> &ProjectService, project_service, |s| ProjectService::detect(&s.working_dir)?
141    );
142
143    lazy_init_getter!(
144        /// Get Simulation service (creates on first access)
145        fn simulation() -> &SimulationService, simulation_service, SimulationService::new()
146    );
147
148    lazy_init_getter!(
149        /// Get Process service (creates on first access)
150        mut fn process() -> &mut ProcessService, process_service, ProcessService::new()
151    );
152
153    /// Get shared Process service for concurrent access
154    ///
155    /// Returns an `Arc<Mutex<ProcessService>>` that can be safely shared between tasks.
156    pub fn shared_process(&mut self) -> std::sync::Arc<std::sync::Mutex<ProcessService>> {
157        if self.shared_process_service.is_none() {
158            self.shared_process_service = Some(std::sync::Arc::new(std::sync::Mutex::new(ProcessService::new())));
159        }
160        self.shared_process_service.as_ref().unwrap().clone()
161    }
162
163    lazy_init_getter!(
164        /// Get Docker service (creates on first access)
165        fn docker() -> &DockerService, docker_service, DockerService::new()
166    );
167
168    /// Get Package service (creates on first access)
169    ///
170    /// Note: This requires a valid project to be detected with name and version.
171    pub fn package(&mut self) -> anyhow::Result<&PackageService> {
172        if self.package_service.is_none() {
173            let project = self.project()?;
174            let name = project.name()?;
175            let version = project.version()?;
176            self.package_service = Some(PackageService::new(name, version, self.working_dir.clone())?);
177        }
178        Ok(self.package_service.as_ref().unwrap())
179    }
180
181    lazy_init_getter!(
182        /// Get Deployment service (creates on first access)
183        fn deployment() -> &DeploymentService, deployment_service, DeploymentService::new()
184    );
185
186    lazy_init_getter!(
187        /// Get Component Catalog service (creates on first access)
188        fn component_catalog() -> &ComponentCatalogService, component_catalog_service, ComponentCatalogService::new()
189    );
190
191    lazy_init_getter!(
192        /// Get Discovery service (creates on first access)
193        mut fn discovery() -> &mut DiscoveryService, discovery_service, DiscoveryService::new()
194    );
195
196    lazy_init_getter!(
197        /// Get Init service (creates on first access)
198        fn init_service() -> &InitService, init_service, InitService::new()
199    );
200
201    lazy_init_getter!(
202        /// Get Project Template service (creates on first access)
203        fn project_template_service() -> &ProjectTemplateService, project_template_service, ProjectTemplateService::new()
204    );
205
206    lazy_init_getter!(
207        /// Get Dev service (creates on first access)
208        fn dev() -> &DevService, dev_service, self |s| DevService::new(s.redis_url.clone())
209    );
210
211    lazy_init_getter!(
212        /// Get Topology service (creates on first access)
213        ///
214        /// The topology service performs static analysis of project structure,
215        /// extracting node definitions and pub/sub topic relationships from source code.
216        fn topology() -> &TopologyService, topology_service, self |s| TopologyService::new(s.working_dir.clone())
217    );
218
219    lazy_init_getter!(
220        /// Get Template Download service (creates on first access)
221        ///
222        /// Downloads project templates from GitHub releases on-demand.
223        /// Templates are versioned and cached locally for offline use.
224        fn template_download() -> &TemplateDownloadService, template_download_service, TemplateDownloadService::new()
225    );
226
227    lazy_init_getter!(
228        /// Get Assets Download service (creates on first access)
229        ///
230        /// Downloads the current `mecha10-assets` content (behaviors, RL tasks/jobs, simulation
231        /// models/environments, `mecha10.json.template`) from `mecha10-api` on-demand. No
232        /// version/cache concept, unlike [`Self::template_download`] - every fetch is fresh.
233        fn assets_download() -> &AssetsDownloadService, assets_download_service, AssetsDownloadService::new()
234    );
235
236    /// Validate project structure
237    ///
238    /// Checks that the project has the expected directory structure
239    /// and required files.
240    pub fn validate_project_structure(&self) -> anyhow::Result<()> {
241        if !self.is_project_initialized() {
242            return Err(anyhow::anyhow!("Project not initialized. Run 'mecha10 init' first."));
243        }
244
245        let required_dirs = vec!["nodes", "drivers"];
246        for dir in required_dirs {
247            let path = self.project_path(dir);
248            if !path.exists() {
249                return Err(anyhow::anyhow!(
250                    "Required directory missing: {}. Expected at: {}",
251                    dir,
252                    path.display()
253                ));
254            }
255        }
256
257        Ok(())
258    }
259
260    /// Check if running in CI environment
261    pub fn is_ci(&self) -> bool {
262        is_ci_environment()
263    }
264
265    /// Get logs directory path
266    pub fn logs_dir(&self) -> PathBuf {
267        self.project_path("logs")
268    }
269
270    /// Get data directory path
271    pub fn data_dir(&self) -> PathBuf {
272        self.project_path("data")
273    }
274
275    /// Get recordings directory path
276    pub fn recordings_dir(&self) -> PathBuf {
277        self.project_path("data/recordings")
278    }
279
280    /// Get maps directory path
281    pub fn maps_dir(&self) -> PathBuf {
282        self.project_path("data/maps")
283    }
284
285    /// Get telemetry directory path
286    pub fn telemetry_dir(&self) -> PathBuf {
287        self.project_path("data/telemetry")
288    }
289
290    /// Get simulation directory path
291    pub fn simulation_dir(&self) -> PathBuf {
292        self.project_path(paths::project::SIMULATION_DIR)
293    }
294
295    /// Get RL task-curriculum configs directory path (`configs/rl/tasks`, LAB-2429) - deliberately
296    /// separate from `simulation_dir()` since tasks are project RL config, not a simulation/
297    /// physics/rendering asset.
298    pub fn rl_tasks_dir(&self) -> PathBuf {
299        self.project_path(paths::project::RL_TASKS_DIR)
300    }
301
302    /// Get target/debug directory path
303    pub fn target_debug_dir(&self) -> PathBuf {
304        self.project_path("target/debug")
305    }
306
307    /// Get target/release directory path
308    pub fn target_release_dir(&self) -> PathBuf {
309        self.project_path("target/release")
310    }
311
312    /// Get packages output directory path
313    pub fn packages_dir(&self) -> PathBuf {
314        self.project_path("target/packages")
315    }
316
317    /// Ensure a directory exists, creating it if necessary
318    pub fn ensure_dir(&self, path: &PathBuf) -> anyhow::Result<()> {
319        if !path.exists() {
320            std::fs::create_dir_all(path)?;
321        }
322        Ok(())
323    }
324}
325
326impl Default for CliContext {
327    fn default() -> Self {
328        CliContextBuilder::new()
329            .log_level(Level::INFO)
330            .build()
331            .expect("Failed to build default CliContext")
332    }
333}
334
335pub struct CliContextBuilder {
336    config_path: Option<PathBuf>,
337    log_level: Option<Level>,
338    working_dir: Option<PathBuf>,
339    redis_url: Option<String>,
340    postgres_url: Option<String>,
341    verbose: Option<bool>,
342}
343
344impl CliContextBuilder {
345    /// Create a new builder
346    pub fn new() -> Self {
347        Self {
348            config_path: None,
349            log_level: None,
350            working_dir: None,
351            redis_url: None,
352            postgres_url: None,
353            verbose: None,
354        }
355    }
356
357    mecha10_core::builder_setter!(
358        /// Set config file path
359        assign, config_path, config_path, Option<PathBuf>
360    );
361
362    mecha10_core::builder_setter!(
363        /// Set log level
364        into_some, log_level, log_level, Level
365    );
366
367    mecha10_core::builder_setter!(
368        /// Set working directory
369        into_some, working_dir, working_dir, PathBuf
370    );
371
372    mecha10_core::builder_setter!(
373        /// Set Redis URL
374        into_some, redis_url, redis_url, String
375    );
376
377    mecha10_core::builder_setter!(
378        /// Set PostgreSQL URL
379        assign, postgres_url, postgres_url, Option<String>
380    );
381
382    mecha10_core::builder_setter!(
383        /// Set verbose mode
384        into_some, verbose, verbose, bool
385    );
386
387    /// Build the CliContext with proper precedence
388    pub fn build(self) -> anyhow::Result<CliContext> {
389        let working_dir = self
390            .working_dir
391            .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
392
393        let config_path = self
394            .config_path
395            .unwrap_or_else(|| working_dir.join(paths::PROJECT_CONFIG));
396
397        let config_redis_url = if config_path.exists() {
398            std::fs::read_to_string(&config_path)
399                .ok()
400                .and_then(|content| serde_json::from_str::<serde_json::Value>(&content).ok())
401                .and_then(|json| json.get("redis")?.get("url")?.as_str().map(String::from))
402        } else {
403            None
404        };
405
406        let redis_url = self
407            .redis_url
408            .or_else(|| std::env::var("MECHA10_REDIS_URL").ok())
409            .or_else(|| std::env::var("REDIS_URL").ok())
410            .or(config_redis_url)
411            .unwrap_or_else(|| "redis://localhost:6379".to_string());
412
413        let postgres_url = self
414            .postgres_url
415            .or_else(|| std::env::var("DATABASE_URL").ok())
416            .or_else(|| std::env::var("POSTGRES_URL").ok());
417
418        let log_level = self.log_level.unwrap_or(Level::INFO);
419
420        let verbose = self.verbose.unwrap_or(false);
421
422        Ok(CliContext {
423            config_path,
424            log_level,
425            working_dir,
426            redis_url,
427            postgres_url,
428            verbose,
429            redis_service: None,
430            project_service: None,
431            simulation_service: None,
432            process_service: None,
433            shared_process_service: None,
434            docker_service: None,
435            package_service: None,
436            deployment_service: None,
437            component_catalog_service: None,
438            discovery_service: None,
439            init_service: None,
440            project_template_service: None,
441            dev_service: None,
442            topology_service: None,
443            template_download_service: None,
444            assets_download_service: None,
445            _phantom: std::marker::PhantomData,
446        })
447    }
448}
449
450impl Default for CliContextBuilder {
451    fn default() -> Self {
452        Self::new()
453    }
454}
455
456/// Check if running in a CI environment.
457///
458/// Standalone free function so call sites without a `CliContext` handy can still check this.
459pub fn is_ci_environment() -> bool {
460    std::env::var("CI").is_ok()
461        || std::env::var("GITHUB_ACTIONS").is_ok()
462        || std::env::var("GITLAB_CI").is_ok()
463        || std::env::var("CIRCLECI").is_ok()
464        || std::env::var("JENKINS_HOME").is_ok()
465}