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 using the new composable primitives API:
89/// - bundle=false → new_library (externalize deps)
90/// - splitting=true with multiple entries → new_app (code splitting)
91/// - Otherwise → new_multiple (separate bundles) or new (single entry)
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
110    // Display mode info based on config
111    let mode = if !config.bundle {
112        "library (externalize deps)"
113    } else if config.splitting && config.entry.len() > 1 {
114        "app (code splitting)"
115    } else if config.entry.len() > 1 {
116        "components (separate bundles)"
117    } else {
118        "standalone"
119    };
120    ui::info(&format!("Mode: {}", mode));
121    ui::info(&format!("Format: {:?}", config.format));
122    ui::info(&format!("Output: {}", config.out_dir.display()));
123
124    // Create builder based on config using new composable primitives
125    // Map old config fields to new constructor patterns:
126    // - bundle=false → externalize_from("package.json")
127    // - splitting=true with multiple entries → bundle_together().with_code_splitting()
128    // - multiple entries without splitting → bundle_separately()
129    // - single entry with bundle=true → new (standard bundle)
130    let mut builder = if !config.bundle {
131        // Library mode: externalize dependencies
132        if config.entry.len() == 1 {
133            fob_bundler::BuildOptions::new(&config.entry[0]).externalize_from("package.json")
134        } else {
135            // Multiple library entries - use new_multiple with externalize
136            fob_bundler::BuildOptions::new_multiple(&config.entry).externalize_from("package.json")
137        }
138    } else if config.splitting && config.entry.len() > 1 {
139        // App mode: multiple entries with code splitting
140        fob_bundler::BuildOptions::new_multiple(&config.entry)
141            .bundle_together()
142            .with_code_splitting()
143    } else if config.entry.len() > 1 {
144        // Components mode: multiple separate bundles
145        fob_bundler::BuildOptions::new_multiple(&config.entry).bundle_separately()
146    } else {
147        // Standalone: single entry, full bundling
148        fob_bundler::BuildOptions::new(&config.entry[0])
149    };
150
151    // Apply common configuration
152    builder = builder
153        .format(convert_format(config.format))
154        .platform(convert_platform(config.platform))
155        .cwd(cwd)
156        .runtime(Arc::new(NativeRuntime));
157
158    // Minification
159    if config.minify {
160        builder = builder.minify_level("identifiers");
161    }
162
163    // Sourcemap
164    if let Some(sourcemap_mode) = config.sourcemap {
165        builder = match sourcemap_mode {
166            crate::config::SourceMapMode::Inline => builder.sourcemap_inline(),
167            crate::config::SourceMapMode::External => builder.sourcemap(true),
168            crate::config::SourceMapMode::Hidden => builder.sourcemap_hidden(),
169        };
170    }
171
172    // Externals
173    if !config.external.is_empty() {
174        builder = builder.externalize(&config.external);
175    }
176
177    // Global name for IIFE
178    if let Some(ref name) = config.global_name {
179        builder = builder.globals_map([("__self__".to_string(), name.clone())]);
180    }
181
182    // TypeScript declarations
183    #[cfg(feature = "dts-generation")]
184    {
185        if config.dts {
186            builder = builder.emit_dts(true);
187        }
188    }
189
190    // Build
191    let result = builder
192        .build()
193        .await
194        .map_err(|e| CliError::Build(BuildError::Custom(format!("Build failed: {}", e))))?;
195
196    // Write output (force overwrite for build command)
197    let resolved_out_dir = utils::resolve_path(&config.out_dir, cwd);
198    result.write_to_force(&resolved_out_dir).map_err(|e| {
199        CliError::Build(BuildError::Custom(format!("Failed to write output: {}", e)))
200    })?;
201
202    ui::success(&format!("Built to {}", config.out_dir.display()));
203
204    Ok(result)
205}
206
207/// Unified build function that applies configuration directly.
208///
209/// Builds based on configuration using composable primitives:
210/// - bundle=false → library mode (externalize deps)
211/// - splitting=true → app mode (code splitting)
212/// - Otherwise → components or standalone mode
213pub(crate) async fn build(config: &FobConfig, cwd: &std::path::Path) -> Result<()> {
214    build_with_result(config, cwd).await?;
215    Ok(())
216}
217
218/// Validates that the output directory is safe to write to.
219///
220/// # Security
221///
222/// Prevents writing to dangerous locations that could corrupt the system:
223/// - Root directories (/, /usr, /etc, etc.)
224/// - System directories
225/// - Paths outside the project tree
226///
227/// # Errors
228///
229/// Returns `OutputNotWritable` if the directory is unsafe.
230fn validate_output_dir(out_dir: &Path, cwd: &Path) -> Result<()> {
231    let resolved_out_dir = utils::resolve_path(out_dir, cwd);
232    let canonical_out = if resolved_out_dir.exists() {
233        resolved_out_dir.canonicalize()?
234    } else {
235        let parent = resolved_out_dir.parent().ok_or_else(|| {
236            CliError::Build(BuildError::OutputNotWritable(resolved_out_dir.clone()))
237        })?;
238        let filename = resolved_out_dir.file_name().ok_or_else(|| {
239            CliError::Build(BuildError::OutputNotWritable(resolved_out_dir.clone()))
240        })?;
241        parent.canonicalize()?.join(filename)
242    };
243
244    let canonical_cwd = cwd.canonicalize()?;
245
246    let is_within_project = canonical_out.starts_with(&canonical_cwd);
247    let is_sibling = canonical_out
248        .parent()
249        .and_then(|p| canonical_cwd.parent().map(|c| p == c))
250        .unwrap_or(false);
251
252    if !is_within_project && !is_sibling {
253        return Err(CliError::Build(BuildError::OutputNotWritable(
254            resolved_out_dir,
255        )));
256    }
257
258    const DANGEROUS_PATHS: &[&str] = &[
259        "/bin",
260        "/boot",
261        "/dev",
262        "/etc",
263        "/lib",
264        "/lib64",
265        "/proc",
266        "/root",
267        "/sbin",
268        "/sys",
269        "/usr/bin",
270        "/usr/lib",
271        "/usr/sbin",
272        "/var/log",
273    ];
274
275    let out_str = canonical_out.to_string_lossy();
276    for dangerous in DANGEROUS_PATHS {
277        if out_str.starts_with(dangerous) {
278            return Err(CliError::Build(BuildError::Custom(format!(
279                "Refusing to write to system directory: {}",
280                out_str
281            ))));
282        }
283    }
284
285    if out_str == "/" {
286        return Err(CliError::Build(BuildError::Custom(
287            "Refusing to write to root directory".to_string(),
288        )));
289    }
290
291    Ok(())
292}
293
294/// Convert CLI format enum to fob-bundler OutputFormat
295fn convert_format(format: crate::config::Format) -> fob_bundler::OutputFormat {
296    match format {
297        crate::config::Format::Esm => fob_bundler::OutputFormat::Esm,
298        crate::config::Format::Cjs => fob_bundler::OutputFormat::Cjs,
299        crate::config::Format::Iife => fob_bundler::OutputFormat::Iife,
300    }
301}
302
303/// Convert CLI platform enum to fob-bundler Platform
304fn convert_platform(platform: crate::config::Platform) -> fob_bundler::Platform {
305    match platform {
306        crate::config::Platform::Browser => fob_bundler::Platform::Browser,
307        crate::config::Platform::Node => fob_bundler::Platform::Node,
308    }
309}