fob_cli/commands/
build.rs

1//! Build command implementation.
2//!
3//! This module implements the `fob build` command, which bundles JavaScript/TypeScript
4//! files using the fob-core library.
5
6use crate::cli::BuildArgs;
7use crate::commands::utils;
8use crate::config::FobConfig;
9use crate::error::{BuildError, CliError, Result};
10use crate::ui;
11use fob_bundler::NativeRuntime;
12use std::path::Path;
13use std::sync::Arc;
14use std::time::Instant;
15
16/// Execute the build command.
17///
18/// # Build Process
19///
20/// 1. Load and validate configuration (CLI > Env > File > Defaults)
21/// 2. Clean output directory if requested
22/// 3. Validate entry points
23/// 4. Execute build with progress tracking
24/// 5. Write output files
25/// 6. Display build summary
26///
27/// # Arguments
28///
29/// * `args` - Parsed command-line arguments
30///
31/// # Errors
32///
33/// Returns errors for:
34/// - Invalid configuration
35/// - Missing entry points
36/// - Build failures
37/// - File system errors
38pub async fn execute(args: BuildArgs) -> Result<()> {
39    let start_time = Instant::now();
40
41    // Step 1: Load configuration
42    ui::info("Loading configuration...");
43    let config = FobConfig::load(&args, None)?;
44    config.validate()?;
45
46    // Resolve project root using smart auto-detection
47    let cwd = utils::resolve_project_root(
48        config.cwd.as_deref(),                    // explicit --cwd flag
49        config.entry.first().map(String::as_str), // first entry point
50    )?;
51
52    // Step 2: Clean output if requested
53    if config.clean {
54        let out_dir = utils::resolve_path(&config.out_dir, &cwd);
55        ui::info(&format!("Cleaning output directory: {}", out_dir.display()));
56        utils::clean_output_dir(&out_dir)?;
57    } else {
58        let out_dir = utils::resolve_path(&config.out_dir, &cwd);
59        utils::ensure_output_dir(&out_dir)?;
60    }
61
62    // Step 3: Validate entry points
63    if config.entry.is_empty() {
64        return Err(CliError::InvalidArgument(
65            "At least one entry point is required".to_string(),
66        ));
67    }
68
69    for entry in &config.entry {
70        let entry_path = utils::resolve_path(std::path::Path::new(entry), &cwd);
71        utils::validate_entry(&entry_path)?;
72    }
73
74    // Step 4: Execute build
75    build(&config, &cwd).await?;
76
77    let duration = start_time.elapsed();
78    ui::success(&format!(
79        "Build completed in {}",
80        ui::format_duration(duration)
81    ));
82
83    Ok(())
84}
85
86/// Unified build function that returns the BuildResult.
87///
88/// Builds based on configuration, not mode detection:
89/// - Single entry → BuildOptions::new()
90/// - Multiple entries → BuildOptions::new_multiple()
91/// - Applies bundle, splitting, platform, etc. from config
92///
93/// This version returns the BuildResult for dev server use.
94pub(crate) async fn build_with_result(
95    config: &FobConfig,
96    cwd: &std::path::Path,
97) -> Result<fob_bundler::BuildResult> {
98    validate_output_dir(&config.out_dir, cwd)?;
99
100    // Display build info
101    if config.entry.len() == 1 {
102        ui::info(&format!("Building: {}", config.entry[0]));
103    } else {
104        ui::info(&format!("Building {} entries...", config.entry.len()));
105        for entry in &config.entry {
106            ui::info(&format!("  - {}", entry));
107        }
108    }
109    ui::info(&format!("Bundle: {}", config.bundle));
110    ui::info(&format!("Format: {:?}", config.format));
111    if config.splitting {
112        ui::info("Code splitting: enabled");
113    }
114    ui::info(&format!("Output: {}", config.out_dir.display()));
115
116    // Create builder based on entry count
117    let mut builder = if config.entry.len() == 1 {
118        fob_bundler::BuildOptions::new(&config.entry[0])
119    } else {
120        fob_bundler::BuildOptions::new_multiple(&config.entry)
121    };
122
123    // Apply configuration
124    builder = builder
125        .bundle(config.bundle)
126        .format(convert_format(config.format))
127        .minify(config.minify)
128        .platform(convert_platform(config.platform))
129        .splitting(config.splitting)
130        .cwd(cwd)
131        .runtime(Arc::new(NativeRuntime));
132
133    // Sourcemap
134    if let Some(sourcemap_mode) = config.sourcemap {
135        builder = match sourcemap_mode {
136            crate::config::SourceMapMode::Inline => builder.sourcemap_inline(),
137            crate::config::SourceMapMode::External => builder.sourcemap(true),
138            crate::config::SourceMapMode::Hidden => builder.sourcemap_hidden(),
139        };
140    }
141
142    // Externals
143    if !config.external.is_empty() {
144        builder = builder.external(&config.external);
145    }
146
147    // Global name for IIFE
148    if let Some(ref name) = config.global_name {
149        builder = builder.globals_map([("__self__".to_string(), name.clone())]);
150    }
151
152    // TypeScript declarations
153    #[cfg(feature = "dts-generation")]
154    {
155        if config.dts {
156            builder = builder.emit_dts(true);
157        }
158    }
159
160    // Build
161    let result = builder
162        .build()
163        .await
164        .map_err(|e| CliError::Build(BuildError::Custom(format!("Build failed: {}", e))))?;
165
166    // Write output (force overwrite for build command)
167    let resolved_out_dir = utils::resolve_path(&config.out_dir, cwd);
168    result.write_to_force(&resolved_out_dir).map_err(|e| {
169        CliError::Build(BuildError::Custom(format!("Failed to write output: {}", e)))
170    })?;
171
172    ui::success(&format!("Built to {}", config.out_dir.display()));
173
174    Ok(result)
175}
176
177/// Unified build function that applies configuration directly.
178///
179/// Builds based on configuration, not mode detection:
180/// - Single entry → BuildOptions::new()
181/// - Multiple entries → BuildOptions::new_multiple()
182/// - Applies bundle, splitting, platform, etc. from config
183pub(crate) async fn build(config: &FobConfig, cwd: &std::path::Path) -> Result<()> {
184    build_with_result(config, cwd).await?;
185    Ok(())
186}
187
188/// Validates that the output directory is safe to write to.
189///
190/// # Security
191///
192/// Prevents writing to dangerous locations that could corrupt the system:
193/// - Root directories (/, /usr, /etc, etc.)
194/// - System directories
195/// - Paths outside the project tree
196///
197/// # Errors
198///
199/// Returns `OutputNotWritable` if the directory is unsafe.
200fn validate_output_dir(out_dir: &Path, cwd: &Path) -> Result<()> {
201    let resolved_out_dir = utils::resolve_path(out_dir, cwd);
202    let canonical_out = if resolved_out_dir.exists() {
203        resolved_out_dir.canonicalize()?
204    } else {
205        let parent = resolved_out_dir.parent().ok_or_else(|| {
206            CliError::Build(BuildError::OutputNotWritable(resolved_out_dir.clone()))
207        })?;
208        parent
209            .canonicalize()?
210            .join(resolved_out_dir.file_name().unwrap())
211    };
212
213    let canonical_cwd = cwd.canonicalize()?;
214
215    let is_within_project = canonical_out.starts_with(&canonical_cwd);
216    let is_sibling = canonical_out
217        .parent()
218        .and_then(|p| canonical_cwd.parent().map(|c| p == c))
219        .unwrap_or(false);
220
221    if !is_within_project && !is_sibling {
222        return Err(CliError::Build(BuildError::OutputNotWritable(
223            resolved_out_dir,
224        )));
225    }
226
227    const DANGEROUS_PATHS: &[&str] = &[
228        "/bin",
229        "/boot",
230        "/dev",
231        "/etc",
232        "/lib",
233        "/lib64",
234        "/proc",
235        "/root",
236        "/sbin",
237        "/sys",
238        "/usr/bin",
239        "/usr/lib",
240        "/usr/sbin",
241        "/var/log",
242    ];
243
244    let out_str = canonical_out.to_string_lossy();
245    for dangerous in DANGEROUS_PATHS {
246        if out_str.starts_with(dangerous) {
247            return Err(CliError::Build(BuildError::Custom(format!(
248                "Refusing to write to system directory: {}",
249                out_str
250            ))));
251        }
252    }
253
254    if out_str == "/" {
255        return Err(CliError::Build(BuildError::Custom(
256            "Refusing to write to root directory".to_string(),
257        )));
258    }
259
260    Ok(())
261}
262
263/// Convert CLI format enum to fob-bundler OutputFormat
264fn convert_format(format: crate::config::Format) -> fob_bundler::OutputFormat {
265    match format {
266        crate::config::Format::Esm => fob_bundler::OutputFormat::Esm,
267        crate::config::Format::Cjs => fob_bundler::OutputFormat::Cjs,
268        crate::config::Format::Iife => fob_bundler::OutputFormat::Iife,
269    }
270}
271
272/// Convert CLI platform enum to fob-bundler Platform
273fn convert_platform(platform: crate::config::Platform) -> fob_bundler::Platform {
274    match platform {
275        crate::config::Platform::Browser => fob_bundler::Platform::Browser,
276        crate::config::Platform::Node => fob_bundler::Platform::Node,
277    }
278}