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 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 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 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 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 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 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 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 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 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 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
405fn 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 let Some(parent) = path.parent() {
475 return CobbleConfig::find_in_path(parent);
476 }
477 } else {
478 return CobbleConfig::find_in_path(path);
480 }
481 }
482 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) .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 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 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}