1use std::path::{Path, PathBuf};
2use std::str::FromStr;
3
4use clap::{Args, Subcommand};
5use qualia_core_db::wgsl_forge::execute::WgpuComputeContext;
6use qualia_core_db::wgsl_forge::{
7 candidate_evaluation, generate_builtin, resolve_execution_backend, tune_with, validate_native,
8 validate_wgsl, AdapterConstraints, BuiltinKernel, CertificationManifest, ForgeError,
9 ManifestCache, Schedule, ScheduleSpace, TargetBackend, TuningConfig, TuningManifest,
10};
11
12#[derive(Debug, Clone, Args)]
13pub struct ScheduleArgs {
14 #[arg(long, default_value_t = 64)]
16 pub workgroup: u32,
17 #[arg(long, default_value_t = 1)]
19 pub items: u32,
20 #[arg(long, default_value_t = 1)]
22 pub vector_width: u32,
23}
24
25impl ScheduleArgs {
26 fn schedule(&self) -> Schedule {
27 Schedule {
28 workgroup_size: self.workgroup,
29 items_per_invocation: self.items,
30 vector_width: self.vector_width,
31 ..Default::default()
32 }
33 }
34}
35
36#[derive(Debug, Subcommand)]
37pub enum ShaderAction {
38 ListKernels,
40 Doctor,
43 Roofline {
45 #[arg(default_value = "affine-f32")]
46 kernel: String,
47 #[arg(long, default_value_t = 65_536)]
49 n: u64,
50 #[arg(long)]
51 json: bool,
52 },
53 ProfileHardware {
55 #[arg(long)]
57 export: Option<PathBuf>,
58 #[arg(long)]
59 json: bool,
60 },
61 Generate {
63 #[arg(default_value = "affine-f32")]
64 kernel: String,
65 #[arg(long, default_value = "wgsl")]
67 target: String,
68 #[command(flatten)]
69 schedule: ScheduleArgs,
70 #[arg(long)]
72 out: Option<PathBuf>,
73 #[arg(long)]
75 json: bool,
76 },
77 Validate {
79 #[arg(long)]
81 input: Option<PathBuf>,
82 #[arg(default_value = "affine-f32")]
83 kernel: String,
84 #[arg(long, default_value = "wgsl")]
86 target: String,
87 #[command(flatten)]
88 schedule: ScheduleArgs,
89 #[arg(long)]
91 manifest: Option<PathBuf>,
92 #[arg(long)]
93 json: bool,
94 },
95 Certify {
97 #[arg(default_value = "affine-f32")]
98 kernel: String,
99 #[command(flatten)]
100 schedule: ScheduleArgs,
101 #[arg(long, default_value_t = 4_099)]
102 length: usize,
103 #[arg(long, default_value_t = 3)]
104 warmups: usize,
105 #[arg(long, default_value_t = 9)]
106 samples: usize,
107 #[arg(long)]
108 manifest: Option<PathBuf>,
109 #[arg(long)]
111 cache_dir: Option<PathBuf>,
112 #[arg(long)]
114 dry_run: bool,
115 },
116 Tune {
118 #[arg(default_value = "affine-f32")]
119 kernel: String,
120 #[arg(long, default_value_t = 65_537)]
121 length: usize,
122 #[arg(long, default_value_t = 2)]
123 warmups: usize,
124 #[arg(long, default_value_t = 3)]
125 initial_samples: usize,
126 #[arg(long, default_value_t = 11)]
127 finalist_samples: usize,
128 #[arg(long, default_value_t = 6)]
129 finalists: usize,
130 #[arg(long, default_value_t = 48)]
131 max_candidates: usize,
132 #[arg(long)]
133 manifest: Option<PathBuf>,
134 #[arg(long)]
136 cache_dir: Option<PathBuf>,
137 #[arg(long)]
139 dry_run: bool,
140 },
141 AutoTuneAll {
143 #[arg(long, default_value_t = 65_537)]
144 length: usize,
145 #[arg(long, default_value_t = 2)]
146 warmups: usize,
147 #[arg(long, default_value_t = 24)]
148 max_candidates: usize,
149 #[arg(long)]
152 cache_dir: Option<PathBuf>,
153 #[arg(long)]
155 update_local_manifest: bool,
156 #[arg(long)]
158 dry_run: bool,
159 #[arg(long)]
164 budget_ms: Option<u64>,
165 #[arg(long)]
170 thermal_limit: Option<f32>,
171 },
172}
173
174pub fn run(action: &ShaderAction) -> Result<(), Box<dyn std::error::Error>> {
175 match action {
176 ShaderAction::ListKernels => {
177 println!("Qualia WGSL Forge kernels:");
178 for builtin in BuiltinKernel::ALL {
179 let spec = builtin.spec();
180 println!(
181 " {:<16} v{} {}",
182 builtin.name(),
183 spec.semantic_version,
184 spec.description
185 );
186 }
187 }
188 ShaderAction::Roofline { kernel, n, json } => {
189 let builtin = parse_kernel(kernel)?;
190 let estimate = qualia_core_db::wgsl_forge::roofline_for(builtin, *n);
191 if *json {
192 println!("{}", serde_json::to_string_pretty(&estimate)?);
193 } else {
194 println!(
195 "{} @ n={}: {} FLOP / {} bytes -> {:.3} FLOP/byte ({:?}-bound)",
196 builtin.name(),
197 n,
198 estimate.flops,
199 estimate.bytes,
200 estimate.arithmetic_intensity,
201 estimate.bound
202 );
203 }
204 }
205 ShaderAction::Doctor => {
206 use std::process::Command;
207 println!("Qualia WGSL Forge — environment doctor\n");
208
209 match WgpuComputeContext::new(1024 * 1024) {
210 Ok(runner) => println!(
211 "[ok] wgpu adapter: {} ({})",
212 runner.adapter.name, runner.adapter.backend
213 ),
214 Err(error) => println!(
215 "[warn] wgpu adapter: none ({error}); generation/validation still work headless"
216 ),
217 }
218
219 let dxc = std::env::var("QUALIA_DXC_PATH").unwrap_or_else(|_| "dxc".to_string());
220 match Command::new(&dxc).arg("--version").output() {
221 Ok(out) if out.status.success() => {
222 println!("[ok] DXC (HLSL->SPIR-V/DXIL): {dxc}");
223 }
224 _ => println!(
225 "[warn] DXC not found — set QUALIA_DXC_PATH or add dxc to PATH.\n HLSL native path disabled; get DXC: https://github.com/microsoft/DirectXShaderCompiler/releases"
226 ),
227 }
228
229 let nvcc = std::env::var("CUDA_PATH")
230 .map(|p| format!("{p}/bin/nvcc"))
231 .unwrap_or_else(|_| "nvcc".to_string());
232 match Command::new(&nvcc).arg("--version").output() {
233 Ok(out) if out.status.success() => {
234 let text = String::from_utf8_lossy(&out.stdout);
235 let release = text.lines().find(|l| l.contains("release")).unwrap_or("present");
236 println!("[ok] CUDA toolkit (nvcc): {}", release.trim());
237 }
238 _ => println!(
239 "[warn] CUDA toolkit not found — set CUDA_PATH.\n PTX/CUDA backend disabled; get CUDA: https://developer.nvidia.com/cuda-downloads"
240 ),
241 }
242
243 println!(
244 "\nMissing native toolchains degrade gracefully to the wgpu/WGSL path (plan §12)."
245 );
246 }
247 ShaderAction::ProfileHardware { export, json } => {
248 let runner = WgpuComputeContext::new(1024 * 1024)?;
249 let profile = &runner.profile;
250 let topology_hash = profile.topology_hash()?;
251 if let Some(path) = export {
252 std::fs::write(path, profile.to_pretty_json()?.as_bytes())?;
253 eprintln!("wrote {} (topology {})", path.display(), topology_hash);
254 }
255 if *json {
256 println!("{}", profile.to_pretty_json()?);
257 } else {
258 println!(
259 "Adapter: {} ({})",
260 profile.adapter.name, profile.adapter.backend
261 );
262 println!("Device type: {}", profile.adapter.device_type);
263 println!(
264 "Driver: {} {}",
265 profile.adapter.driver, profile.adapter.driver_info
266 );
267 println!("Memory class: {}", profile.memory_class);
268 println!("Subgroups: {}", profile.constraints.supports_subgroups);
269 println!("Tensor (coopmat): {}", profile.constraints.supports_coopmat);
270 println!("RT cores: {}", profile.constraints.supports_rt_cores);
271 println!("Timestamp query: {}", profile.supports_timestamp_query);
272 println!(
273 "Max workgroup: {} invocations, {} bytes shared",
274 profile.constraints.max_invocations_per_workgroup,
275 profile.max_compute_workgroup_storage_size
276 );
277 println!(
278 "Bind alignment: storage {} / uniform {}",
279 profile.min_storage_buffer_offset_alignment,
280 profile.min_uniform_buffer_offset_alignment
281 );
282 println!("Topology hash: {topology_hash}");
283 }
284 }
285 ShaderAction::Generate {
286 kernel,
287 target,
288 schedule,
289 out,
290 json,
291 } => {
292 let builtin = parse_kernel(kernel)?;
293 let target_backend = target
294 .parse()
295 .map_err(|e: String| ForgeError::Emission(e))?;
296 let generated = generate_builtin(builtin, schedule.schedule(), target_backend)?;
297 if let Some(path) = out {
298 std::fs::write(path, generated.source.as_bytes())?;
299 eprintln!(
300 "generated {} -> {} ({})",
301 generated.kernel_id,
302 path.display(),
303 generated.source_hash
304 );
305 } else if *json {
306 println!("{}", serde_json::to_string_pretty(&generated)?);
307 } else {
308 print!("{}", generated.source);
309 }
310 }
311 ShaderAction::Validate {
312 input,
313 kernel,
314 target,
315 schedule,
316 manifest,
317 json,
318 } => {
319 let requested_backend: TargetBackend = target
320 .parse()
321 .map_err(|e: String| ForgeError::Emission(e))?;
322 let (target_backend, fallback_note) =
330 resolve_execution_backend(requested_backend, |t| check_native_toolchain(t).is_ok());
331 if let Some(note) = fallback_note {
332 println!("note: {note}");
333 }
334 let (source, generated, spec) = if let Some(path) = input {
335 (std::fs::read_to_string(path)?, None, None)
336 } else {
337 let builtin = parse_kernel(kernel)?;
338 let generated = generate_builtin(builtin, schedule.schedule(), target_backend)?;
339 (
340 generated.source.clone(),
341 Some(generated),
342 Some(builtin.spec()),
343 )
344 };
345 let report = if target_backend == TargetBackend::Wgsl {
346 Some(validate_wgsl(&source)?)
347 } else if target_backend == TargetBackend::Spirv {
348 if let Some(builtin) = parse_kernel(kernel).ok() {
354 let wgsl = generate_builtin(builtin, schedule.schedule(), TargetBackend::Wgsl)?;
355 Some(validate_wgsl(&wgsl.source)?)
356 } else {
357 eprintln!("SPIR-V validation of an opaque --input blob is not supported; provide a kernel.");
358 None
359 }
360 } else {
361 match validate_native(&source, target_backend, spec.as_ref()) {
362 Ok(r) => Some(r),
363 Err(ForgeError::WgslValidation(msg)) => {
364 eprintln!("Native validation skipped or failed: {}", msg);
365 None
366 }
367 Err(e) => return Err(Box::new(e)),
368 }
369 };
370 if let Some(path) = manifest {
371 let generated = generated.ok_or_else(|| {
372 std::io::Error::new(
373 std::io::ErrorKind::InvalidInput,
374 "--manifest requires a generated kernel (omit --input)",
375 )
376 })?;
377 if let Some(report) = report.clone() {
378 let record = CertificationManifest::naga_only(&generated, report);
379 write_json(path, &record)?;
380 } else {
381 eprintln!("Warning: Validation manifest generation is currently only supported for WGSL targets.");
382 }
383 }
384 if let Some(report) = report {
385 if *json {
386 println!("{}", serde_json::to_string_pretty(&report)?);
387 } else {
388 println!(
389 "Validated {} binding(s), entry point(s): {}",
390 report.binding_count,
391 report.entry_points.join(", ")
392 );
393 if let Some(tool) = report.native_tool_validated {
394 println!(
395 "Validation success (via {}): entry points = {:?}",
396 tool, report.entry_points
397 );
398 } else {
399 println!(
400 "Validation success (via Naga): entry points = {:?}",
401 report.entry_points
402 );
403 }
404 }
405 } else {
406 println!("Validation skipped for non-WGSL target.");
407 }
408 }
409 ShaderAction::Certify {
410 kernel,
411 schedule,
412 length,
413 warmups,
414 samples,
415 manifest,
416 cache_dir,
417 dry_run,
418 } => {
419 let builtin = parse_kernel(kernel)?;
420 if *dry_run {
421 let sched = schedule.schedule();
422 let spec = builtin.spec();
423 let generated = generate_builtin(builtin, sched, TargetBackend::Wgsl)?;
424 let report = validate_wgsl(&generated.source)?;
425 let constraints = AdapterConstraints::portable();
426 let schedule_ok = sched.validate(&spec, &constraints).is_ok();
427 let adapter_ok = constraints.supports_kernel(&spec).is_ok();
428 println!(
429 "DRY-RUN certify {}: naga={} ({} bindings), schedule_valid={}, adapter_supported(portable)={}, gpu_oracle={}",
430 builtin.name(), report.naga_validated, report.binding_count, schedule_ok, adapter_ok, builtin.has_gpu_oracle()
431 );
432 return Ok(());
433 }
434 let mut runner = WgpuComputeContext::new(4 * 1024 * 1024)?;
435 runner.constraints.supports_kernel(&builtin.spec())?;
436 let record = qualia_core_db::wgsl_forge::certify_builtin(
437 &mut runner,
438 builtin,
439 schedule.schedule(),
440 *length,
441 *warmups,
442 *samples,
443 )?;
444 if let Some(path) = manifest {
445 write_json(path, &record)?;
446 }
447 if let Some(root) = cache_dir {
448 let path = ManifestCache::new(root).store_certification(&record)?;
449 eprintln!("cached {}", path.display());
450 }
451 println!(
452 "CERTIFIED {} on {}: median {} ns, p95 {} ns",
453 record.kernel_id,
454 record
455 .adapter
456 .as_ref()
457 .map(|value| value.name.as_str())
458 .unwrap_or("unknown"),
459 record
460 .timing
461 .as_ref()
462 .map(|value| value.median_ns)
463 .unwrap_or(0),
464 record
465 .timing
466 .as_ref()
467 .map(|value| value.p95_ns)
468 .unwrap_or(0)
469 );
470 println!(
471 "cache key: {}",
472 record.cache_key.as_deref().unwrap_or("none")
473 );
474 }
475 ShaderAction::Tune {
476 kernel,
477 length,
478 warmups,
479 initial_samples,
480 finalist_samples,
481 finalists,
482 max_candidates,
483 manifest,
484 cache_dir,
485 dry_run,
486 } => {
487 let builtin = parse_kernel(kernel)?;
488 let spec = builtin.spec();
489 if *dry_run {
490 let constraints = match WgpuComputeContext::new(4 * 1024 * 1024) {
491 Ok(runner) => runner.constraints,
492 Err(_) => AdapterConstraints::portable(),
493 };
494 let space = ScheduleSpace::default();
495 let total = space.workgroup_sizes.len()
496 * space.items_per_invocation.len()
497 * space.vector_widths.len();
498 let candidates = space.candidates(&spec, &constraints);
499 let adapter_ok = constraints.supports_kernel(&spec);
500 let roofline = qualia_core_db::wgsl_forge::roofline_for(builtin, *length as u64);
501 println!(
502 "DRY-RUN tune {}: {}/{} schedule(s) survive pruning; adapter_supported={}; gpu_oracle={}",
503 builtin.name(),
504 candidates.len(),
505 total,
506 adapter_ok.is_ok(),
507 builtin.has_gpu_oracle()
508 );
509 println!(
510 " roofline @ n={}: {:.3} FLOP/byte ({:?}-bound)",
511 length, roofline.arithmetic_intensity, roofline.bound
512 );
513 println!(
514 " warp size: {} (non-multiples pruned)",
515 constraints.warp_size
516 );
517 for &wg in &space.workgroup_sizes {
518 let kept = candidates.iter().filter(|c| c.workgroup_size == wg).count();
519 let note = if wg % constraints.warp_size == 0 {
520 ""
521 } else {
522 " [warp-pruned]"
523 };
524 println!(" workgroup {wg:>4}: {kept} kept{note}");
525 }
526 if let Err(error) = adapter_ok {
527 println!(" pruned: {error}");
528 }
529 return Ok(());
530 }
531 let mut runner = WgpuComputeContext::new(4 * 1024 * 1024)?;
532 let constraints = runner.constraints;
533 let result = tune_with(
534 &spec,
535 &constraints,
536 &ScheduleSpace::default(),
537 TuningConfig {
538 initial_samples: *initial_samples,
539 finalist_samples: *finalist_samples,
540 finalist_count: *finalists,
541 max_candidates: *max_candidates,
542 },
543 |schedule, sample_count| {
544 candidate_evaluation(
545 &mut runner,
546 builtin,
547 schedule,
548 *length,
549 *warmups,
550 sample_count,
551 )
552 },
553 )?;
554 println!("\nBest configuration:");
555 let generated = generate_builtin(builtin, result.winner.schedule, TargetBackend::Wgsl)?;
556 println!("{}", generated.source);
557 let record = TuningManifest::new(&generated, runner.adapter.clone(), result)?;
558 if let Some(path) = manifest {
559 write_json(path, &record)?;
560 }
561 if let Some(root) = cache_dir {
562 let path = ManifestCache::new(root).store_tuning(&record)?;
563 eprintln!("cached {}", path.display());
564 }
565 let winner = &record.result.winner;
566 println!(
567 "TUNED {} on {}: wg={}, items={}, vector={} -> median {} ns, p95 {} ns",
568 record.kernel_id,
569 record.adapter.name,
570 winner.schedule.workgroup_size,
571 winner.schedule.items_per_invocation,
572 winner.schedule.vector_width,
573 winner.timing.median_ns,
574 winner.timing.p95_ns
575 );
576 println!(
577 "evaluated {}, rejected {}, cache key {}",
578 record.result.evaluated_candidates,
579 record.result.rejected_candidates,
580 record.cache_key
581 );
582 }
583 ShaderAction::AutoTuneAll {
584 length,
585 warmups,
586 max_candidates,
587 cache_dir,
588 update_local_manifest,
589 dry_run,
590 budget_ms,
591 thermal_limit,
592 } => {
593 if let Err(why) = check_native_toolchain(TargetBackend::Wgsl) {
598 return Err(why.into());
599 }
600
601 let mut runner = WgpuComputeContext::new(4 * 1024 * 1024)?;
602 let topology_hash = runner.profile.topology_hash()?;
603 let constraints = runner.constraints;
604 let adapter_name = runner.adapter.name.clone();
605 let cache = cache_dir.as_ref().map(|p| ManifestCache::new(p.clone()));
606 println!("auto-tune-all on {adapter_name} (topology {topology_hash})");
607
608 let start = std::time::Instant::now();
612
613 let thermal_active = match (thermal_limit, read_gpu_temperature_celsius()) {
616 (Some(limit), Some(_)) => Some(*limit),
617 (Some(limit), None) => {
618 eprintln!(
619 "warning: --thermal-limit {limit} requested but no GPU temperature sensor is available on this host (nvidia-smi missing or non-NVIDIA GPU); proceeding WITHOUT thermal limiting."
620 );
621 None
622 }
623 (None, _) => None,
624 };
625
626 let mut tuned = 0usize;
627 let mut skipped_budget = 0usize;
628 for builtin in BuiltinKernel::ALL {
629 let spec = builtin.spec();
630 let name = builtin.name();
631 if constraints.supports_kernel(&spec).is_err() {
632 println!(" {name:<12} SKIP (adapter lacks required intrinsics)");
633 continue;
634 }
635 if !builtin.has_gpu_oracle() {
636 println!(" {name:<12} SKIP (no GPU oracle wired yet)");
637 continue;
638 }
639 if let Some(cache) = &cache {
640 if let Some(existing) = cache.load_tuning_for_topology(&topology_hash, name)? {
641 let winner = &existing.result.winner;
642 println!(
643 " {name:<12} CACHED wg={} items={} -> median {} ns",
644 winner.schedule.workgroup_size,
645 winner.schedule.items_per_invocation,
646 winner.timing.median_ns
647 );
648 continue;
649 }
650 }
651 if *dry_run {
652 println!(" {name:<12} WOULD TUNE");
653 continue;
654 }
655 if let Some(budget) = budget_ms {
658 let elapsed = start.elapsed().as_millis() as u64;
659 if elapsed >= *budget {
660 println!(" {name:<12} SKIP (budget: {elapsed} ms elapsed >= {budget} ms)");
661 skipped_budget += 1;
662 continue;
663 }
664 }
665 if let Some(limit) = thermal_active {
668 const MAX_WAITS: u32 = 5;
669 for attempt in 0..MAX_WAITS {
670 match read_gpu_temperature_celsius() {
671 Some(temp) if temp >= limit => {
672 println!(
673 " {name:<12} THERMAL HOLD ({temp:.0} C >= {limit:.0} C), waiting ~2s [{}/{}]",
674 attempt + 1,
675 MAX_WAITS
676 );
677 std::thread::sleep(std::time::Duration::from_secs(2));
678 }
679 _ => break,
680 }
681 }
682 }
683 let result = tune_with(
684 &spec,
685 &constraints,
686 &ScheduleSpace::default(),
687 TuningConfig {
688 initial_samples: 3,
689 finalist_samples: 11,
690 finalist_count: 6,
691 max_candidates: *max_candidates,
692 },
693 |schedule, sample_count| {
694 candidate_evaluation(
695 &mut runner,
696 builtin,
697 schedule,
698 *length,
699 *warmups,
700 sample_count,
701 )
702 },
703 );
704 match result {
705 Ok(result) => {
706 tuned += 1;
707 let generated =
708 generate_builtin(builtin, result.winner.schedule, TargetBackend::Wgsl)?;
709 let record =
710 TuningManifest::new(&generated, runner.adapter.clone(), result)?;
711 let winner = &record.result.winner;
712 println!(
713 " {name:<12} TUNED wg={} items={} vec={} -> median {} ns, p95 {} ns",
714 winner.schedule.workgroup_size,
715 winner.schedule.items_per_invocation,
716 winner.schedule.vector_width,
717 winner.timing.median_ns,
718 winner.timing.p95_ns
719 );
720 if *update_local_manifest {
721 if let Some(cache) = &cache {
722 let path = cache.store_tuning_for_topology(
723 &topology_hash,
724 name,
725 &record,
726 )?;
727 eprintln!(" cached {}", path.display());
728 }
729 }
730 }
731 Err(error) => println!(" {name:<12} FAILED: {error}"),
732 }
733 }
734 if budget_ms.is_some() {
735 println!(
736 "auto-tune-all done in {} ms: {tuned} kernel(s) tuned, {skipped_budget} skipped due to budget",
737 start.elapsed().as_millis()
738 );
739 }
740 }
741 }
742 Ok(())
743}
744
745fn parse_kernel(value: &str) -> Result<BuiltinKernel, Box<dyn std::error::Error>> {
746 Ok(BuiltinKernel::from_str(value)?)
747}
748
749fn read_gpu_temperature_celsius() -> Option<f32> {
755 use std::process::Command;
756 let output = Command::new("nvidia-smi")
757 .arg("--query-gpu=temperature.gpu")
758 .arg("--format=csv,noheader,nounits")
759 .output()
760 .ok()?;
761 if !output.status.success() {
762 return None;
763 }
764 let text = String::from_utf8_lossy(&output.stdout);
765 text.lines()
767 .next()?
768 .trim()
769 .split(|c: char| !c.is_ascii_digit())
770 .find(|s| !s.is_empty())
771 .and_then(|s| s.parse::<f32>().ok())
772}
773
774fn check_native_toolchain(target: TargetBackend) -> Result<(), String> {
779 use std::process::Command;
780 match target {
781 TargetBackend::Wgsl | TargetBackend::Spirv => Ok(()),
783 TargetBackend::Hlsl => {
784 let dxc = std::env::var("QUALIA_DXC_PATH").unwrap_or_else(|_| "dxc".to_string());
785 match Command::new(&dxc).arg("--version").output() {
786 Ok(out) if out.status.success() => Ok(()),
787 _ => Err(format!(
788 "DXC (HLSL compiler) not found as `{dxc}`; set QUALIA_DXC_PATH or add dxc to PATH. Run `shader doctor` for details."
789 )),
790 }
791 }
792 TargetBackend::Ptx | TargetBackend::CudaC => {
793 let nvcc = std::env::var("CUDA_PATH")
794 .map(|p| format!("{p}/bin/nvcc"))
795 .unwrap_or_else(|_| "nvcc".to_string());
796 match Command::new(&nvcc).arg("--version").output() {
797 Ok(out) if out.status.success() => Ok(()),
798 _ => Err(
799 "CUDA toolkit (nvcc) not found at CUDA_PATH; set CUDA_PATH or add nvcc to PATH. Run `shader doctor` for details.".to_string(),
800 ),
801 }
802 }
803 TargetBackend::Msl => {
804 match Command::new("xcrun").arg("--version").output() {
805 Ok(out) if out.status.success() => Ok(()),
806 _ => Err(
807 "Metal toolchain (xcrun) not found; install Xcode command-line tools. Run `shader doctor` for details.".to_string(),
808 ),
809 }
810 }
811 }
812}
813
814fn write_json<T: serde::Serialize>(
815 path: &Path,
816 value: &T,
817) -> Result<(), Box<dyn std::error::Error>> {
818 let json = serde_json::to_string_pretty(value)?;
819 std::fs::write(path, json.as_bytes())?;
820 eprintln!("wrote {}", path.display());
821 Ok(())
822}