Skip to main content

Maintain/Build/
Process.rs

1//=============================================================================//
2// File Path: Element/Maintain/Source/Build/Process.rs
3//=============================================================================//
4// Module: Process
5//
6// Brief Description: Main orchestration logic for preparing and executing the
7// build.
8//
9// RESPONSIBILITIES:
10// ================
11//
12// Primary:
13// - Orchestrate the entire build process from start to finish
14// - Generate product names and bundle identifiers
15// - Modify configuration files for specific build flavors
16// - Stage and bundle Node.js sidecar binaries if needed
17// - Execute the final build command
18//
19// Secondary:
20// - Provide detailed logging of build orchestration steps
21// - Ensure cleanup of temporary files
22//
23// ARCHITECTURAL ROLE:
24// ===================
25//
26// Position:
27// - Core/Orchestration layer
28// - Build process coordination
29//
30// Dependencies (What this module requires):
31// - External crates: std (env, fs, path, process, os), log, toml
32// - Internal modules: Constant::*, Definition::*, Error::BuildError,
33//   Function::*
34// - Traits implemented: None
35//
36// Dependents (What depends on this module):
37// - Main entry point
38// - Fn function
39//
40// IMPLEMENTATION DETAILS:
41// =======================
42//
43// Design Patterns:
44// - Orchestration pattern
45// - Guard pattern (for file backup/restoration)
46//
47// Performance Considerations:
48// - Complexity: O(n) - file I/O operations dominate
49// - Memory usage patterns: Moderate (stores configuration data in memory)
50// - Hot path optimizations: None needed (build time is user-facing)
51//
52// Thread Safety:
53// - Thread-safe: No (not designed for concurrent execution)
54// - Synchronization mechanisms used: None
55// - Interior mutability considerations: None
56//
57// Error Handling:
58// - Error types returned: BuildError (various)
59// - Recovery strategies: Guard restores files on error
60//
61// EXAMPLES:
62// =========
63//
64// Example 1: Basic build orchestration
65use std::{
66	collections::BTreeMap,
67	env,
68	fs,
69	path::PathBuf,
70	process::{Command as ProcessCommand, Stdio},
71};
72
73use log::info;
74use toml;
75
76/// ```rust
77/// use crate::Maintain::Source::Build::{Argument, Process};
78/// let argument = Argument::parse();
79/// Process(&argument)?;
80/// ```
81// Example 2: Handling build errors
82/// ```rust
83/// use crate::Maintain::Source::Build::Process;
84/// match Process(&argument) {
85/// 	Ok(_) => println!("Build succeeded"),
86/// 	Err(e) => println!("Build failed: {}", e),
87/// }
88/// ```
89//
90//=============================================================================//
91// IMPLEMENTATION
92//=============================================================================//
93use crate::Build::Error::Error as BuildError;
94use crate::Build::{
95	Constant::{
96		CargoFile,
97		CocoonEsbuildDefineEnv,
98		IdDelimiter,
99		JsonFile,
100		JsonfiveFile,
101		LandDisableEnv,
102		LandInspectEnv,
103		LandRecordEnv,
104		LandTraceEnv,
105		NameDelimiter,
106		PlistFile,
107	},
108	Definition::{Argument, Guard, Manifest},
109	GetTauriTargetTriple::GetTauriTargetTriple,
110	JsonEdit::JsonEdit,
111	Pascalize::Pascalize,
112	PlistEdit::PlistEdit,
113	TomlEdit::TomlEdit,
114	WordsFromPascal::WordsFromPascal,
115};
116
117/// Main orchestration logic for preparing and executing the build.
118///
119/// This function is the core of the build system, coordinating all aspects
120/// of preparing, building, and restoring project configurations. It:
121///
122/// 1. Validates the project directory and configuration files
123/// 2. Creates guards to backup and restore configuration files
124/// 3. Generates a unique product name and bundle identifier based on build
125///    flags
126/// 4. Modifies Cargo.toml and Tauri configuration files
127/// 5. Optionally stages a Node.js sidecar binary
128/// 6. Executes the provided build command
129/// 7. Cleans up temporary files after successful build
130///
131/// # Parameters
132///
133/// * `Argument` - Parsed command-line arguments and environment variables
134///
135/// # Returns
136///
137/// Returns `Ok(())` on successful build completion or a `BuildError` if
138/// any step fails.
139///
140/// # Errors
141///
142/// * `BuildError::Missing` - If the project directory doesn't exist
143/// * `BuildError::Config` - If Tauri configuration file not found
144/// * `BuildError::Exists` - If a backup file already exists
145/// * `BuildError::Io` - For file operation failures
146/// * `BuildError::Edit` - For TOML editing failures
147/// * `BuildError::Json` / `BuildError::Jsonfive` - For JSON/JSON5 parsing
148///   failures
149/// * `BuildError::Parse` - For TOML parsing failures
150/// * `BuildError::Shell` - If the build command fails
151///
152/// # Build Flavor Generation
153///
154/// The product name and bundle identifier are generated by combining:
155///
156/// - **Environment**: Node.js environment (development, production, etc.)
157/// - **Dependency**: Dependency information (org/repo or generic)
158/// - **Node Version**: Node.js version if bundling a sidecar
159/// - **Build Flags**: Bundle, Clean, Browser, Compile, Debug
160///
161/// Example product name:
162/// `Development_GenDependency_22NodeVersion_Debug_Mountain`
163///
164/// Example bundle identifier:
165/// `land.editor.binary.development.generic.node.22.debug.mountain`
166///
167/// # Node.js Sidecar Bundling
168///
169/// If `NodeVersion` is specified:
170/// - The Node.js binary is copied from
171///   `Element/SideCar/{triple}/NODE/{version}/`
172/// - The binary is staged in the project's `Binary/` directory
173/// - The Tauri configuration is updated to include the sidecar
174/// - The binary is given appropriate permissions on Unix-like systems
175/// - The temporary directory is cleaned up after successful build
176///
177/// # File Safety
178///
179/// All configuration file modifications are protected by the Guard pattern:
180/// - Files are backed up before modification
181/// - Files are automatically restored on error or when the guard drops
182/// - This ensures the original state is preserved regardless of build outcome
183///
184/// # Example
185///
186/// ```no_run
187/// use crate::Maintain::Source::Build::{Argument, Process};
188/// let argument = Argument::parse();
189/// Process(&argument)?;
190/// ```
191pub fn Process(Argument:&Argument) -> Result<(), BuildError> {
192	info!(target: "Build", "Starting build orchestration...");
193
194	log::debug!(target: "Build", "Argument: {:?}", Argument);
195
196	// Tier fan-out observability. The shell helper
197	// `Maintain/Script/TierEnvironment.sh` exports `CargoFeatures` and
198	// `CocoonEsbuildDefine`; surface them here so a build transcript shows
199	// which tier set shipped into the binary without having to replay the
200	// shell environment.
201	if let Some(Features) = Argument.CargoFeatures.as_deref().filter(|v| !v.is_empty()) {
202		info!(target: "Build", "Cargo features: {}", Features);
203	}
204
205	if let Some(Defines) = Argument.CocoonEsbuildDefine.as_deref().filter(|v| !v.is_empty()) {
206		info!(target: "Build", "Cocoon esbuild defines: {}", Defines);
207	}
208
209	let ProjectDir = PathBuf::from(&Argument.Directory);
210
211	if !ProjectDir.is_dir() {
212		return Err(BuildError::Missing(ProjectDir));
213	}
214
215	let CargoPath = ProjectDir.join(CargoFile);
216
217	let ConfigPath = {
218		let Jsonfive = ProjectDir.join(JsonfiveFile);
219
220		if Jsonfive.exists() { Jsonfive } else { ProjectDir.join(JsonFile) }
221	};
222
223	if !ConfigPath.exists() {
224		return Err(BuildError::Config);
225	}
226
227	// Create guards for file backup and restoration
228	let mut CargoGuard = Guard::New(CargoPath.clone(), "Cargo.toml".to_string())?;
229
230	let mut ConfigGuard = Guard::New(ConfigPath.clone(), "Tauri config".to_string())?;
231
232	let mut NamePartsForProductName = Vec::new();
233
234	let mut NamePartsForId = Vec::new();
235
236	// Include Node.js environment in product name
237	if let Some(NodeValue) = &Argument.Environment {
238		if !NodeValue.is_empty() {
239			let PascalEnv = Pascalize(NodeValue);
240
241			if !PascalEnv.is_empty() {
242				NamePartsForProductName.push(format!("{}NodeEnvironment", PascalEnv));
243
244				NamePartsForId.extend(WordsFromPascal(&PascalEnv));
245
246				NamePartsForId.push("node".to_string());
247
248				NamePartsForId.push("environment".to_string());
249			}
250		}
251	}
252
253	// Include dependency information in product name
254	if let Some(DependencyValue) = &Argument.Dependency {
255		if !DependencyValue.is_empty() {
256			let (PascalDepBase, IdDepWords) = if DependencyValue.eq_ignore_ascii_case("true") {
257				("Generic".to_string(), vec!["generic".to_string()])
258			} else if let Some((Org, Repo)) = DependencyValue.split_once('/') {
259				(format!("{}{}", Pascalize(Org), Pascalize(Repo)), {
260					let mut w = WordsFromPascal(&Pascalize(Org));
261
262					w.extend(WordsFromPascal(&Pascalize(Repo)));
263
264					w
265				})
266			} else {
267				(Pascalize(DependencyValue), WordsFromPascal(&Pascalize(DependencyValue)))
268			};
269
270			if !PascalDepBase.is_empty() {
271				NamePartsForProductName.push(format!("{}Dependency", PascalDepBase));
272
273				NamePartsForId.extend(IdDepWords);
274
275				NamePartsForId.push("dependency".to_string());
276			}
277		}
278	}
279
280	// Include Node.js version in product name
281	if let Some(Version) = &Argument.NodeVersion {
282		if !Version.is_empty() {
283			let PascalVersion = format!("{}NodeVersion", Version);
284
285			NamePartsForProductName.push(PascalVersion.clone());
286
287			NamePartsForId.push("node".to_string());
288
289			NamePartsForId.push(Version.to_string());
290		}
291	}
292
293	// Include build flags in product name
294	if Argument.Bundle.as_ref().map_or(false, |v| v == "true") {
295		NamePartsForProductName.push("Bundle".to_string());
296
297		NamePartsForId.push("bundle".to_string());
298	}
299
300	if Argument.Clean.as_ref().map_or(false, |v| v == "true") {
301		NamePartsForProductName.push("Clean".to_string());
302
303		NamePartsForId.push("clean".to_string());
304	}
305
306	if Argument.Browser.as_ref().map_or(false, |v| v == "true") {
307		NamePartsForProductName.push("Browser".to_string());
308
309		NamePartsForId.push("browser".to_string());
310	}
311
312	if Argument.Compile.as_ref().map_or(false, |v| v == "true") {
313		NamePartsForProductName.push("Compile".to_string());
314
315		NamePartsForId.push("compile".to_string());
316	}
317
318	if Argument.Debug.as_ref().map_or(false, |v| v == "true")
319		|| Argument.Command.iter().any(|arg| arg.contains("--debug"))
320	{
321		NamePartsForProductName.push("Debug".to_string());
322
323		NamePartsForId.push("debug".to_string());
324	}
325
326	// Workbench-profile suffixes. These are what keep `debug-mountain` and
327	// `debug-electron` binaries separated on disk. Without them, both
328	// profiles would compile into the same `Target/debug/<LongName>_Mountain`
329	// binary (because the Cargo bin name is "Mountain"), so switching
330	// profiles couldn't run side-by-side and the bundler would thrash the
331	// same artefacts every rebuild.
332	if Argument.Mountain.as_ref().map_or(false, |v| v == "true") {
333		NamePartsForProductName.push("MountainProfile".to_string());
334
335		NamePartsForId.push("mountain".to_string());
336
337		NamePartsForId.push("profile".to_string());
338	}
339
340	if Argument.Electron.as_ref().map_or(false, |v| v == "true") {
341		NamePartsForProductName.push("ElectronProfile".to_string());
342
343		NamePartsForId.push("electron".to_string());
344
345		NamePartsForId.push("profile".to_string());
346	}
347
348	// Compiler variant (e.g. "Rest") - distinguishes the OXC build path
349	// from the default TypeScript compiler path so two binaries with the
350	// same workbench flavour but different compilers don't collide.
351	if let Some(Variant) = &Argument.Compiler {
352		if !Variant.is_empty() {
353			let PascalCompiler = Pascalize(Variant);
354
355			if !PascalCompiler.is_empty() {
356				NamePartsForProductName.push(format!("{}Compiler", PascalCompiler));
357
358				NamePartsForId.extend(WordsFromPascal(&PascalCompiler));
359
360				NamePartsForId.push("compiler".to_string());
361			}
362		}
363	}
364
365	// Generate final product name
366	let ProductNamePrefix = NamePartsForProductName.join(NameDelimiter);
367
368	let FinalName = if !ProductNamePrefix.is_empty() {
369		format!("{}{}{}", ProductNamePrefix, NameDelimiter, Argument.Name)
370	} else {
371		Argument.Name.clone()
372	};
373
374	info!(target: "Build", "Final generated product name: '{}'", FinalName);
375
376	// Generate final bundle identifier
377	NamePartsForId.extend(WordsFromPascal(&Argument.Name));
378
379	let IdSuffix = NamePartsForId
380		.into_iter()
381		.filter(|s| !s.is_empty())
382		.collect::<Vec<String>>()
383		.join(IdDelimiter);
384
385	let FinalId = format!("{}{}{}", Argument.Prefix, IdDelimiter, IdSuffix);
386
387	info!(target: "Build", "Generated bundle identifier: '{}'", FinalId);
388
389	// Update Cargo.toml if product name changed
390	if FinalName != Argument.Name {
391		TomlEdit(&CargoPath, &Argument.Name, &FinalName)?;
392	}
393
394	// Get version from Cargo.toml
395	let AppVersion = toml::from_str::<Manifest>(&fs::read_to_string(&CargoPath)?)?
396		.get_version()
397		.to_string();
398
399	// Update Tauri configuration and optionally bundle Node.js sidecar
400	JsonEdit(
401		&ConfigPath,
402		&FinalName,
403		&FinalId,
404		&AppVersion,
405		(if let Some(version) = &Argument.NodeVersion {
406			info!(target: "Build", "Selected Node.js version: {}", version);
407
408			let Triple = GetTauriTargetTriple();
409
410			// Path to the pre-downloaded Node executable
411			let Executable = if cfg!(target_os = "windows") {
412				PathBuf::from(format!("./Element/SideCar/{}/NODE/{}/node.exe", Triple, version))
413			} else {
414				PathBuf::from(format!("./Element/SideCar/{}/NODE/{}/bin/node", Triple, version))
415			};
416
417			// Define a consistent, temporary directory for the staged binary
418			let DirectorySideCarTemporary = ProjectDir.join("Binary");
419
420			fs::create_dir_all(&DirectorySideCarTemporary)?;
421
422			// Define the consistent name for the binary that Tauri will bundle
423			let PathExecutableDestination = if cfg!(target_os = "windows") {
424				DirectorySideCarTemporary.join(format!("node-{}.exe", Triple))
425			} else {
426				DirectorySideCarTemporary.join(format!("node-{}", Triple))
427			};
428
429			info!(
430				target: "Build",
431
432				"Staging sidecar from {} to {}",
433
434				Executable.display(),
435
436				PathExecutableDestination.display()
437			);
438
439			// Perform the copy
440			fs::copy(&Executable, &PathExecutableDestination)?;
441
442			// On non-windows, make sure the copied binary is executable
443			#[cfg(not(target_os = "windows"))]
444			{
445				use std::os::unix::fs::PermissionsExt;
446
447				let mut Permission = fs::metadata(&PathExecutableDestination)?.permissions();
448
449				// rwxr-xr-x
450				Permission.set_mode(0o755);
451
452				fs::set_permissions(&PathExecutableDestination, Permission)?;
453			}
454
455			Some("Binary/node".to_string())
456		} else {
457			info!(target: "Build", "No Node.js flavour selected for bundling.");
458
459			None
460		})
461		.as_deref(),
462	)?;
463
464	// On macOS, inject dev-control environment variables into Info.plist.
465	// Tauri uses the project Info.plist as a template; when the .app is
466	// launched via LaunchServices (Finder double-click, open, Spotlight),
467	// keys under LSEnvironment are injected into the process environment.
468	// We only do this if an Info.plist exists in the project directory and
469	// we're running on macOS. Skip on Linux/Windows.
470	#[cfg(target_os = "macos")]
471	{
472		let PlistPath = ProjectDir.join(PlistFile);
473
474		if PlistPath.exists() {
475			let PlistEnvVars = BuildPlistEnvironment();
476
477			if !PlistEnvVars.is_empty() {
478				let mut PlistGuard = Guard::New(PlistPath.clone(), "Info.plist".to_string())?;
479
480				let _ = PlistEdit(&PlistPath, &PlistEnvVars);
481
482				PlistGuard.disarm();
483			}
484		}
485	}
486
487	// Execute the build command
488	if Argument.Command.is_empty() {
489		return Err(BuildError::NoCommand);
490	}
491
492	// Materialise the command into an owned Vec so we can append
493	// `--features <list>` to `pnpm tauri build [--debug]` invocations
494	// without mutating the parsed `Argument`. The guard below keeps the
495	// append scoped to tauri builds - other commands (e.g. cargo, direct
496	// tooling) pass through unchanged.
497	let mut CommandArguments:Vec<String> = Argument.Command.clone();
498
499	let IsTauriBuild = CommandArguments.len() >= 3
500		&& CommandArguments[0] == "pnpm"
501		&& CommandArguments[1] == "tauri"
502		&& CommandArguments[2] == "build";
503
504	if IsTauriBuild {
505		if let Some(Features) = Argument.CargoFeatures.as_deref().filter(|v| !v.is_empty()) {
506			let AlreadyPresent = CommandArguments.iter().any(|a| a == "--features" || a == "-f");
507
508			if !AlreadyPresent {
509				info!(
510					target: "Build",
511
512					"Forwarding Cargo features to `tauri build`: {}",
513
514					Features
515				);
516
517				CommandArguments.push("--features".to_string());
518
519				CommandArguments.push(Features.to_string());
520			}
521		}
522	}
523
524	let mut ShellCommand = if cfg!(target_os = "windows") {
525		let mut Command = ProcessCommand::new("cmd");
526
527		Command.arg("/C").args(&CommandArguments);
528
529		Command
530	} else {
531		let mut Command = ProcessCommand::new(&CommandArguments[0]);
532
533		Command.args(&CommandArguments[1..]);
534
535		Command
536	};
537
538	// Re-assert `CocoonEsbuildDefine` on the child environment so Cocoon's
539	// esbuild step sees the tier `define` blob even if a wrapper ever calls
540	// `.env_clear()` on our `ProcessCommand`. `ProcessCommand` inherits the
541	// parent env by default, so without a clear this is belt-and-braces.
542	if let Some(Defines) = Argument.CocoonEsbuildDefine.as_deref().filter(|v| !v.is_empty()) {
543		ShellCommand.env(CocoonEsbuildDefineEnv, Defines);
544	}
545
546	info!(target: "Build::Exec", "Executing final build command: {:?}", ShellCommand);
547
548	let Status = ShellCommand
549		.current_dir(env::current_dir()?)
550		.stdout(Stdio::inherit())
551		.stderr(Stdio::inherit())
552		.status()?;
553
554	// Handle build failure
555	if !Status.success() {
556		let temp_sidecar_dir = ProjectDir.join("bin");
557
558		if temp_sidecar_dir.exists() {
559			let _ = fs::remove_dir_all(&temp_sidecar_dir);
560		}
561
562		return Err(BuildError::Shell(Status));
563	}
564
565	// Final cleanup of the temporary sidecar directory after a successful build
566	let DirectorySideCarTemporary = ProjectDir.join("bin");
567
568	if DirectorySideCarTemporary.exists() {
569		fs::remove_dir_all(&DirectorySideCarTemporary)?;
570
571		info!(target: "Build", "Cleaned up temporary sidecar directory.");
572	}
573
574	// Guards drop here, restoring Cargo.toml and tauri.conf.json to their
575	// original state and deleting the .Backup files.  The binary has already
576	// been compiled with the generated product name so restoring the source
577	// files is safe and required for the next build to succeed.
578	drop(CargoGuard);
579
580	drop(ConfigGuard);
581
582	info!(target: "Build", "Build orchestration completed successfully.");
583
584	Ok(())
585}
586
587/// Collects environment variables from `.env.Land` for injection into
588/// Info.plist LSEnvironment so the bundled .app works standalone.
589///
590/// Sources from the `.env.Land` file in the repo root (where Maintain
591/// runs from). This ensures every runtime-relevant variable -- Product*,
592/// Tier*, Network*, Trace, Record, Inspect, Disable, etc. -- is
593/// available when the .app is launched via LaunchServices.
594///
595/// Build-time-only flags (CargoFeatures, CocoonEsbuildDefine, NODE_ENV)
596/// are excluded because they have no meaning at runtime inside the .app.
597fn BuildPlistEnvironment() -> BTreeMap<String, String> {
598	let mut EnvVars = BTreeMap::new();
599
600	// Build-time / Maintain-control keys that should NOT leak into the
601	// bundled .app's LSEnvironment.
602	let SkipKeys = ["CargoFeatures", "CocoonEsbuildDefine", "NODE_ENV"];
603
604	// Primary source: the .env.Land file at the repo root (Maintain's
605	// working directory).
606	for Source in [".env.Land", ".env.Land.Sample"] {
607		let Path = PathBuf::from(Source);
608
609		if Path.exists() {
610			if let Ok(Content) = fs::read_to_string(&Path) {
611				info!(target: "Build::Plist", "Loading LSEnvironment vars from {}", Source);
612
613				for Line in Content.lines() {
614					let Trimmed = Line.trim();
615
616					if Trimmed.is_empty() || Trimmed.starts_with('#') {
617						continue;
618					}
619
620					if let Some((Key, Value)) = Trimmed.split_once('=') {
621						let CleanKey = Key.trim();
622
623						let CleanValue = Value.trim().trim_matches('"').trim_matches('\'');
624
625						// Skip build-time-only keys.
626						if SkipKeys.contains(&CleanKey) {
627							continue;
628						}
629
630						EnvVars.insert(CleanKey.to_string(), CleanValue.to_string());
631					}
632				}
633			}
634
635			break;
636		}
637	}
638
639	// Supplement from the current process environment for dev-control
640	// knobs that live outside .env.Land (Trace, Record, Inspect, Disable).
641	// These may have been overridden by the user before invoking Maintain.
642	// Only add if not already populated from .env.Land.
643	for Key in [LandTraceEnv, LandRecordEnv, LandInspectEnv, LandDisableEnv] {
644		if !EnvVars.contains_key(Key) {
645			if let Ok(Value) = env::var(Key) {
646				EnvVars.insert(Key.to_string(), Value);
647			}
648		}
649	}
650
651	EnvVars
652}