Skip to main content

Maintain/Run/
CLI.rs

1//=============================================================================//
2// File Path: Element/Maintain/Source/Run/CLI.rs
3//=============================================================================//
4// Module: CLI - Command Line Interface for Development Run
5//
6// This module provides the cargo-first CLI interface that enables triggering
7// development runs directly with the Cargo utility instead of shell scripts.
8//
9// RESPONSIBILITIES:
10// ================
11//
12// Primary:
13// - Parse command-line arguments for profile-based runs
14// - Load and validate configuration from land-config.json
15// - Resolve environment variables from configuration
16// - Execute development runs with resolved configuration
17//
18// Secondary:
19// - Provide utility commands (--list-profiles, --show-profile)
20// - Support dry-run mode for configuration preview
21// - Enable profile aliases for quick access
22//
23// USAGE:
24// ======
25//
26// Basic usage:
27// ```bash
28// cargo run --bin Maintain -- --run --profile debug-mountain
29// ```
30//
31// List profiles:
32// ```bash
33// cargo run --bin Maintain -- --run --list-profiles
34// ```
35//
36// Dry run:
37// ```bash
38// cargo run --bin Maintain -- --run --profile debug --dry-run
39// ```
40//
41//===================================================================================
42
43use std::{collections::HashMap, path::PathBuf};
44
45use clap::{Parser, Subcommand, ValueEnum};
46use colored::Colorize;
47
48use crate::Build::Rhai::ConfigLoader::{LandConfig, Profile, load_config};
49
50//=============================================================================
51// CLI Argument Definitions
52//=============================================================================
53
54/// Land Run System - Configuration-based development runs via Cargo
55#[derive(Parser, Debug, Clone)]
56#[clap(
57	name = "maintain-run",
58	author,
59	version,
60	about = "Land Run System - Configuration-based development runs",
61	long_about = "A configuration-driven run system that enables triggering development runs directly with Cargo \
62	              instead of shell scripts. Reads configuration from .vscode/land-config.json and supports multiple \
63	              run profiles with hot-reload support."
64)]
65pub struct Cli {
66	#[clap(subcommand)]
67	pub command:Option<Commands>,
68
69	/// Run profile to use (shortcut for 'run' subcommand)
70	#[clap(long, short = 'p', value_parser = parse_profile_name)]
71	pub profile:Option<String>,
72
73	/// Configuration file path (default: .vscode/land-config.json)
74	#[clap(long, short = 'c', global = true)]
75	pub config:Option<PathBuf>,
76
77	/// Override workbench type
78	#[clap(long, short = 'w', global = true)]
79	pub workbench:Option<String>,
80
81	/// Override Node.js version
82	#[clap(long, short = 'n', global = true)]
83	pub node_version:Option<String>,
84
85	/// Override Node.js environment
86	#[clap(long, short = 'e', global = true)]
87	pub environment:Option<String>,
88
89	/// Override dependency source
90	#[clap(long, short = 'd', global = true)]
91	pub dependency:Option<String>,
92
93	/// Override environment variables (key=value pairs)
94	#[clap(long = "env", value_parser = parse_key_val::<String, String>, global = true, action = clap::ArgAction::Append)]
95	pub env_override:Vec<(String, String)>,
96
97	/// Enable hot-reload (default: true for dev runs)
98	#[clap(long, global = true, default_value = "true")]
99	pub hot_reload:bool,
100
101	/// Enable watch mode (default: true for dev runs)
102	#[clap(long, global = true, default_value = "true")]
103	pub watch:bool,
104
105	/// Live-reload port
106	#[clap(long, global = true, default_value = "3001")]
107	pub live_reload_port:u16,
108
109	/// Enable dry-run mode (show config without running)
110	#[clap(long, global = true)]
111	pub dry_run:bool,
112
113	/// Enable verbose output
114	#[clap(long, short = 'v', global = true)]
115	pub verbose:bool,
116
117	/// Merge with shell environment (default: true)
118	#[clap(long, default_value = "true", global = true)]
119	pub merge_env:bool,
120
121	/// Additional run arguments (passed through to run command)
122	#[clap(last = true)]
123	pub run_args:Vec<String>,
124}
125
126/// Available subcommands
127#[derive(Subcommand, Debug, Clone)]
128pub enum Commands {
129	/// Execute a development run with the specified profile
130	Run {
131		/// Run profile to use
132		#[clap(long, short = 'p', value_parser = parse_profile_name)]
133		profile:String,
134
135		/// Enable hot-reload
136		#[clap(long, default_value = "true")]
137		hot_reload:bool,
138
139		/// Enable dry-run mode
140		#[clap(long)]
141		dry_run:bool,
142	},
143
144	/// List all available run profiles
145	ListProfiles {
146		/// Show detailed information for each profile
147		#[clap(long, short = 'v')]
148		verbose:bool,
149	},
150
151	/// Show details for a specific profile
152	ShowProfile {
153		/// Profile name to show
154		profile:String,
155	},
156
157	/// Validate a run profile
158	ValidateProfile {
159		/// Profile name to validate
160		profile:String,
161	},
162
163	/// Show current environment variable resolution
164	Resolve {
165		/// Profile name to resolve
166		#[clap(long, short = 'p')]
167		profile:String,
168
169		/// Output format
170		#[clap(long, short = 'f', default_value = "table")]
171		format:OutputFormat,
172	},
173}
174
175/// Output format options
176#[derive(Debug, Clone, ValueEnum)]
177pub enum OutputFormat {
178	Table,
179	Json,
180	Env,
181}
182
183impl std::fmt::Display for OutputFormat {
184	fn fmt(&self, f:&mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185		match self {
186			OutputFormat::Table => write!(f, "table"),
187			OutputFormat::Json => write!(f, "json"),
188			OutputFormat::Env => write!(f, "env"),
189		}
190	}
191}
192
193//=============================================================================
194// CLI Implementation
195//=============================================================================
196
197impl Cli {
198	/// Execute the CLI command
199	pub fn execute(&self) -> Result<(), String> {
200		let config_path = self.config.clone().unwrap_or_else(|| PathBuf::from(".vscode/land-config.json"));
201
202		// Load configuration
203		let config = load_config(&config_path).map_err(|e| format!("Failed to load configuration: {}", e))?;
204
205		// Handle subcommands
206		if let Some(command) = &self.command {
207			return self.execute_command(command, &config);
208		}
209
210		// Handle direct profile argument
211		if let Some(profile_name) = &self.profile {
212			return self.execute_run(profile_name, &config, self.dry_run);
213		}
214
215		// Default: show help
216		Err("No command specified. Use --profile <name> to run or --help for usage.".to_string())
217	}
218
219	/// Execute a subcommand
220	fn execute_command(&self, command:&Commands, config:&LandConfig) -> Result<(), String> {
221		match command {
222			Commands::Run { profile, hot_reload, dry_run } => {
223				let _ = hot_reload; // Use hot_reload for run-specific logic
224				self.execute_run(profile, config, *dry_run)
225			},
226			Commands::ListProfiles { verbose } => self.execute_list_profiles(config, *verbose),
227			Commands::ShowProfile { profile } => self.execute_show_profile(profile, config),
228			Commands::ValidateProfile { profile } => self.execute_validate_profile(profile, config),
229			Commands::Resolve { profile, format } => self.execute_resolve(profile, config, Some(format.to_string())),
230		}
231	}
232
233	/// Execute a run with the specified profile
234	fn execute_run(&self, profile_name:&str, config:&LandConfig, dry_run:bool) -> Result<(), String> {
235		// Resolve profile name (handle aliases)
236		let resolved_profile = resolve_profile_name(profile_name, config);
237
238		// Get profile from config
239		let profile = config.profiles.get(&resolved_profile).ok_or_else(|| {
240			format!(
241				"Profile '{}' not found. Available profiles: {}",
242				resolved_profile,
243				config.profiles.keys().cloned().collect::<Vec<_>>().join(", ")
244			)
245		})?;
246
247		// Print run header
248		print_run_header(&resolved_profile, profile);
249
250		// Resolve environment variables with dual-path merge
251		let env_vars = resolve_environment_dual_path(profile, config, self.merge_env, &self.env_override);
252
253		// Apply CLI overrides for explicit flags
254		let env_vars = apply_overrides(
255			env_vars,
256			&self.workbench,
257			&self.node_version,
258			&self.environment,
259			&self.dependency,
260		);
261
262		// Print resolved configuration
263		if self.verbose || dry_run {
264			print_resolved_environment(&env_vars);
265		}
266
267		// Dry run: stop here
268		if dry_run {
269			println!("\n{}", "Dry run complete. No changes made.");
270			return Ok(());
271		}
272
273		// Execute run
274		execute_run_command(&resolved_profile, config, &env_vars, &self.run_args)
275	}
276
277	/// List all available profiles
278	fn execute_list_profiles(&self, config:&LandConfig, verbose:bool) -> Result<(), String> {
279		println!("\n{}", "Land Run System - Available Profiles");
280		println!("{}\n", "=".repeat(50));
281
282		// Group profiles by type
283		let mut debug_profiles:Vec<_> = config.profiles.iter().filter(|(k, _)| k.starts_with("debug")).collect();
284		let mut release_profiles:Vec<_> = config
285			.profiles
286			.iter()
287			.filter(|(k, _)| k.starts_with("production") || k.starts_with("release") || k.starts_with("web"))
288			.collect();
289
290		// Sort profiles
291		debug_profiles.sort_by_key(|(k, _)| k.as_str());
292		release_profiles.sort_by_key(|(k, _)| k.as_str());
293
294		// Print debug profiles
295		println!("{}:", "Debug Profiles".yellow());
296		println!();
297		for (name, profile) in &debug_profiles {
298			let default_profile = config
299				.cli
300				.as_ref()
301				.and_then(|cli| cli.default_profile.as_ref())
302				.map(|s| s.as_str())
303				.unwrap_or("");
304			let recommended = default_profile == name.as_str();
305			let marker = if recommended { " [RECOMMENDED]" } else { "" };
306			println!(
307				" {:<20} - {}{}",
308				name.green(),
309				profile.description.as_ref().map(|d| d.as_str()).unwrap_or("No description"),
310				marker.bright_magenta()
311			);
312			if verbose {
313				if let Some(workbench) = &profile.workbench {
314					println!(" Workbench: {}", workbench);
315				}
316				if let Some(features) = &profile.features {
317					for (feature, enabled) in features {
318						let status = if *enabled { "[X]" } else { "[ ]" };
319						println!(" {:>20} {} = {}", feature.cyan(), status, enabled);
320					}
321				}
322			}
323		}
324
325		// Print release profiles
326		println!("\n{}:", "Release Profiles".yellow());
327		for (name, profile) in &release_profiles {
328			println!(
329				" {:<20} - {}",
330				name.green(),
331				profile.description.as_ref().map(|d| d.as_str()).unwrap_or("No description")
332			);
333			if verbose {
334				if let Some(workbench) = &profile.workbench {
335					println!(" Workbench: {}", workbench);
336				}
337			}
338		}
339
340		// Print CLI aliases if available
341		if let Some(cli_config) = &config.cli {
342			if !cli_config.profile_aliases.is_empty() {
343				println!("\n{}:", "Profile Aliases");
344				for (alias, target) in &cli_config.profile_aliases {
345					println!(" {:<10} -> {}", alias.cyan(), target);
346				}
347			}
348		}
349
350		println!();
351		Ok(())
352	}
353
354	/// Show details for a specific profile
355	fn execute_show_profile(&self, profile_name:&str, config:&LandConfig) -> Result<(), String> {
356		let resolved_profile = resolve_profile_name(profile_name, config);
357
358		let profile = config
359			.profiles
360			.get(&resolved_profile)
361			.ok_or_else(|| format!("Profile '{}' not found.", resolved_profile))?;
362
363		println!("\n{}: {}", "Profile:", resolved_profile.green());
364		println!("{}\n", "=".repeat(50));
365
366		// Description
367		if let Some(desc) = &profile.description {
368			println!("Description: {}", desc);
369		}
370
371		// Workbench
372		if let Some(workbench) = &profile.workbench {
373			println!("\nWorkbench:");
374			println!(" Type: {}", workbench);
375		}
376
377		// Environment Variables
378		println!("\nEnvironment Variables:");
379		if let Some(env) = &profile.env {
380			let mut sorted_env:Vec<_> = env.iter().collect();
381			sorted_env.sort_by_key(|(k, _)| k.as_str());
382			for (key, value) in sorted_env {
383				println!(" {:<25} = {}", key, value);
384			}
385		}
386
387		// Features
388		if let Some(features) = &profile.features {
389			println!("\nFeatures:");
390			println!("\n Enabled:");
391			let mut sorted_features:Vec<_> = features.iter().filter(|(_, enabled)| **enabled).collect();
392			sorted_features.sort_by_key(|(k, _)| k.as_str());
393			for (feature, _) in &sorted_features {
394				println!(" {:<30}", feature.green());
395			}
396		}
397
398		// Rhai Script
399		if let Some(script) = &profile.rhai_script {
400			println!("\nRhai Script: {}", script);
401		}
402
403		println!();
404		Ok(())
405	}
406
407	/// Validate a profile's configuration
408	fn execute_validate_profile(&self, profile_name:&str, config:&LandConfig) -> Result<(), String> {
409		let resolved_profile = resolve_profile_name(profile_name, config);
410
411		let profile = config
412			.profiles
413			.get(&resolved_profile)
414			.ok_or_else(|| format!("Profile '{}' not found.", resolved_profile))?;
415
416		println!("\n{}: {}", "Validating Profile:", resolved_profile.green());
417		println!("{}\n", "=".repeat(50));
418
419		let mut issues = Vec::new();
420		let mut warnings = Vec::new();
421
422		// Check description
423		if profile.description.is_none() {
424			warnings.push("Profile has no description".to_string());
425		}
426
427		// Check workbench
428		if profile.workbench.is_none() {
429			issues.push("Profile has no workbench type specified".to_string());
430		}
431
432		// Check environment variables
433		if profile.env.is_none() || profile.env.as_ref().unwrap().is_empty() {
434			warnings.push("Profile has no environment variables defined".to_string());
435		}
436
437		// Display results
438		if issues.is_empty() && warnings.is_empty() {
439			println!("{}", "Profile is valid!".green());
440		} else {
441			if !warnings.is_empty() {
442				println!("\n{} Warnings:", warnings.len().to_string().yellow());
443				for warning in &warnings {
444					println!(" - {}", warning.yellow());
445				}
446			}
447			if !issues.is_empty() {
448				println!("\n{} Issues:", issues.len().to_string().red());
449				for issue in &issues {
450					println!(" - {}", issue.red());
451				}
452			}
453		}
454
455		println!();
456		Ok(())
457	}
458
459	/// Resolve a profile to its resolved configuration
460	fn execute_resolve(&self, profile_name:&str, config:&LandConfig, _format:Option<String>) -> Result<(), String> {
461		let resolved_profile = resolve_profile_name(profile_name, config);
462
463		let profile = config
464			.profiles
465			.get(&resolved_profile)
466			.ok_or_else(|| format!("Profile '{}' not found.", resolved_profile))?;
467
468		println!("\n{}: {}", "Resolved Profile:", resolved_profile.green());
469		println!("{}\n", "=".repeat(50));
470
471		// Profile information
472		if let Some(desc) = &profile.description {
473			println!("Description: {}", desc);
474		}
475
476		if let Some(workbench) = &profile.workbench {
477			println!("Workbench: {}", workbench);
478		}
479
480		// Environment Variables
481		if let Some(env) = &profile.env {
482			println!("\nEnvironment Variables ({}):", env.len());
483			for (key, value) in env {
484				println!(" {} = {}", key.green(), value);
485			}
486		}
487
488		// Features
489		if let Some(features) = &profile.features {
490			println!("\nFeatures ({}):", features.len());
491			for (feature, enabled) in features {
492				let status = if *enabled { "[X]" } else { "[ ]" };
493				println!(" {} {}", status, feature);
494			}
495		}
496
497		println!();
498		Ok(())
499	}
500}
501
502//=============================================================================
503// Helper Functions (standalone functions, not methods)
504//=============================================================================
505
506/// Print run header
507fn print_run_header(profile_name:&str, profile:&Profile) {
508	println!("\n{}", "========================================");
509	println!("Land Run: {}", profile_name);
510	println!("========================================");
511
512	if let Some(desc) = &profile.description {
513		println!("Description: {}", desc);
514	}
515
516	if let Some(workbench) = &profile.workbench {
517		println!("Workbench: {}", workbench);
518	}
519}
520
521/// Print resolved environment variables
522fn print_resolved_environment(env:&HashMap<String, String>) {
523	println!("\nResolved Environment:");
524	let mut sorted_env:Vec<_> = env.iter().collect();
525	sorted_env.sort_by_key(|(k, _)| k.as_str());
526
527	for (key, value) in sorted_env {
528		let display_value = if value.is_empty() { "(empty)" } else { value };
529		println!(" {:<25} = {}", key, display_value);
530	}
531}
532
533/// Parse and validate profile name
534fn parse_profile_name(s:&str) -> Result<String, String> {
535	let name = s.trim().to_lowercase();
536
537	if name.is_empty() {
538		return Err("Profile name cannot be empty".to_string());
539	}
540
541	if name.contains(' ') {
542		return Err("Profile name cannot contain spaces".to_string());
543	}
544
545	Ok(name)
546}
547
548/// Resolve profile name (handle aliases)
549fn resolve_profile_name(name:&str, config:&LandConfig) -> String {
550	if let Some(cli_config) = &config.cli {
551		if let Some(resolved) = cli_config.profile_aliases.get(name) {
552			return resolved.clone();
553		}
554	}
555	name.to_string()
556}
557
558/// Resolve environment variables with dual-path merging.
559///
560/// This function implements the dual-path environment resolution:
561/// - Path A: Shell environment variables (from process)
562/// - Path B: CLI profile configuration (from land-config.json)
563///
564/// Merge priority (lowest to highest):
565/// 1. Template defaults
566/// 2. Shell environment variables (if merge_env is true)
567/// 3. Profile environment variables
568/// 4. CLI --env overrides
569///
570/// # Arguments
571///
572/// * `profile` - The profile configuration
573/// * `config` - The land configuration
574/// * `merge_env` - Whether to merge with shell environment
575/// * `cli_overrides` - CLI --env override pairs
576///
577/// # Returns
578///
579/// Merged HashMap of environment variables
580fn resolve_environment_dual_path(
581	profile:&Profile,
582	config:&LandConfig,
583	merge_env:bool,
584	cli_overrides:&[(String, String)],
585) -> HashMap<String, String> {
586	let mut env = HashMap::new();
587
588	// Layer 1: Start with template defaults (lowest priority)
589	if let Some(templates) = &config.templates {
590		for (key, value) in &templates.env {
591			env.insert(key.clone(), value.clone());
592		}
593	}
594
595	// Layer 2: Merge shell environment variables (if enabled)
596	if merge_env {
597		for (key, value) in std::env::vars() {
598			// Only merge relevant environment variables
599			// that are part of our build system
600			if is_run_env_var(&key) {
601				env.insert(key, value);
602			}
603		}
604	}
605
606	// Layer 3: Apply profile environment (overrides shell)
607	if let Some(profile_env) = &profile.env {
608		for (key, value) in profile_env {
609			env.insert(key.clone(), value.clone());
610		}
611	}
612
613	// Layer 4: Apply CLI --env overrides (highest priority)
614	for (key, value) in cli_overrides {
615		env.insert(key.clone(), value.clone());
616	}
617
618	env
619}
620
621/// Check if an environment variable is a run system variable.
622fn is_run_env_var(key:&str) -> bool {
623	matches!(
624		key,
625		"Browser"
626			| "Bundle"
627			| "Clean" | "Compile"
628			| "Debug" | "Dependency"
629			| "Mountain"
630			| "Wind" | "Electron"
631			| "BrowserProxy"
632			| "NODE_ENV"
633			| "NODE_VERSION"
634			| "NODE_OPTIONS"
635			| "RUST_LOG"
636			| "AIR_LOG_JSON"
637			| "AIR_LOG_FILE"
638			| "Level" | "Name"
639			| "Prefix"
640			| "HOT_RELOAD"
641			| "WATCH"
642	)
643}
644
645/// Parse a key=value pair from command line.
646fn parse_key_val<K, V>(s:&str) -> Result<(K, V), String>
647where
648	K: std::str::FromStr,
649	V: std::str::FromStr,
650	K::Err: std::fmt::Display,
651	V::Err: std::fmt::Display, {
652	let pos = s.find('=').ok_or_else(|| format!("invalid KEY=value: no `=` found in `{s}`"))?;
653	Ok((
654		s[..pos].parse().map_err(|e| format!("key parse error: {e}"))?,
655		s[pos + 1..].parse().map_err(|e| format!("value parse error: {e}"))?,
656	))
657}
658
659/// Apply CLI overrides to environment
660fn apply_overrides(
661	mut env:HashMap<String, String>,
662	workbench:&Option<String>,
663	node_version:&Option<String>,
664	environment:&Option<String>,
665	dependency:&Option<String>,
666) -> HashMap<String, String> {
667	if let Some(workbench) = workbench {
668		// Clear all workbench flags
669		env.remove("Browser");
670		env.remove("Wind");
671		env.remove("Mountain");
672		env.remove("Electron");
673		env.remove("BrowserProxy");
674
675		// Set the selected workbench
676		env.insert(workbench.clone(), "true".to_string());
677	}
678
679	if let Some(version) = node_version {
680		env.insert("NODE_VERSION".to_string(), version.clone());
681	}
682
683	if let Some(environment) = environment {
684		env.insert("NODE_ENV".to_string(), environment.clone());
685	}
686
687	if let Some(dependency) = dependency {
688		env.insert("Dependency".to_string(), dependency.clone());
689	}
690
691	env
692}
693
694/// Execute the run command with dual-path environment injection.
695///
696/// This function:
697/// 1. Calls the Maintain binary in run mode with merged environment variables
698/// 2. Starts the development server with hot-reload
699/// 3. Watches for file changes
700///
701/// # Arguments
702///
703/// * `profile_name` - The resolved profile name
704/// * `config` - Land configuration
705/// * `env_vars` - Merged environment variables from all sources
706/// * `run_args` - Additional run arguments
707///
708/// # Returns
709///
710/// Result indicating success or failure
711fn execute_run_command(
712	profile_name:&str,
713	_config:&LandConfig,
714	env_vars:&HashMap<String, String>,
715	run_args:&[String],
716) -> Result<(), String> {
717	use std::process::Command as StdCommand;
718
719	// Determine if this is a debug run
720	let is_debug = profile_name.starts_with("debug");
721
722	// Build the run command
723	// For development runs, we typically use: pnpm dev or pnpm tauri dev
724	let run_command = if is_debug { "pnpm tauri dev" } else { "pnpm dev" };
725
726	// Build the command arguments
727	let mut cmd_args:Vec<String> = run_command.split_whitespace().map(|s| s.to_string()).collect();
728	cmd_args.extend(run_args.iter().cloned());
729
730	println!("Executing: {}", cmd_args.join(" "));
731	println!("With environment variables:");
732	for (key, value) in env_vars.iter().take(10) {
733		println!(" {}={}", key, value);
734	}
735	if env_vars.len() > 10 {
736		println!(" ... and {} more", env_vars.len() - 10);
737	}
738
739	// Parse command into shell command and arguments
740	let (shell_cmd, args) = cmd_args.split_first().ok_or("Empty command")?;
741
742	// Execute the command with merged environment variables
743	let mut cmd = StdCommand::new(shell_cmd);
744	cmd.args(args);
745	cmd.envs(env_vars.iter());
746
747	// Set the run mode indicator
748	cmd.env("MAINTAIN_RUN_MODE", "true");
749
750	cmd.stderr(std::process::Stdio::inherit())
751		.stdout(std::process::Stdio::inherit());
752
753	let status = cmd
754		.status()
755		.map_err(|e| format!("Failed to execute run command ({}): {}", shell_cmd, e))?;
756
757	if status.success() {
758		println!("\n{}", "Run completed successfully!".green());
759		Ok(())
760	} else {
761		Err(format!("Run failed with exit code: {:?}", status.code()))
762	}
763}