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