1use crate::pack_format::SUPPORTED_MINECRAFT_VERSION;
2use crate::transpiler::SourceMap;
3use crate::validator::CommandValidator;
4use crate::validator::ValidationReport;
5use sha1::{Digest, Sha1};
6use std::collections::{HashMap, HashSet};
7use std::fs;
8use std::io::Read;
9use std::path::{Component, Path, PathBuf};
10use std::process::Command;
11use walkdir::WalkDir;
12
13const VERSION_MANIFEST_URLS: &[&str] = &[
14 "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json",
15 "https://launchermeta.mojang.com/mc/game/version_manifest_v2.json",
16 "https://piston-meta.mojang.com/mc/game/version_manifest.json",
17 "https://launchermeta.mojang.com/mc/game/version_manifest.json",
18];
19
20const SUPPORTED_SERVER_JAR_URL: &str =
21 "https://piston-data.mojang.com/v1/objects/97ccd4c0ed3f81bbb7bfacddd1090b0c56f9bc51/server.jar";
22const SUPPORTED_SERVER_JAR_SHA1: &str = "97ccd4c0ed3f81bbb7bfacddd1090b0c56f9bc51";
23pub const SUPPORTED_COMMANDS_JSON_SHA1: &str = "18bb0eb6768838b2237821418aa5832d1c837d45";
24
25pub struct ValidateOptions {
26 pub input: PathBuf,
27 pub commands_json: PathBuf,
28}
29
30pub fn validate(options: ValidateOptions) -> Result<(), String> {
31 let report = run_validation(&options.input, &options.commands_json)?;
32 print_validation_report(&report, &options.commands_json, &options.input);
33
34 if report.errors.is_empty() && report.source_map_errors.is_empty() {
35 println!("All commands valid");
36 Ok(())
37 } else {
38 Err(format!(
39 "{} validation error(s) found",
40 report.errors.len() + report.source_map_errors.len()
41 ))
42 }
43}
44
45pub fn run_validation(input: &Path, commands_json: &Path) -> Result<ValidationReport, String> {
46 ensure_commands_json(commands_json)?;
47
48 let validator = CommandValidator::from_file(commands_json)?;
49 let mut report = validator.validate_datapack(input);
50 report.source_map_errors = validate_source_map(input);
51 Ok(report)
52}
53
54fn ensure_commands_json(commands_json: &Path) -> Result<(), String> {
55 if commands_json.exists() {
56 return verify_default_commands_json_fingerprint(commands_json);
57 }
58
59 if !is_auto_download_commands_json_path(commands_json) {
60 return Err(missing_command_tree_error(commands_json));
61 }
62
63 if std::env::var("COBBLE_COMMANDS_JSON_AUTO_DOWNLOAD").as_deref() == Ok("0") {
64 return Err(format!(
65 "{}\nAutomatic download is disabled by COBBLE_COMMANDS_JSON_AUTO_DOWNLOAD=0.",
66 missing_command_tree_error(commands_json)
67 ));
68 }
69
70 generate_commands_json(commands_json)?;
71 verify_default_commands_json_fingerprint(commands_json)
72}
73
74fn verify_default_commands_json_fingerprint(commands_json: &Path) -> Result<(), String> {
75 if !is_auto_download_commands_json_path(commands_json) {
76 return Ok(());
77 }
78
79 verify_commands_json_sha1(commands_json, SUPPORTED_COMMANDS_JSON_SHA1).map_err(|error| {
80 format!(
81 "{}\n\
82 The default command tree must match Minecraft Java Edition {}. Remove {} and rerun validation to regenerate it, or pass --commands-json with a deliberate custom tree.",
83 error,
84 SUPPORTED_MINECRAFT_VERSION,
85 commands_json.display()
86 )
87 })
88}
89
90fn verify_commands_json_sha1(commands_json: &Path, expected_sha1: &str) -> Result<(), String> {
91 let actual_sha1 = sha1_file(commands_json)?;
92 if actual_sha1.eq_ignore_ascii_case(expected_sha1) {
93 return Ok(());
94 }
95
96 Err(format!(
97 "Command tree SHA-1 mismatch for {}: expected {}, got {}",
98 commands_json.display(),
99 expected_sha1,
100 actual_sha1
101 ))
102}
103
104fn missing_command_tree_error(commands_json: &Path) -> String {
105 format!(
106 "Command tree not found: {}\n\
107 Cobble can auto-generate the default data/commands.json path during validation.\n\
108 For custom paths, generate data/commands.json with scripts/setup_commands_json.sh {} and copy it to the requested path.\n\
109 Default path: data/commands.json",
110 commands_json.display(),
111 SUPPORTED_MINECRAFT_VERSION
112 )
113}
114
115fn is_auto_download_commands_json_path(commands_json: &Path) -> bool {
116 commands_json == Path::new("data/commands.json")
117}
118
119fn generate_commands_json(commands_json: &Path) -> Result<(), String> {
120 let output_dir = commands_json.parent().unwrap_or_else(|| Path::new("."));
121 fs::create_dir_all(output_dir).map_err(|e| {
122 format!(
123 "Failed to create commands.json directory {}: {}",
124 output_dir.display(),
125 e
126 )
127 })?;
128
129 let stamp = std::time::SystemTime::now()
130 .duration_since(std::time::UNIX_EPOCH)
131 .map_err(|e| {
132 format!(
133 "System clock error while creating command tree cache: {}",
134 e
135 )
136 })?
137 .as_nanos();
138 let work_dir = output_dir.join(format!(".commands-json-{}-{}", std::process::id(), stamp));
139 fs::create_dir_all(&work_dir).map_err(|e| {
140 format!(
141 "Failed to create temporary command tree directory {}: {}",
142 work_dir.display(),
143 e
144 )
145 })?;
146
147 println!(
148 "Command tree not found at {}; generating Minecraft {} commands.json...",
149 commands_json.display(),
150 SUPPORTED_MINECRAFT_VERSION
151 );
152
153 let result = generate_commands_json_in(&work_dir, commands_json);
154 let cleanup_result = fs::remove_dir_all(&work_dir);
155 if let Err(error) = cleanup_result {
156 eprintln!(
157 "Warning: failed to remove temporary command tree directory {}: {}",
158 work_dir.display(),
159 error
160 );
161 }
162 result
163}
164
165fn generate_commands_json_in(work_dir: &Path, commands_json: &Path) -> Result<(), String> {
166 if let Ok(commands_url) = std::env::var("COBBLE_COMMANDS_JSON_URL") {
167 download_ready_made_commands_json(&commands_url, commands_json)?;
168 return Ok(());
169 }
170
171 let server_jar = work_dir.join("server.jar");
172 let expected_sha1 = prepare_server_jar(&server_jar)?;
173 verify_sha1(&server_jar, &expected_sha1)?;
174 let server_jar = server_jar.canonicalize().map_err(|e| {
175 format!(
176 "Failed to resolve Minecraft server jar path {}: {}",
177 server_jar.display(),
178 e
179 )
180 })?;
181
182 run_command(
183 Command::new("java")
184 .arg("-DbundlerMainClass=net.minecraft.data.Main")
185 .arg("-jar")
186 .arg(&server_jar)
187 .arg("--reports")
188 .current_dir(work_dir),
189 "generate Minecraft command reports",
190 )?;
191
192 let generated_commands = find_generated_commands_json(work_dir).ok_or_else(|| {
193 format!(
194 "Minecraft server reports did not generate commands.json under {}",
195 work_dir.display()
196 )
197 })?;
198
199 let partial = commands_json.with_file_name("commands.json.part");
200 let _ = fs::remove_file(&partial);
201 fs::copy(&generated_commands, &partial).map_err(|e| {
202 format!(
203 "Failed to copy generated commands.json from {} to {}: {}",
204 generated_commands.display(),
205 partial.display(),
206 e
207 )
208 })?;
209 fs::rename(&partial, commands_json).map_err(|e| {
210 format!(
211 "Failed to move generated commands.json to {}: {}",
212 commands_json.display(),
213 e
214 )
215 })?;
216
217 println!("Generated commands.json at {}", commands_json.display());
218 Ok(())
219}
220
221fn download_ready_made_commands_json(url: &str, commands_json: &Path) -> Result<(), String> {
222 println!("Downloading commands.json from COBBLE_COMMANDS_JSON_URL...");
223 let partial = commands_json.with_file_name("commands.json.part");
224 let _ = fs::remove_file(&partial);
225 run_command(
226 Command::new("curl")
227 .args(["-fsSL", "--retry", "3", "-o"])
228 .arg(&partial)
229 .arg(url),
230 "download commands.json",
231 )?;
232 fs::rename(&partial, commands_json).map_err(|e| {
233 format!(
234 "Failed to move downloaded commands.json to {}: {}",
235 commands_json.display(),
236 e
237 )
238 })?;
239 println!("Downloaded commands.json at {}", commands_json.display());
240 Ok(())
241}
242
243fn prepare_server_jar(server_jar: &Path) -> Result<String, String> {
244 let override_sha1 = std::env::var("COBBLE_MINECRAFT_SERVER_SHA1").ok();
245
246 if let Ok(local_jar) = std::env::var("COBBLE_MINECRAFT_SERVER_JAR") {
247 let local_jar = PathBuf::from(local_jar);
248 if !local_jar.exists() {
249 return Err(format!(
250 "COBBLE_MINECRAFT_SERVER_JAR points to a missing file: {}",
251 local_jar.display()
252 ));
253 }
254 fs::copy(&local_jar, server_jar).map_err(|e| {
255 format!(
256 "Failed to copy local Minecraft server jar from {} to {}: {}",
257 local_jar.display(),
258 server_jar.display(),
259 e
260 )
261 })?;
262 return Ok(override_sha1.unwrap_or_else(|| SUPPORTED_SERVER_JAR_SHA1.to_string()));
263 }
264
265 let (server_url, expected_sha1) = if let Ok(url) = std::env::var("COBBLE_MINECRAFT_SERVER_URL")
266 {
267 (
268 url,
269 override_sha1.unwrap_or_else(|| SUPPORTED_SERVER_JAR_SHA1.to_string()),
270 )
271 } else {
272 resolve_server_jar_url()?
273 };
274
275 run_command(
276 Command::new("curl")
277 .args(["-fsSL", "--retry", "3", "-o"])
278 .arg(server_jar)
279 .arg(&server_url),
280 "download Minecraft server.jar",
281 )?;
282 Ok(expected_sha1)
283}
284
285fn resolve_server_jar_url() -> Result<(String, String), String> {
286 let mut errors = Vec::new();
287 for manifest_url in VERSION_MANIFEST_URLS {
288 let manifest = match curl_json(manifest_url) {
289 Ok(manifest) => manifest,
290 Err(error) => {
291 errors.push(format!("{}: {}", manifest_url, error));
292 continue;
293 }
294 };
295
296 let Some(version_url) = manifest
297 .get("versions")
298 .and_then(|value| value.as_array())
299 .and_then(|versions| {
300 versions.iter().find_map(|version| {
301 if version.get("id").and_then(|value| value.as_str())
302 == Some(SUPPORTED_MINECRAFT_VERSION)
303 {
304 version.get("url").and_then(|value| value.as_str())
305 } else {
306 None
307 }
308 })
309 })
310 else {
311 errors.push(format!(
312 "{}: Minecraft version {} was not found",
313 manifest_url, SUPPORTED_MINECRAFT_VERSION
314 ));
315 continue;
316 };
317
318 let version_info = match curl_json(version_url) {
319 Ok(version_info) => version_info,
320 Err(error) => {
321 errors.push(format!("{}: {}", version_url, error));
322 continue;
323 }
324 };
325
326 let server_url = version_info
327 .pointer("/downloads/server/url")
328 .and_then(|value| value.as_str());
329 let server_sha1 = version_info
330 .pointer("/downloads/server/sha1")
331 .and_then(|value| value.as_str());
332
333 if let (Some(server_url), Some(server_sha1)) = (server_url, server_sha1) {
334 return Ok((server_url.to_string(), server_sha1.to_string()));
335 }
336
337 errors.push(format!(
338 "{}: metadata does not include a server download URL and SHA-1",
339 version_url
340 ));
341 }
342
343 eprintln!(
344 "Warning: failed to resolve Minecraft server jar from manifests; using pinned {} direct server.jar URL.",
345 SUPPORTED_MINECRAFT_VERSION
346 );
347 for error in &errors {
348 eprintln!(" - {}", error);
349 }
350 Ok((
351 SUPPORTED_SERVER_JAR_URL.to_string(),
352 SUPPORTED_SERVER_JAR_SHA1.to_string(),
353 ))
354}
355
356fn verify_sha1(path: &Path, expected_sha1: &str) -> Result<(), String> {
357 let actual_sha1 = sha1_file(path)?;
358 if actual_sha1.eq_ignore_ascii_case(expected_sha1) {
359 return Ok(());
360 }
361
362 let _ = fs::remove_file(path);
363 Err(format!(
364 "Downloaded Minecraft server jar failed SHA-1 verification: expected {}, got {}",
365 expected_sha1, actual_sha1
366 ))
367}
368
369fn sha1_file(path: &Path) -> Result<String, String> {
370 let mut file = fs::File::open(path).map_err(|e| {
371 format!(
372 "Failed to open {} for SHA-1 verification: {}",
373 path.display(),
374 e
375 )
376 })?;
377 let mut hasher = Sha1::new();
378 let mut buffer = [0_u8; 8192];
379 loop {
380 let read = file.read(&mut buffer).map_err(|e| {
381 format!(
382 "Failed to read {} for SHA-1 verification: {}",
383 path.display(),
384 e
385 )
386 })?;
387 if read == 0 {
388 break;
389 }
390 hasher.update(&buffer[..read]);
391 }
392 Ok(format!("{:x}", hasher.finalize()))
393}
394
395fn curl_json(url: &str) -> Result<serde_json::Value, String> {
396 let output = Command::new("curl")
397 .args(["-fsSL", "--retry", "3", url])
398 .output()
399 .map_err(|e| format!("Failed to execute curl while fetching {}: {}", url, e))?;
400 if !output.status.success() {
401 return Err(format!(
402 "curl failed while fetching {}: {}",
403 url,
404 String::from_utf8_lossy(&output.stderr)
405 ));
406 }
407 serde_json::from_slice(&output.stdout)
408 .map_err(|e| format!("Failed to parse JSON response from {}: {}", url, e))
409}
410
411fn run_command(command: &mut Command, description: &str) -> Result<(), String> {
412 let output = command
413 .output()
414 .map_err(|e| format!("Failed to execute command to {}: {}", description, e))?;
415 if output.status.success() {
416 return Ok(());
417 }
418
419 let stderr = String::from_utf8_lossy(&output.stderr);
420 let stdout = String::from_utf8_lossy(&output.stdout);
421 Err(format!(
422 "Failed to {}.\nstdout:\n{}\nstderr:\n{}",
423 description, stdout, stderr
424 ))
425}
426
427fn find_generated_commands_json(work_dir: &Path) -> Option<PathBuf> {
428 WalkDir::new(work_dir)
429 .follow_links(false)
430 .into_iter()
431 .filter_map(|entry| entry.ok())
432 .map(|entry| entry.into_path())
433 .find(|path| {
434 path.is_file()
435 && path.file_name().and_then(|name| name.to_str()) == Some("commands.json")
436 && path
437 .components()
438 .any(|component| component.as_os_str() == "reports")
439 })
440}
441
442pub fn print_validation_report(
443 report: &ValidationReport,
444 commands_json: &Path,
445 datapack_dir: &Path,
446) {
447 let source_map = load_source_map(datapack_dir);
448 for (file, error) in &report.errors {
449 eprintln!(
450 "{}:{}: {}",
451 file.display(),
452 error.line_number,
453 error.message
454 );
455 eprintln!(" | {}", error.command);
456 if error.position <= error.command.chars().count() {
457 eprintln!(" | {}^", " ".repeat(error.position));
458 }
459 if let Some(entry) = source_map.get(&source_map_key(datapack_dir, file, error.line_number))
460 {
461 if let Some(source) = &entry.source {
462 eprintln!(
463 " = source: {}:{}:{} ({:?})",
464 source.file.display(),
465 source.line,
466 source.column,
467 entry.kind
468 );
469 }
470 }
471 }
472 for error in &report.source_map_errors {
473 eprintln!("source map: {}", error);
474 }
475
476 println!(
477 "Checked {} commands in {} files ({} macro commands checked, {} skipped) using {}",
478 report.commands_checked,
479 report.files_checked,
480 report.macro_commands_checked,
481 report.commands_skipped,
482 commands_json.display()
483 );
484}
485
486fn validate_source_map(datapack_dir: &Path) -> Vec<String> {
487 let path = datapack_dir.join(".cobble").join("source_map.json");
488 let Ok(content) = std::fs::read_to_string(&path) else {
489 return Vec::new();
490 };
491 let Ok(source_map) = serde_json::from_str::<SourceMap>(&content) else {
492 return vec![format!("failed to parse {}", path.display())];
493 };
494
495 let mut errors = Vec::new();
496 let mut mapped_lines = HashSet::new();
497
498 for entry in source_map.entries {
499 let Some(generated_path) = clean_source_map_generated_path(&entry.generated_path) else {
500 errors.push(format!(
501 "{}:{} has invalid generated_path outside the data pack",
502 entry.generated_path, entry.generated_line
503 ));
504 continue;
505 };
506 let generated_path_key = generated_path.to_string_lossy().replace('\\', "/");
507 let generated_file = datapack_dir.join(&generated_path);
508 mapped_lines.insert((generated_path_key.clone(), entry.generated_line));
509 if !is_regular_file_no_symlink(&generated_file) {
510 errors.push(format!(
511 "{}:{} maps to missing or non-regular file",
512 generated_path_key, entry.generated_line
513 ));
514 continue;
515 }
516 let Ok(file_content) = std::fs::read_to_string(&generated_file) else {
517 errors.push(format!(
518 "{}:{} maps to missing file",
519 generated_path_key, entry.generated_line
520 ));
521 continue;
522 };
523 let actual = file_content
524 .lines()
525 .nth(entry.generated_line.saturating_sub(1));
526 match actual {
527 Some(actual) if actual == entry.command => {}
528 Some(actual) => errors.push(format!(
529 "{}:{} command mismatch: map='{}' file='{}'",
530 generated_path_key, entry.generated_line, entry.command, actual
531 )),
532 None => errors.push(format!(
533 "{}:{} maps past end of file",
534 generated_path_key, entry.generated_line
535 )),
536 }
537 }
538
539 for entry in WalkDir::new(datapack_dir)
540 .follow_links(false)
541 .into_iter()
542 .filter_map(|entry| entry.ok())
543 {
544 let path = entry.path();
545 if !entry.file_type().is_file()
546 || path.extension().and_then(|ext| ext.to_str()) != Some("mcfunction")
547 {
548 continue;
549 }
550 let relative = path
551 .strip_prefix(datapack_dir)
552 .unwrap_or(path)
553 .to_string_lossy()
554 .replace('\\', "/");
555 if let Ok(content) = std::fs::read_to_string(path) {
556 for (index, line) in content.lines().enumerate() {
557 if line.trim().is_empty() || line.trim_start().starts_with('#') {
558 continue;
559 }
560 let generated_line = index + 1;
561 if !mapped_lines.contains(&(relative.clone(), generated_line)) {
562 errors.push(format!(
563 "{}:{} has no source map entry",
564 relative, generated_line
565 ));
566 }
567 }
568 }
569 }
570
571 errors
572}
573
574fn is_regular_file_no_symlink(path: &Path) -> bool {
575 std::fs::symlink_metadata(path)
576 .map(|metadata| metadata.file_type().is_file())
577 .unwrap_or(false)
578}
579
580fn clean_source_map_generated_path(generated_path: &str) -> Option<PathBuf> {
581 let path = Path::new(generated_path);
582 if path.is_absolute() {
583 return None;
584 }
585
586 let mut clean = PathBuf::new();
587 for component in path.components() {
588 match component {
589 Component::Normal(part) => clean.push(part),
590 _ => return None,
591 }
592 }
593
594 if clean.as_os_str().is_empty() {
595 None
596 } else {
597 Some(clean)
598 }
599}
600
601fn load_source_map(
602 datapack_dir: &Path,
603) -> HashMap<(String, usize), crate::transpiler::SourceMapEntry> {
604 let path = datapack_dir.join(".cobble").join("source_map.json");
605 let Ok(content) = std::fs::read_to_string(path) else {
606 return HashMap::new();
607 };
608 let Ok(source_map) = serde_json::from_str::<SourceMap>(&content) else {
609 return HashMap::new();
610 };
611
612 source_map
613 .entries
614 .into_iter()
615 .map(|entry| ((entry.generated_path.clone(), entry.generated_line), entry))
616 .collect()
617}
618
619fn source_map_key(datapack_dir: &Path, generated_file: &Path, line: usize) -> (String, usize) {
620 let relative = generated_file
621 .strip_prefix(datapack_dir)
622 .unwrap_or(generated_file)
623 .to_string_lossy()
624 .replace('\\', "/");
625 (relative, line)
626}
627
628#[cfg(test)]
629mod tests {
630 use super::*;
631
632 #[test]
633 fn auto_download_only_applies_to_default_commands_json_paths() {
634 assert!(is_auto_download_commands_json_path(Path::new(
635 "data/commands.json"
636 )));
637 assert!(!is_auto_download_commands_json_path(Path::new(
638 "../../data/commands.json"
639 )));
640 assert!(!is_auto_download_commands_json_path(Path::new(
641 "/tmp/project/data/commands.json"
642 )));
643
644 assert!(!is_auto_download_commands_json_path(Path::new(
645 "commands.json"
646 )));
647 assert!(!is_auto_download_commands_json_path(Path::new(
648 "data/missing_commands.json"
649 )));
650 assert!(!is_auto_download_commands_json_path(Path::new(
651 "custom/commands.json"
652 )));
653 }
654
655 #[test]
656 fn missing_custom_commands_json_path_reports_manual_setup() {
657 let temp_dir = tempfile::TempDir::new().unwrap();
658 let input_dir = temp_dir.path().join("pack");
659 let commands_json = temp_dir.path().join("missing_commands.json");
660 fs::create_dir_all(&input_dir).unwrap();
661
662 let error = run_validation(&input_dir, &commands_json).unwrap_err();
663
664 assert!(error.contains("Command tree not found"));
665 assert!(error.contains("scripts/setup_commands_json.sh"));
666 assert!(!commands_json.exists());
667 }
668
669 #[test]
670 fn command_tree_fingerprint_rejects_wrong_sha1() {
671 let temp_dir = tempfile::TempDir::new().unwrap();
672 let commands_json = temp_dir.path().join("commands.json");
673 fs::write(&commands_json, "{}").unwrap();
674
675 let error =
676 verify_commands_json_sha1(&commands_json, SUPPORTED_COMMANDS_JSON_SHA1).unwrap_err();
677
678 assert!(error.contains("Command tree SHA-1 mismatch"));
679 }
680
681 #[test]
682 fn run_validation_accepts_custom_command_tree_without_supported_fingerprint() {
683 let temp_dir = tempfile::TempDir::new().unwrap();
684 let input_dir = temp_dir.path().join("pack");
685 let commands_json = temp_dir.path().join("custom_commands.json");
686 fs::create_dir_all(&input_dir).unwrap();
687 fs::write(&commands_json, r#"{"type":"root","children":{}}"#).unwrap();
688
689 let report = run_validation(&input_dir, &commands_json).unwrap();
690
691 assert_eq!(report.files_checked, 0);
692 assert!(report.errors.is_empty());
693 assert!(report.source_map_errors.is_empty());
694 }
695
696 #[cfg(unix)]
697 #[test]
698 fn download_ready_made_commands_json_accepts_local_fixture_url() {
699 if Command::new("curl").arg("--version").output().is_err() {
700 eprintln!("Skipping commands.json download fixture test: curl not available");
701 return;
702 }
703
704 let temp_dir = tempfile::TempDir::new().unwrap();
705 let fixture = temp_dir.path().join("fixture_commands.json");
706 let commands_json = temp_dir.path().join("commands.json");
707 let fixture_content = r#"{"type":"root","children":{}}"#;
708 fs::write(&fixture, fixture_content).unwrap();
709
710 download_ready_made_commands_json(&format!("file://{}", fixture.display()), &commands_json)
711 .unwrap();
712
713 assert_eq!(fs::read_to_string(commands_json).unwrap(), fixture_content);
714 assert!(!temp_dir.path().join("commands.json.part").exists());
715 }
716}