fob_cli/commands/
check.rs

1//! Check command implementation.
2//!
3//! Validates configuration and dependencies without building.
4
5use crate::cli::CheckArgs;
6use crate::commands::utils;
7use crate::config::{FobConfig, Format};
8use crate::error::{ConfigError, Result};
9use crate::ui;
10use std::path::Path;
11
12/// Execute the check command.
13///
14/// # Validation Steps
15///
16/// 1. Load and validate fob.config.json
17/// 2. Check entry points exist
18/// 3. Validate format/option combinations
19/// 4. Check dependencies (if --deps flag)
20/// 5. Report warnings (if --warnings flag)
21///
22/// # Arguments
23///
24/// * `args` - Parsed check command arguments
25///
26/// # Errors
27///
28/// Returns errors for invalid configuration or missing files.
29pub async fn execute(args: CheckArgs) -> Result<()> {
30    ui::info("Checking configuration...");
31
32    // Load config file if specified or search for default
33    let config_path = args.config.as_deref();
34    let config_content = if let Some(path) = config_path {
35        std::fs::read_to_string(path).map_err(|_| ConfigError::NotFound(path.to_path_buf()))?
36    } else {
37        let default_path = Path::new("fob.config.json");
38        if default_path.exists() {
39            std::fs::read_to_string(default_path)
40                .map_err(|_| ConfigError::NotFound(default_path.to_path_buf()))?
41        } else {
42            ui::warning("No fob.config.json found, using defaults");
43            return Ok(());
44        }
45    };
46
47    // Parse and validate config
48    let config: FobConfig = serde_json::from_str(&config_content)?;
49    config.validate()?;
50
51    ui::success("Configuration is valid!");
52
53    // Check entry points exist
54    ui::info("Checking entry points...");
55    let cwd = if let Some(ref cwd_path) = config.cwd {
56        cwd_path.clone()
57    } else {
58        utils::get_cwd()?
59    };
60
61    for entry in &config.entry {
62        let entry_path = utils::resolve_path(Path::new(entry), &cwd);
63        if !entry_path.exists() {
64            ui::error(&format!("Entry point not found: {}", entry_path.display()));
65            return Err(ConfigError::MissingField {
66                field: "entry".to_string(),
67                hint: format!("File does not exist: {}", entry_path.display()),
68            }
69            .into());
70        }
71        ui::success(&format!("  {} exists", entry));
72    }
73
74    // Validate option combinations
75    validate_options(&config)?;
76
77    // Check dependencies if requested
78    if args.deps {
79        ui::info("Checking dependencies...");
80        check_dependencies(&cwd)?;
81    }
82
83    // Report warnings if requested
84    if args.warnings {
85        ui::info("Checking for warnings...");
86        check_warnings(&config);
87    }
88
89    ui::success("All checks passed!");
90    Ok(())
91}
92
93/// Validate that configuration options are compatible.
94fn validate_options(config: &FobConfig) -> Result<()> {
95    // IIFE without global name
96    if config.format == Format::Iife && config.global_name.is_none() {
97        return Err(ConfigError::MissingField {
98            field: "globalName".to_string(),
99            hint: "IIFE format requires a global variable name".to_string(),
100        }
101        .into());
102    }
103
104    // Code splitting with non-ESM
105    if config.splitting && config.format != Format::Esm {
106        return Err(ConfigError::ConflictingOptions(
107            "Code splitting requires ESM format".to_string(),
108        )
109        .into());
110    }
111
112    // DTS bundle without DTS
113    if config.dts_bundle == Some(true) && !config.dts {
114        return Err(ConfigError::InvalidValue {
115            field: "dtsBundle".to_string(),
116            value: "true".to_string(),
117            hint: "Requires dts: true".to_string(),
118        }
119        .into());
120    }
121
122    Ok(())
123}
124
125/// Check that package.json and dependencies are valid.
126fn check_dependencies(cwd: &Path) -> Result<()> {
127    let package_json_path = cwd.join("package.json");
128
129    if !package_json_path.exists() {
130        ui::warning("No package.json found");
131        return Ok(());
132    }
133
134    let package_json_content = std::fs::read_to_string(&package_json_path)?;
135    let package_json: serde_json::Value = serde_json::from_str(&package_json_content)?;
136
137    // Check if dependencies field exists
138    if let Some(deps) = package_json.get("dependencies") {
139        if let Some(obj) = deps.as_object() {
140            ui::info(&format!("Found {} dependencies", obj.len()));
141        }
142    }
143
144    if let Some(dev_deps) = package_json.get("devDependencies") {
145        if let Some(obj) = dev_deps.as_object() {
146            ui::info(&format!("Found {} dev dependencies", obj.len()));
147        }
148    }
149
150    ui::success("Dependencies look good");
151    Ok(())
152}
153
154/// Check for potential issues and report warnings.
155fn check_warnings(config: &FobConfig) {
156    let mut warnings = Vec::new();
157
158    // IIFE without global name (if it somehow passes validation)
159    if config.format == Format::Iife && config.global_name.is_none() {
160        warnings.push("IIFE format should have a globalName");
161    }
162
163    // Code splitting with non-ESM
164    if config.splitting && config.format != Format::Esm {
165        warnings.push("Code splitting works best with ESM format");
166    }
167
168    // DTS without TypeScript files
169    if config.dts
170        && !config
171            .entry
172            .iter()
173            .any(|e| e.ends_with(".ts") || e.ends_with(".tsx"))
174    {
175        warnings.push("DTS generation enabled but no TypeScript entry points found");
176    }
177
178    // External packages not in dependencies
179    if !config.external.is_empty() {
180        warnings.push("Ensure external packages are listed in package.json dependencies");
181    }
182
183    // Minification without production build
184    if !config.minify {
185        warnings.push("Consider enabling minification for production builds");
186    }
187
188    // No source maps
189    if config.sourcemap.is_none() {
190        warnings.push("Consider enabling source maps for better debugging");
191    }
192
193    if warnings.is_empty() {
194        ui::info("No warnings found");
195    } else {
196        ui::warning(&format!("Found {} potential issues:", warnings.len()));
197        for warning in warnings {
198            ui::warning(&format!("  - {}", warning));
199        }
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use crate::config::{EsTarget, Platform};
207    use std::path::PathBuf;
208
209    fn test_config() -> FobConfig {
210        FobConfig {
211            entry: vec!["src/index.ts".to_string()],
212            format: Format::Esm,
213            out_dir: PathBuf::from("dist"),
214            bundle: true,
215            dts: false,
216            dts_bundle: None,
217            external: vec![],
218            platform: Platform::Browser,
219            sourcemap: None,
220            minify: false,
221            target: EsTarget::Es2020,
222            global_name: None,
223            splitting: false,
224            no_treeshake: false,
225            clean: false,
226            cwd: None,
227        }
228    }
229
230    #[test]
231    fn test_validate_options_valid_esm() {
232        let config = test_config();
233        assert!(validate_options(&config).is_ok());
234    }
235
236    #[test]
237    fn test_validate_options_iife_without_global_name() {
238        let mut config = test_config();
239        config.format = Format::Iife;
240        config.global_name = None;
241
242        assert!(validate_options(&config).is_err());
243    }
244
245    #[test]
246    fn test_validate_options_iife_with_global_name() {
247        let mut config = test_config();
248        config.format = Format::Iife;
249        config.global_name = Some("MyLib".to_string());
250
251        assert!(validate_options(&config).is_ok());
252    }
253
254    #[test]
255    fn test_validate_options_splitting_with_cjs() {
256        let mut config = test_config();
257        config.format = Format::Cjs;
258        config.splitting = true;
259
260        assert!(validate_options(&config).is_err());
261    }
262
263    #[test]
264    fn test_validate_options_splitting_with_esm() {
265        let mut config = test_config();
266        config.format = Format::Esm;
267        config.splitting = true;
268
269        assert!(validate_options(&config).is_ok());
270    }
271
272    #[test]
273    fn test_validate_options_dts_bundle_without_dts() {
274        let mut config = test_config();
275        config.dts = false;
276        config.dts_bundle = Some(true);
277
278        assert!(validate_options(&config).is_err());
279    }
280
281    #[test]
282    fn test_validate_options_dts_bundle_with_dts() {
283        let mut config = test_config();
284        config.dts = true;
285        config.dts_bundle = Some(true);
286
287        assert!(validate_options(&config).is_ok());
288    }
289
290    #[test]
291    fn test_check_warnings_generates_warnings() {
292        let mut config = test_config();
293        config.dts = true; // DTS enabled but no .ts files
294
295        // Should not panic
296        check_warnings(&config);
297    }
298}