Skip to main content

cobble/commands/
build.rs

1use crate::commands::validate::{print_validation_report, run_validation};
2use crate::config::CobbleConfig;
3use crate::pack_format::{
4    PackFormat, COBBLE_VERSION, SUPPORTED_MINECRAFT_VERSION, SUPPORTED_PACK_FORMAT,
5};
6use crate::parser::parse;
7use crate::transpiler::{BuildManifestInput, BuildManifestValidation, Transpiler};
8use crate::validator::ValidationReport;
9use std::fs;
10use std::io::Write;
11use std::path::{Path, PathBuf};
12use walkdir::WalkDir;
13use zip::write::SimpleFileOptions;
14use zip::{CompressionMethod, ZipWriter};
15
16pub struct BuildOptions {
17    pub input: Option<PathBuf>,
18    pub output: Option<PathBuf>,
19    pub namespace: Option<String>,
20    pub pack_format: Option<String>,
21    pub description: Option<String>,
22    pub verbose: bool,
23    pub quiet: bool,
24    pub zip: bool,
25    pub validate: bool,
26    pub dry_run: bool,
27    pub commands_json: PathBuf,
28}
29
30pub fn build(options: BuildOptions) -> Result<(), String> {
31    if options.quiet && options.verbose {
32        return Err("--quiet cannot be combined with --verbose".to_string());
33    }
34    if options.dry_run && options.zip {
35        return Err(
36            "--dry-run cannot be combined with --zip because no final output is written"
37                .to_string(),
38        );
39    }
40
41    // Try to find cobble.toml
42    let (config, config_dir) = if let Some(config_path) = find_config(&options.input) {
43        let config = if options.pack_format.is_some() {
44            CobbleConfig::load_unvalidated(&config_path)?
45        } else {
46            CobbleConfig::load(&config_path)?
47        };
48        let config_dir = config_path.parent().unwrap().to_path_buf();
49        (Some(config), config_dir)
50    } else {
51        (
52            None,
53            std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
54        )
55    };
56
57    // Determine source and output paths
58    let source_path = if let Some(ref input) = options.input {
59        input.clone()
60    } else if let Some(ref cfg) = config {
61        config_dir.join(&cfg.build.source)
62    } else {
63        return Err("No input specified and no cobble.toml found".to_string());
64    };
65
66    let output_dir = if let Some(ref output) = options.output {
67        output.clone()
68    } else if let Some(ref cfg) = config {
69        config_dir.join(&cfg.build.output)
70    } else {
71        PathBuf::from("output")
72    };
73
74    // Get namespace from options, config, or use default
75    let namespace = options
76        .namespace
77        .clone()
78        .or_else(|| config.as_ref().map(|c| c.project.namespace.clone()))
79        .unwrap_or_else(|| "cobble".to_string());
80
81    // Security: Validate namespace to prevent command injection
82    validate_namespace(&namespace)?;
83
84    let description = options
85        .description
86        .clone()
87        .or_else(|| config.as_ref().map(|c| c.project.description.clone()))
88        .unwrap_or_else(|| "Generated by Cobble".to_string());
89
90    let pack_format = if let Some(ref pack_fmt_str) = options.pack_format {
91        PackFormat::parse_format(pack_fmt_str)?
92    } else if let Some(ref cfg) = config {
93        PackFormat::parse_format(&cfg.project.pack_format)?
94    } else {
95        SUPPORTED_PACK_FORMAT
96    };
97
98    // Validate pack_format against this release's single supported Minecraft target.
99    if !pack_format.is_supported() {
100        return Err(format!(
101            "pack_format must be {} (Minecraft Java Edition {}), got {}.\n\
102            Cobble v{} exclusively supports Minecraft Java Edition {}.\n\
103            See https://minecraft.wiki/w/Pack_format for version compatibility.",
104            SUPPORTED_PACK_FORMAT,
105            SUPPORTED_MINECRAFT_VERSION,
106            pack_format,
107            COBBLE_VERSION,
108            SUPPORTED_MINECRAFT_VERSION
109        ));
110    }
111
112    let configured_entry_points = if options.input.is_none() {
113        config
114            .as_ref()
115            .map(|cfg| cfg.build.entry_points.clone())
116            .unwrap_or_default()
117    } else {
118        Vec::new()
119    };
120
121    // Check if source is a file or directory
122    let files_to_compile = if source_path.is_file() {
123        vec![source_path.clone()]
124    } else if source_path.is_dir() {
125        if options.input.is_none() {
126            if !configured_entry_points.is_empty() {
127                resolve_entry_points(&source_path, &configured_entry_points)?
128            } else {
129                find_cobble_files(&source_path)?
130            }
131        } else {
132            find_cobble_files(&source_path)?
133        }
134    } else {
135        return Err(format!("Source path does not exist: {:?}", source_path));
136    };
137
138    let source_display_root = source_display_root(&source_path, &config_dir);
139
140    if options.verbose {
141        println!("Building {} file(s)...", files_to_compile.len());
142        println!("Namespace: {}", namespace);
143        println!("Pack format: {}", pack_format);
144        println!("Description: {}", description);
145    } else if !options.quiet {
146        println!("Building {} file(s)...", files_to_compile.len());
147    }
148    if options.dry_run && !options.quiet {
149        println!("Dry run: final output will not be written");
150    }
151
152    let final_output_dir = output_dir.clone();
153    let build_output_dir = if options.validate && !files_to_compile.is_empty() {
154        staging_output_dir(&final_output_dir)?
155    } else {
156        final_output_dir.clone()
157    };
158
159    // Create transpiler
160    let mut transpiler = Transpiler::new(namespace.clone(), build_output_dir.clone());
161    transpiler.set_description(description);
162    transpiler.set_pack_format(pack_format);
163    transpiler.set_source_display_root(source_display_root.clone());
164    transpiler.set_build_input(BuildManifestInput {
165        source: path_display_relative(&source_path, &source_display_root),
166        entry_points: configured_entry_points,
167        compiled_files: files_to_compile
168            .iter()
169            .map(|path| path_display_relative(path, &source_display_root))
170            .collect(),
171    });
172
173    if files_to_compile.is_empty() {
174        if !options.dry_run {
175            transpiler
176                .write_data_pack()
177                .map_err(|e| format!("Failed to clean data pack output: {}", e))?;
178        }
179        return Err("No Cobble files found to compile".to_string());
180    }
181
182    // Compile all files
183    for file_path in &files_to_compile {
184        if !options.quiet {
185            println!(
186                "  • Compiling: {:?}",
187                file_path.file_name().unwrap_or_default()
188            );
189        }
190
191        let src = fs::read_to_string(file_path)
192            .map_err(|e| format!("Failed to read {:?}: {}", file_path, e))?;
193
194        let program = parse(&src).map_err(|errors| {
195            format!(
196                "Parse failed for {:?}:\n  {}",
197                file_path,
198                errors.join("\n  ")
199            )
200        })?;
201
202        // Set current file for import resolution and source tracking
203        transpiler.set_current_file_with_source(file_path, &src);
204
205        transpiler
206            .transpile(&program)
207            .map_err(|e| format!("Transpilation failed for {:?}: {}", file_path, e))?;
208    }
209
210    if !options.dry_run || options.validate {
211        transpiler
212            .write_data_pack()
213            .map_err(|e| format!("Failed to write data pack: {}", e))?;
214    }
215
216    let mut validation_summary = None;
217    if options.validate {
218        if !options.quiet {
219            println!("Validating generated commands...");
220        }
221        let report = match run_validation(&build_output_dir, &options.commands_json) {
222            Ok(report) => report,
223            Err(error) => {
224                if build_output_dir != final_output_dir {
225                    let _ = fs::remove_dir_all(&build_output_dir);
226                }
227                return Err(error);
228            }
229        };
230        let has_validation_errors =
231            !report.errors.is_empty() || !report.source_map_errors.is_empty();
232        if !options.quiet || has_validation_errors {
233            print_validation_report(&report, &options.commands_json, &build_output_dir);
234        }
235        validation_summary = Some(validation_summary_from_report(
236            &options.commands_json,
237            &report,
238        ));
239        if has_validation_errors {
240            if build_output_dir != final_output_dir {
241                let _ = fs::remove_dir_all(&build_output_dir);
242            }
243            return Err(format!(
244                "{} validation error(s) found",
245                report.errors.len() + report.source_map_errors.len()
246            ));
247        }
248        transpiler.set_validation_summary(validation_summary.clone());
249        if !options.dry_run {
250            transpiler
251                .write_data_pack()
252                .map_err(|e| format!("Failed to write validation metadata: {}", e))?;
253        }
254    }
255
256    if options.dry_run {
257        if build_output_dir != final_output_dir {
258            let _ = fs::remove_dir_all(&build_output_dir);
259        }
260        if !options.quiet {
261            println!("✓ Dry run completed; no output written");
262        }
263        if options.validate && !options.quiet {
264            println!("✓ All commands valid");
265        }
266        if !options.quiet {
267            print_build_summary(
268                &transpiler,
269                files_to_compile.len(),
270                validation_summary.as_ref(),
271                None,
272                true,
273            );
274        }
275        return Ok(());
276    }
277
278    if options.validate {
279        if build_output_dir != final_output_dir {
280            replace_output_dir(&build_output_dir, &final_output_dir)?;
281        }
282        if !options.quiet {
283            println!("✓ Data pack generated at {:?}", final_output_dir);
284            println!("✓ All commands valid");
285        }
286    } else if !options.quiet {
287        println!("✓ Data pack generated at {:?}", final_output_dir);
288    }
289
290    // Create zip if requested
291    let zip_path = if options.zip {
292        let zip_path = create_zip(&final_output_dir, &namespace)?;
293        if !options.quiet {
294            println!("✓ Created {}", zip_path.display());
295        }
296        Some(zip_path)
297    } else {
298        None
299    };
300
301    if !options.quiet {
302        print_build_summary(
303            &transpiler,
304            files_to_compile.len(),
305            validation_summary.as_ref(),
306            zip_path.as_deref(),
307            false,
308        );
309    }
310
311    Ok(())
312}
313
314fn path_display(path: &Path) -> String {
315    path.display().to_string()
316}
317
318fn path_display_relative(path: &Path, root: &Path) -> String {
319    let canonical_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
320    let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
321
322    if let Ok(relative_path) = canonical_path.strip_prefix(&canonical_root) {
323        if relative_path.as_os_str().is_empty() {
324            return ".".to_string();
325        }
326        return path_display(relative_path);
327    }
328
329    if path.is_relative() {
330        return path_display(path);
331    }
332
333    path_display(&canonical_path)
334}
335
336fn source_display_root(source_path: &Path, config_dir: &Path) -> PathBuf {
337    let source_path = source_path
338        .canonicalize()
339        .unwrap_or_else(|_| source_path.to_path_buf());
340    let config_dir = config_dir
341        .canonicalize()
342        .unwrap_or_else(|_| config_dir.to_path_buf());
343
344    if source_path.strip_prefix(&config_dir).is_ok() {
345        return config_dir;
346    }
347
348    if source_path.is_file() {
349        return source_path
350            .parent()
351            .map(Path::to_path_buf)
352            .unwrap_or_else(|| PathBuf::from("."));
353    }
354
355    source_path
356}
357
358fn validation_summary_from_report(
359    commands_json: &Path,
360    report: &ValidationReport,
361) -> BuildManifestValidation {
362    BuildManifestValidation {
363        enabled: true,
364        commands_json: path_display(commands_json),
365        files_checked: report.files_checked,
366        commands_checked: report.commands_checked,
367        macro_commands_checked: report.macro_commands_checked,
368        commands_skipped: report.commands_skipped,
369        errors: report.errors.len(),
370        source_map_errors: report.source_map_errors.len(),
371    }
372}
373
374fn print_build_summary(
375    transpiler: &Transpiler,
376    source_files: usize,
377    validation: Option<&BuildManifestValidation>,
378    zip_path: Option<&Path>,
379    dry_run: bool,
380) {
381    let generated = transpiler.data_pack.generated_counts();
382    println!("Build summary:");
383    println!("  Source files: {}", source_files);
384    println!("  Functions: {}", generated.functions);
385    println!("  Commands: {}", generated.commands);
386    println!("  Function tags: {}", generated.function_tags);
387    println!("  JSON resources: {}", generated.total_json_resources);
388    if let Some(validation) = validation {
389        println!(
390            "  Validation: {} commands in {} files ({} macro checked, {} skipped)",
391            validation.commands_checked,
392            validation.files_checked,
393            validation.macro_commands_checked,
394            validation.commands_skipped
395        );
396    }
397    if let Some(zip_path) = zip_path {
398        println!("  ZIP: {}", zip_path.display());
399    }
400    if dry_run {
401        println!("  Output: not written (--dry-run)");
402    }
403}
404
405/// Validate that namespace contains only safe characters
406fn validate_namespace(namespace: &str) -> Result<(), String> {
407    if namespace.is_empty() {
408        return Err("Namespace cannot be empty".to_string());
409    }
410    if namespace.len() > 64 {
411        return Err(format!(
412            "Namespace too long: {} chars (max 64)",
413            namespace.len()
414        ));
415    }
416    if !namespace
417        .chars()
418        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-' || c == '.')
419    {
420        return Err(format!(
421            "Invalid namespace '{}': Can only contain lowercase letters, digits, underscores, hyphens, and dots.\n\
422            Example: 'my_datapack', 'cool-pack.v2'",
423            namespace
424        ));
425    }
426    Ok(())
427}
428
429fn staging_output_dir(output_dir: &Path) -> Result<PathBuf, String> {
430    let parent = output_dir.parent().unwrap_or_else(|| Path::new("."));
431    let name = output_dir
432        .file_name()
433        .and_then(|name| name.to_str())
434        .unwrap_or("output");
435    let stamp = std::time::SystemTime::now()
436        .duration_since(std::time::UNIX_EPOCH)
437        .map_err(|e| format!("System clock error while creating staging output: {}", e))?
438        .as_nanos();
439    let staging = parent.join(format!(
440        ".{}.cobble-staging-{}-{}",
441        name,
442        std::process::id(),
443        stamp
444    ));
445    if staging.exists() {
446        fs::remove_dir_all(&staging)
447            .map_err(|e| format!("Failed to clean staging output {:?}: {}", staging, e))?;
448    }
449    Ok(staging)
450}
451
452fn replace_output_dir(staging_dir: &Path, output_dir: &Path) -> Result<(), String> {
453    if output_dir.exists() {
454        if output_dir.is_dir() {
455            fs::remove_dir_all(output_dir)
456                .map_err(|e| format!("Failed to replace output {:?}: {}", output_dir, e))?;
457        } else {
458            fs::remove_file(output_dir)
459                .map_err(|e| format!("Failed to replace output {:?}: {}", output_dir, e))?;
460        }
461    }
462    fs::rename(staging_dir, output_dir).map_err(|e| {
463        format!(
464            "Failed to move validated data pack from {:?} to {:?}: {}",
465            staging_dir, output_dir, e
466        )
467    })
468}
469
470fn find_config(input: &Option<PathBuf>) -> Option<PathBuf> {
471    if let Some(path) = input {
472        if path.is_file() {
473            // If input is a file, look for config in parent directories
474            if let Some(parent) = path.parent() {
475                return CobbleConfig::find_in_path(parent);
476            }
477        } else {
478            // If input is a directory, look for config in it
479            return CobbleConfig::find_in_path(path);
480        }
481    }
482    // Look in current directory
483    CobbleConfig::find_in_path(".")
484}
485
486fn resolve_entry_points(
487    source_dir: &Path,
488    entry_points: &[String],
489) -> Result<Vec<PathBuf>, String> {
490    let mut files = Vec::new();
491
492    for entry_point in entry_points {
493        let entry_path = Path::new(entry_point);
494        let path = if entry_path.is_absolute() {
495            entry_path.to_path_buf()
496        } else {
497            source_dir.join(entry_path)
498        };
499
500        if path.is_file() {
501            files.push(path);
502        } else if path.is_dir() {
503            files.extend(find_cobble_files(&path)?);
504        } else {
505            return Err(format!("Entry point does not exist: {}", path.display()));
506        }
507    }
508
509    Ok(files)
510}
511
512fn find_cobble_files(dir: &Path) -> Result<Vec<PathBuf>, String> {
513    let mut files = Vec::new();
514
515    for entry in WalkDir::new(dir)
516        .follow_links(false) // Security: Don't follow symlinks to prevent attacks
517        .into_iter()
518        .filter_map(|e| e.ok())
519    {
520        let path = entry.path();
521        if path.is_symlink() {
522            eprintln!("⚠️  Warning: Skipping symlink: {:?}", path);
523            continue;
524        }
525        if path.is_file() {
526            if let Some(ext) = path.extension() {
527                if ext == "cbl" || ext == "cobble" {
528                    files.push(path.to_path_buf());
529                }
530            }
531        }
532    }
533
534    files.sort();
535    Ok(files)
536}
537
538#[cfg(test)]
539#[allow(clippy::items_after_test_module)]
540mod tests {
541    use super::*;
542    use std::sync::Mutex;
543
544    static CWD_LOCK: Mutex<()> = Mutex::new(());
545
546    struct CurrentDirGuard {
547        previous: PathBuf,
548    }
549
550    impl CurrentDirGuard {
551        fn push(path: &Path) -> Self {
552            let previous = std::env::current_dir().unwrap();
553            std::env::set_current_dir(path).unwrap();
554            Self { previous }
555        }
556    }
557
558    impl Drop for CurrentDirGuard {
559        fn drop(&mut self) {
560            std::env::set_current_dir(&self.previous).unwrap();
561        }
562    }
563
564    #[test]
565    fn resolves_entry_points_relative_to_source_dir() {
566        let temp_dir = tempfile::TempDir::new().unwrap();
567        let source_dir = temp_dir.path().join("src");
568        fs::create_dir_all(&source_dir).unwrap();
569        fs::write(source_dir.join("main.cbl"), "def main():\n    pass\n").unwrap();
570        fs::write(source_dir.join("utils.cbl"), "def helper():\n    pass\n").unwrap();
571
572        let files = resolve_entry_points(&source_dir, &["main.cbl".to_string()]).unwrap();
573
574        assert_eq!(files, vec![source_dir.join("main.cbl")]);
575    }
576
577    #[test]
578    fn build_validate_reports_missing_command_tree() {
579        let temp_dir = tempfile::TempDir::new().unwrap();
580        let input_file = temp_dir.path().join("test.cbl");
581        let output_dir = temp_dir.path().join("output");
582        let commands_json = temp_dir.path().join("missing_commands.json");
583        fs::write(&input_file, "def test():\n    /say hello\n").unwrap();
584
585        let error = build(BuildOptions {
586            input: Some(input_file),
587            output: Some(output_dir.clone()),
588            namespace: None,
589            pack_format: None,
590            description: None,
591            verbose: false,
592            quiet: false,
593            zip: false,
594            validate: true,
595            dry_run: false,
596            commands_json,
597        })
598        .unwrap_err();
599
600        assert!(error.contains("Command tree not found"));
601        assert!(error.contains("scripts/setup_commands_json.sh 26.1.2"));
602        assert!(!temp_dir
603            .path()
604            .read_dir()
605            .unwrap()
606            .filter_map(|entry| entry.ok())
607            .any(|entry| entry
608                .file_name()
609                .to_string_lossy()
610                .contains(".output.cobble-staging-")));
611    }
612
613    #[test]
614    fn build_validate_fails_on_invalid_generated_command() {
615        let temp_dir = tempfile::TempDir::new().unwrap();
616        let input_file = temp_dir.path().join("test.cbl");
617        let output_dir = temp_dir.path().join("output");
618        let commands_json = temp_dir.path().join("commands.json");
619        fs::write(&input_file, "def test():\n    /say hello\n").unwrap();
620        fs::write(&commands_json, r#"{"type":"root","children":{}}"#).unwrap();
621
622        let error = build(BuildOptions {
623            input: Some(input_file),
624            output: Some(output_dir.clone()),
625            namespace: None,
626            pack_format: None,
627            description: None,
628            verbose: false,
629            quiet: false,
630            zip: false,
631            validate: true,
632            dry_run: false,
633            commands_json,
634        })
635        .unwrap_err();
636
637        assert!(error.contains("validation error(s) found"));
638        assert!(!output_dir.exists());
639    }
640
641    #[test]
642    fn build_validate_preserves_previous_output_on_validation_failure() {
643        let temp_dir = tempfile::TempDir::new().unwrap();
644        let input_file = temp_dir.path().join("test.cbl");
645        let output_dir = temp_dir.path().join("output");
646        let valid_commands_json = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
647            .join("data")
648            .join("commands.json");
649        if !valid_commands_json.exists() {
650            return;
651        }
652
653        fs::write(&input_file, "def test():\n    /say valid\n").unwrap();
654        build(BuildOptions {
655            input: Some(input_file.clone()),
656            output: Some(output_dir.clone()),
657            namespace: None,
658            pack_format: None,
659            description: None,
660            verbose: false,
661            quiet: false,
662            zip: false,
663            validate: true,
664            dry_run: false,
665            commands_json: valid_commands_json.clone(),
666        })
667        .unwrap();
668
669        fs::write(&input_file, "def test():\n    /titel @a actionbar bad\n").unwrap();
670        let error = build(BuildOptions {
671            input: Some(input_file),
672            output: Some(output_dir.clone()),
673            namespace: None,
674            pack_format: None,
675            description: None,
676            verbose: false,
677            quiet: false,
678            zip: false,
679            validate: true,
680            dry_run: false,
681            commands_json: valid_commands_json,
682        })
683        .unwrap_err();
684
685        assert!(error.contains("validation error(s) found"));
686        let content =
687            fs::read_to_string(output_dir.join("data/cobble/function/test.mcfunction")).unwrap();
688        assert_eq!(content.trim(), "say valid");
689    }
690
691    #[test]
692    fn dry_run_does_not_write_output() {
693        let temp_dir = tempfile::TempDir::new().unwrap();
694        let input_file = temp_dir.path().join("test.cbl");
695        let output_dir = temp_dir.path().join("output");
696        fs::write(&input_file, "def test():\n    /say dry run\n").unwrap();
697
698        build(BuildOptions {
699            input: Some(input_file),
700            output: Some(output_dir.clone()),
701            namespace: Some("dry_run".to_string()),
702            pack_format: None,
703            description: None,
704            verbose: false,
705            quiet: false,
706            zip: false,
707            validate: false,
708            dry_run: true,
709            commands_json: PathBuf::from("data/commands.json"),
710        })
711        .unwrap();
712
713        assert!(!output_dir.exists());
714    }
715
716    #[test]
717    fn quiet_build_succeeds_and_verbose_quiet_is_rejected() {
718        let temp_dir = tempfile::TempDir::new().unwrap();
719        let input_file = temp_dir.path().join("test.cbl");
720        let output_dir = temp_dir.path().join("output");
721        fs::write(&input_file, "def test():\n    /say quiet\n").unwrap();
722
723        build(BuildOptions {
724            input: Some(input_file.clone()),
725            output: Some(output_dir.clone()),
726            namespace: Some("quiet".to_string()),
727            pack_format: None,
728            description: None,
729            verbose: false,
730            quiet: true,
731            zip: false,
732            validate: false,
733            dry_run: false,
734            commands_json: PathBuf::from("data/commands.json"),
735        })
736        .unwrap();
737        assert!(output_dir
738            .join("data/quiet/function/test.mcfunction")
739            .exists());
740
741        let error = build(BuildOptions {
742            input: Some(input_file),
743            output: Some(output_dir),
744            namespace: Some("quiet".to_string()),
745            pack_format: None,
746            description: None,
747            verbose: true,
748            quiet: true,
749            zip: false,
750            validate: false,
751            dry_run: false,
752            commands_json: PathBuf::from("data/commands.json"),
753        })
754        .unwrap_err();
755        assert!(error.contains("--quiet cannot be combined with --verbose"));
756    }
757
758    #[test]
759    fn dry_run_validate_preserves_existing_output_and_cleans_staging() {
760        let temp_dir = tempfile::TempDir::new().unwrap();
761        let input_file = temp_dir.path().join("test.cbl");
762        let output_dir = temp_dir.path().join("output");
763        let valid_commands_json = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
764            .join("data")
765            .join("commands.json");
766        if !valid_commands_json.exists() {
767            return;
768        }
769
770        fs::write(&input_file, "def test():\n    /say valid\n").unwrap();
771        fs::create_dir_all(&output_dir).unwrap();
772        fs::write(output_dir.join("marker.txt"), "keep\n").unwrap();
773
774        build(BuildOptions {
775            input: Some(input_file),
776            output: Some(output_dir.clone()),
777            namespace: Some("dry_run".to_string()),
778            pack_format: None,
779            description: None,
780            verbose: false,
781            quiet: false,
782            zip: false,
783            validate: true,
784            dry_run: true,
785            commands_json: valid_commands_json,
786        })
787        .unwrap();
788
789        assert_eq!(
790            fs::read_to_string(output_dir.join("marker.txt")).unwrap(),
791            "keep\n"
792        );
793        assert!(!temp_dir
794            .path()
795            .read_dir()
796            .unwrap()
797            .filter_map(|entry| entry.ok())
798            .any(|entry| entry
799                .file_name()
800                .to_string_lossy()
801                .contains(".output.cobble-staging-")));
802    }
803
804    #[test]
805    fn build_validate_writes_validation_summary_to_manifest() {
806        let temp_dir = tempfile::TempDir::new().unwrap();
807        let input_file = temp_dir.path().join("test.cbl");
808        let output_dir = temp_dir.path().join("output");
809        let valid_commands_json = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
810            .join("data")
811            .join("commands.json");
812        if !valid_commands_json.exists() {
813            return;
814        }
815
816        fs::write(&input_file, "def test():\n    /say valid\n").unwrap();
817        build(BuildOptions {
818            input: Some(input_file),
819            output: Some(output_dir.clone()),
820            namespace: Some("manifest_validate".to_string()),
821            pack_format: None,
822            description: None,
823            verbose: false,
824            quiet: false,
825            zip: false,
826            validate: true,
827            dry_run: false,
828            commands_json: valid_commands_json,
829        })
830        .unwrap();
831
832        let manifest_path = output_dir.join(".cobble/build_manifest.json");
833        let manifest: serde_json::Value =
834            serde_json::from_str(&fs::read_to_string(manifest_path).unwrap()).unwrap();
835        assert_eq!(manifest["validation"]["enabled"], true);
836        assert_eq!(manifest["validation"]["commands_checked"], 1);
837        assert_eq!(manifest["validation"]["errors"], 0);
838    }
839
840    #[test]
841    fn build_fails_on_missing_import_with_importing_file() {
842        let temp_dir = tempfile::TempDir::new().unwrap();
843        let input_file = temp_dir.path().join("main.cbl");
844        let output_dir = temp_dir.path().join("output");
845        fs::write(&input_file, "import missing\n\ndef test():\n    pass\n").unwrap();
846
847        let error = build(BuildOptions {
848            input: Some(input_file.clone()),
849            output: Some(output_dir),
850            namespace: None,
851            pack_format: None,
852            description: None,
853            verbose: false,
854            quiet: false,
855            zip: false,
856            validate: false,
857            dry_run: false,
858            commands_json: PathBuf::from("data/commands.json"),
859        })
860        .unwrap_err();
861
862        assert!(error.contains("Cannot import 'missing'"));
863        assert!(error.contains(&input_file.display().to_string()));
864    }
865
866    #[test]
867    fn build_fails_on_import_cycle_with_chain() {
868        let temp_dir = tempfile::TempDir::new().unwrap();
869        let main_file = temp_dir.path().join("main.cbl");
870        let helper_file = temp_dir.path().join("helper.cbl");
871        let output_dir = temp_dir.path().join("output");
872        fs::write(&main_file, "import helper\n\ndef main():\n    /say main\n").unwrap();
873        fs::write(
874            &helper_file,
875            "import main\n\ndef helper():\n    /say helper\n",
876        )
877        .unwrap();
878
879        let error = build(BuildOptions {
880            input: Some(main_file.clone()),
881            output: Some(output_dir),
882            namespace: None,
883            pack_format: None,
884            description: None,
885            verbose: false,
886            quiet: false,
887            zip: false,
888            validate: false,
889            dry_run: false,
890            commands_json: PathBuf::from("data/commands.json"),
891        })
892        .unwrap_err();
893
894        assert!(error.contains("Circular import detected"));
895        assert!(error.contains(&main_file.display().to_string()));
896        assert!(error.contains(&helper_file.display().to_string()));
897    }
898
899    #[test]
900    fn cli_pack_format_overrides_invalid_config_value() {
901        let temp_dir = tempfile::TempDir::new().unwrap();
902        let source_dir = temp_dir.path().join("src");
903        let output_dir = temp_dir.path().join("output");
904        fs::create_dir_all(&source_dir).unwrap();
905        fs::write(source_dir.join("main.cbl"), "def main():\n    /say hi\n").unwrap();
906        fs::write(
907            temp_dir.path().join("cobble.toml"),
908            r#"
909[project]
910name = "Override"
911description = "Override"
912namespace = "override"
913pack_format = "18"
914
915[build]
916source = "src"
917output = "output"
918"#,
919        )
920        .unwrap();
921
922        build(BuildOptions {
923            input: Some(source_dir),
924            output: Some(output_dir.clone()),
925            namespace: None,
926            pack_format: Some("101.1".to_string()),
927            description: None,
928            verbose: false,
929            quiet: false,
930            zip: false,
931            validate: false,
932            dry_run: false,
933            commands_json: PathBuf::from("data/commands.json"),
934        })
935        .unwrap();
936
937        assert!(output_dir
938            .join("data/override/function/main.mcfunction")
939            .exists());
940    }
941
942    #[test]
943    fn empty_source_directory_cleans_previous_output() {
944        let temp_dir = tempfile::TempDir::new().unwrap();
945        let source_dir = temp_dir.path().join("src");
946        let output_dir = temp_dir.path().join("output");
947        let input_file = source_dir.join("main.cbl");
948        fs::create_dir_all(&source_dir).unwrap();
949        fs::write(&input_file, "def main():\n    /say hi\n").unwrap();
950
951        let build_once = || {
952            build(BuildOptions {
953                input: Some(source_dir.clone()),
954                output: Some(output_dir.clone()),
955                namespace: Some("stale".to_string()),
956                pack_format: None,
957                description: None,
958                verbose: false,
959                quiet: false,
960                zip: false,
961                validate: false,
962                dry_run: false,
963                commands_json: PathBuf::from("data/commands.json"),
964            })
965        };
966
967        build_once().unwrap();
968        assert!(output_dir
969            .join("data/stale/function/main.mcfunction")
970            .exists());
971
972        fs::remove_file(input_file).unwrap();
973        let error = build_once().unwrap_err();
974
975        assert!(error.contains("No Cobble files found"));
976        assert!(!output_dir
977            .join("data/stale/function/main.mcfunction")
978            .exists());
979    }
980
981    #[test]
982    fn zip_contains_only_datapack_files_when_output_is_current_dir() {
983        let temp_dir = tempfile::TempDir::new().unwrap();
984        let _lock = CWD_LOCK.lock().unwrap();
985        let _guard = CurrentDirGuard::push(temp_dir.path());
986        fs::write("main.cbl", "def main():\n    /say hi\n").unwrap();
987
988        build(BuildOptions {
989            input: Some(PathBuf::from("main.cbl")),
990            output: Some(PathBuf::from(".")),
991            namespace: Some("zipped".to_string()),
992            pack_format: None,
993            description: None,
994            verbose: false,
995            quiet: false,
996            zip: true,
997            validate: false,
998            dry_run: false,
999            commands_json: PathBuf::from("data/commands.json"),
1000        })
1001        .unwrap();
1002
1003        let zip_file = fs::File::open(temp_dir.path().join("zipped.zip")).unwrap();
1004        let mut archive = zip::ZipArchive::new(zip_file).unwrap();
1005        let names: Vec<String> = (0..archive.len())
1006            .map(|index| archive.by_index(index).unwrap().name().to_string())
1007            .collect();
1008
1009        assert!(names.iter().any(|name| name == "pack.mcmeta"));
1010        assert!(names
1011            .iter()
1012            .any(|name| name == "data/zipped/function/main.mcfunction"));
1013        assert!(!names.iter().any(|name| name == "main.cbl"));
1014        assert!(!names.iter().any(|name| name == "zipped.zip"));
1015        assert!(!names.iter().any(|name| name.starts_with(".cobble/")));
1016    }
1017}
1018
1019fn create_zip(output_dir: &Path, namespace: &str) -> Result<PathBuf, String> {
1020    let zip_path = output_dir.with_file_name(format!("{}.zip", namespace));
1021    let file =
1022        fs::File::create(&zip_path).map_err(|e| format!("Failed to create zip file: {}", e))?;
1023
1024    let mut zip = ZipWriter::new(file);
1025    let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
1026
1027    // Add all files from output directory to zip
1028    for entry in WalkDir::new(output_dir).into_iter().filter_map(|e| e.ok()) {
1029        let path = entry.path();
1030        if path.is_file() {
1031            let relative_path = path
1032                .strip_prefix(output_dir)
1033                .map_err(|e| format!("Failed to get relative path: {}", e))?;
1034
1035            // Convert path to use forward slashes for ZIP (required by Minecraft)
1036            let zip_path = relative_path.to_string_lossy().replace('\\', "/");
1037            if zip_path != "pack.mcmeta" && !zip_path.starts_with("data/") {
1038                continue;
1039            }
1040
1041            let file_data =
1042                fs::read(path).map_err(|e| format!("Failed to read file for zip: {}", e))?;
1043
1044            zip.start_file(zip_path, options)
1045                .map_err(|e| format!("Failed to add file to zip: {}", e))?;
1046
1047            zip.write_all(&file_data)
1048                .map_err(|e| format!("Failed to write file to zip: {}", e))?;
1049        }
1050    }
1051
1052    zip.finish()
1053        .map_err(|e| format!("Failed to finalize zip: {}", e))?;
1054
1055    Ok(zip_path)
1056}