1#[macro_use]
13extern crate serde;
14
15extern crate core;
16mod building;
17mod bundled_examples;
18mod cartridge_generation;
19pub mod dev_session;
20pub mod helpers;
21mod hot_reload;
22pub mod project_metadata;
23mod route_metadata;
24pub mod static_analysis;
25pub mod svg_import;
26
27pub mod design_server;
28
29pub use hot_reload::HotReloadMode;
30
31use color_eyre::eyre;
32use color_eyre::eyre::Report;
33use eyre::eyre;
34use helpers::{copy_dir_recursively, wait_with_output};
35use pax_manifest::{
36 ComponentDefinition, ComponentTemplate, GradientElement, GradientShapeDefinition,
37 LiteralBlockDefinition, PaxExpression, PaxManifest, SettingElement, SettingsBlockElement,
38 TemplateNodeDefinition, TypeId, ValueDefinition,
39};
40use pax_runtime_api::PaxValue;
41use reqwest::blocking::Client;
42use reqwest::Url;
43use serde_json::Value as JsonValue;
44use std::collections::hash_map::DefaultHasher;
45use std::collections::HashSet;
46use std::fs;
47use std::io;
48use std::sync::atomic::{AtomicBool, Ordering};
49use std::sync::{Arc, Mutex};
50
51#[cfg(unix)]
52use std::os::unix::process::CommandExt;
53
54use crate::building::build_project_with_cartridge;
55
56use crate::cartridge_generation::generate_cartridge_partial_rs;
57use crate::project_metadata::PaxProjectMetadata;
58use std::hash::{Hash, Hasher};
59use std::path::{Path, PathBuf};
60use std::process::Command;
61use std::time::{Duration, Instant};
62use walkdir::WalkDir;
63
64use crate::helpers::{
65 get_or_create_pax_directory, update_pax_dependency_versions, INTERFACE_DIR_NAME, PAX_BADGE,
66 PAX_CREATE_AGENTS_TEMPLATE, PAX_IOS_INTERFACE_TEMPLATE, PAX_MACOS_INTERFACE_TEMPLATE,
67 PAX_SWIFT_CARTRIDGE_TEMPLATE, PAX_SWIFT_COMMON_TEMPLATE, PAX_WEB_INTERFACE_TEMPLATE,
68};
69
70pub trait RunLifecycleObserver: Send + Sync {
72 fn run_ready(&self, target: RunTarget);
74}
75
76#[derive(Clone)]
81pub struct RunContext {
82 pub target: RunTarget,
83 pub project_path: PathBuf,
84 pub verbose: bool,
85 pub should_also_run: bool,
86 pub is_libdev_mode: bool,
87 pub process_child_ids: Arc<Mutex<Vec<u64>>>,
88 pub should_run_designtime: bool,
89 pub hot_reload: Option<HotReloadMode>,
95 pub is_release: bool,
96 pub profile_wasm_size: bool,
97 pub ios_device: Option<String>,
98 pub ios_development_team: Option<String>,
99 pub lifecycle_observer: Option<Arc<dyn RunLifecycleObserver>>,
101}
102
103#[derive(Clone)]
104pub(crate) struct RunLifecycleNotifier {
105 observer: Option<Arc<dyn RunLifecycleObserver>>,
106 target: RunTarget,
107 notified: Arc<AtomicBool>,
108}
109
110impl RunLifecycleNotifier {
111 pub(crate) fn new(ctx: &RunContext) -> Self {
112 Self {
113 observer: ctx.lifecycle_observer.clone(),
114 target: ctx.target.clone(),
115 notified: Arc::new(AtomicBool::new(false)),
116 }
117 }
118
119 pub(crate) fn notify(&self) {
120 let Some(observer) = &self.observer else {
121 return;
122 };
123 if self
124 .notified
125 .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
126 .is_ok()
127 {
128 observer.run_ready(self.target.clone());
129 }
130 }
131
132 pub(crate) fn into_callback(self) -> Option<Box<dyn FnOnce() + Send>> {
135 if self.observer.is_some() {
136 Some(Box::new(move || self.notify()))
137 } else {
138 None
139 }
140 }
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Hash)]
144struct WebFontSource {
145 family: String,
146 url: String,
147}
148
149#[derive(Clone, Debug, PartialEq)]
150pub enum RunTarget {
151 #[allow(non_camel_case_types)]
152 macOS,
153 Web,
154 #[allow(non_camel_case_types)]
155 iOS,
156 #[allow(non_camel_case_types)]
157 iPadOS,
158}
159
160const WEB_INTERFACE_BUNDLE_FILE: &str = "public/pax-interface-web.js";
161const WEB_INTERFACE_FINGERPRINT_FILE: &str = ".pax-interface-web.fingerprint";
162const WEB_INTERFACE_HASH_OFFSET: u64 = 0xcbf29ce484222325;
163const WEB_INTERFACE_HASH_PRIME: u64 = 0x100000001b3;
164
165pub(crate) struct BuildTimings {
166 total_start: Instant,
167 phases: Vec<BuildTimingPhase>,
168}
169
170struct BuildTimingPhase {
171 label: &'static str,
172 duration: Duration,
173}
174
175impl BuildTimings {
176 fn start() -> Self {
177 Self {
178 total_start: Instant::now(),
179 phases: Vec::new(),
180 }
181 }
182
183 fn record<T>(&mut self, label: &'static str, operation: impl FnOnce() -> T) -> T {
184 let start = Instant::now();
185 let result = operation();
186 self.phases.push(BuildTimingPhase {
187 label,
188 duration: start.elapsed(),
189 });
190 result
191 }
192
193 pub(crate) fn print_summary(&self) {
194 let total = self.total_start.elapsed();
195 let measured = self
196 .phases
197 .iter()
198 .map(|phase| phase.duration)
199 .fold(Duration::ZERO, |sum, duration| sum + duration);
200 let other = total.saturating_sub(measured);
201
202 println!(
203 "{} ⏱️ Build completed in {:.2}s",
204 *PAX_BADGE,
205 seconds(total)
206 );
207 for phase in &self.phases {
208 println!(
209 "{} {:<24} {:.2}s",
210 *PAX_BADGE,
211 phase.label,
212 seconds(phase.duration)
213 );
214 }
215 if seconds(other) >= 0.005 {
216 println!("{} {:<24} {:.2}s", *PAX_BADGE, "other", seconds(other));
217 }
218 }
219}
220
221fn seconds(duration: Duration) -> f64 {
222 duration.as_secs_f64()
223}
224
225pub(crate) struct PreparedCartridgeSources {
226 pub pax_dir: PathBuf,
227 pub userland_manifest: PaxManifest,
228 pub assets_dirs: Vec<String>,
229 pub project_metadata: PaxProjectMetadata,
230}
231
232pub fn perform_build(ctx: &RunContext) -> eyre::Result<(PaxManifest, Option<PathBuf>), Report> {
236 let mut timings = BuildTimings::start();
237 let prepared = prepare_cartridge_sources_with_timings(ctx, &mut timings)?;
238 let hot_reload = if ctx.should_also_run && ctx.should_run_designtime {
239 let mode = resolve_hot_reload_mode(
240 ctx.is_release,
241 ctx.hot_reload,
242 std::env::var("PAX_HOT_RELOAD").ok().as_deref(),
243 prepared.project_metadata.configured_hot_reload(),
244 )?;
245 validate_hot_reload_target(&ctx.target, mode)?;
246 mode
247 } else {
248 HotReloadMode::Off
249 };
250 let mut effective_ctx = ctx.clone();
251 effective_ctx.hot_reload = Some(hot_reload);
252
253 println!("{} 🧱 Building project with `cargo`", *PAX_BADGE);
255 let build_dir = build_project_with_cartridge(
256 &prepared.pax_dir,
257 &effective_ctx,
258 Arc::clone(&ctx.process_child_ids),
259 prepared.assets_dirs,
260 prepared.userland_manifest.clone(),
261 prepared.project_metadata.clone(),
262 &mut timings,
263 )?;
264
265 Ok((prepared.userland_manifest, build_dir))
266}
267
268fn resolve_hot_reload_mode(
269 is_release: bool,
270 explicit: Option<HotReloadMode>,
271 environment: Option<&str>,
272 project_metadata: Option<&str>,
273) -> Result<HotReloadMode, Report> {
274 if is_release {
275 return Ok(HotReloadMode::Off);
276 }
277 if let Some(mode) = explicit {
278 return Ok(mode);
279 }
280 if let Some(value) = environment {
281 return value
282 .parse()
283 .map_err(|err: String| eyre!("Invalid PAX_HOT_RELOAD value: {err}"));
284 }
285 if let Some(value) = project_metadata {
286 return value.parse().map_err(|err: String| {
287 eyre!("Invalid package.metadata.pax.dev.hot_reload value: {err}")
288 });
289 }
290 Ok(HotReloadMode::default())
291}
292
293fn validate_hot_reload_target(target: &RunTarget, hot_reload: HotReloadMode) -> Result<(), Report> {
294 if matches!(target, RunTarget::iOS | RunTarget::iPadOS) && hot_reload == HotReloadMode::Logic {
295 return Err(eyre!(
296 "The `logic` hot-reload mode is unavailable for iOS and iPadOS because those chassis do not dynamically replace application logic. Use `pax` or `all`, or rebuild the app after logic changes."
297 ));
298 }
299 Ok(())
300}
301
302pub(crate) fn prepare_cartridge_sources(
303 ctx: &RunContext,
304) -> eyre::Result<PreparedCartridgeSources, Report> {
305 let mut timings = BuildTimings::start();
306 prepare_cartridge_sources_with_timings(ctx, &mut timings)
307}
308
309fn prepare_cartridge_sources_with_timings(
310 ctx: &RunContext,
311 timings: &mut BuildTimings,
312) -> eyre::Result<PreparedCartridgeSources, Report> {
313 validate_release_feature_boundary(ctx)?;
314
315 if ctx.target == RunTarget::Web {
316 timings.record("web interface", || ensure_default_web_interface_bundle(ctx));
317 }
318
319 let project_metadata = timings.record("project metadata", || {
320 project_metadata::load_project_metadata(&ctx.project_path)
321 })?;
322
323 let pax_dir = get_or_create_pax_directory(&ctx.project_path);
324
325 timings.record("copy interface", || {
327 copy_interface_files_for_target(ctx, &pax_dir)
328 });
329 timings.record("project metadata interface", || {
330 project_metadata::apply_copied_interface_metadata(ctx, &pax_dir, &project_metadata)
331 })?;
332
333 let mut userland_manifest = timings.record("manifest", || {
334 static_analysis::build_manifest_with_options(
335 &ctx.project_path,
336 static_analysis::BuildManifestOptions {
337 is_designtime: ctx.should_run_designtime,
338 },
339 )
340 })?;
341 println!("{} 🔎 Built manifest via static analysis", *PAX_BADGE);
342
343 if ctx.target == RunTarget::Web {
344 timings.record("web route metadata", || {
345 route_metadata::prepare_web_route_metadata(
346 &pax_dir,
347 &userland_manifest,
348 &project_metadata,
349 ctx.is_release,
350 )
351 })?;
352 }
353
354 let merged_manifest = userland_manifest.clone();
355
356 let wrapper_type_id = TypeId::build_singleton("ROOT_COMPONENT", Some("RootComponent"));
358 let mut tnd = TemplateNodeDefinition::default();
359 tnd.type_id = userland_manifest.main_component_type_id.clone();
360 let mut wrapper_component_template = ComponentTemplate::new(wrapper_type_id.clone(), None);
361 wrapper_component_template.add(tnd);
362 userland_manifest.components.insert(
363 wrapper_type_id.clone(),
364 ComponentDefinition {
365 type_id: wrapper_type_id.clone(),
366 is_main_component: false,
367 is_primitive: false,
368 is_struct_only_component: false,
369 module_path: "".to_string(),
370 primitive_instance_import_path: None,
371 template: Some(wrapper_component_template),
372 settings: None,
373 timelines: vec![],
374 route_branch: None,
375 },
376 );
377
378 if matches!(
379 ctx.target,
380 RunTarget::macOS | RunTarget::iOS | RunTarget::iPadOS
381 ) {
382 timings.record("apple web fonts", || {
383 vendor_apple_web_fonts(ctx, &pax_dir, &merged_manifest)
384 })?;
385 }
386
387 println!("{} 🦀 Generating Rust", *PAX_BADGE);
388 timings.record("generate rust", || {
389 generate_cartridge_partial_rs(
390 &pax_dir,
391 &merged_manifest,
392 &userland_manifest,
393 ctx.should_run_designtime,
394 ctx.is_release && !ctx.should_run_designtime,
395 );
396 });
397 Ok(PreparedCartridgeSources {
398 pax_dir,
399 userland_manifest,
400 assets_dirs: merged_manifest.assets_dirs,
401 project_metadata,
402 })
403}
404
405fn validate_release_feature_boundary(ctx: &RunContext) -> Result<(), Report> {
406 if !ctx.is_release {
407 return Ok(());
408 }
409 if ctx.should_run_designtime {
410 return Err(eyre!(
411 "Release builds do not support designtime features. Use a debug build for designtime sessions."
412 ));
413 }
414
415 Ok(())
416}
417
418pub(crate) fn validate_release_cargo_feature_boundary(
419 ctx: &RunContext,
420 target_triples: &[&str],
421) -> Result<(), Report> {
422 if !ctx.is_release {
423 return Ok(());
424 }
425
426 let requested_features = vec![match ctx.target {
427 RunTarget::Web => "web",
428 RunTarget::macOS => "macos",
429 RunTarget::iOS | RunTarget::iPadOS => "ios",
430 }];
431 let cargo_features = helpers::pax_project_feature_args(&ctx.project_path, &requested_features);
432 let activators = helpers::pax_project_release_devtime_activators(
433 &ctx.project_path,
434 &cargo_features,
435 target_triples,
436 )
437 .map_err(|err| eyre!("Could not verify the release Cargo feature boundary: {err}"))?;
438 if !activators.is_empty() {
439 return Err(eyre!(
440 "Release builds do not support designtime code, but this project's Cargo configuration activates it through: {}. Remove these entries from Cargo defaults/dependencies for release builds. Pax enables development features explicitly for debug designtime sessions.",
441 activators.join("; ")
442 ));
443 }
444
445 Ok(())
446}
447
448fn ensure_default_web_interface_bundle(ctx: &RunContext) {
449 let pax_compiler_root = Path::new(env!("CARGO_MANIFEST_DIR"));
450 let web_interface_root = pax_compiler_root
451 .join("files")
452 .join("interfaces")
453 .join("web");
454 if !web_interface_root.exists() {
455 return;
456 }
457 if !web_interface_bundle_needs_rebuild(&web_interface_root, ctx.is_libdev_mode) {
458 return;
459 }
460
461 let mut cmd = Command::new("bash");
462 cmd.arg("./build-interface.sh")
463 .current_dir(&web_interface_root)
464 .stdout(std::process::Stdio::inherit())
465 .stderr(std::process::Stdio::inherit());
466
467 #[cfg(unix)]
468 unsafe {
469 cmd.pre_exec(pre_exec_hook);
470 }
471
472 let child = cmd
473 .spawn()
474 .expect("failed to start web interface bundle build");
475 let output = wait_with_output(&ctx.process_child_ids, child);
476 if !output.status.success() {
477 panic!(
478 "failed to build the default Pax web interface at {:?}",
479 web_interface_root
480 );
481 }
482
483 if ctx.is_libdev_mode {
484 if let Err(err) = write_web_interface_fingerprint(&web_interface_root) {
485 eprintln!(
486 "{} ⚠️ Failed to write web interface fingerprint: {}",
487 *PAX_BADGE, err
488 );
489 }
490 }
491}
492
493fn web_interface_bundle_needs_rebuild(web_interface_root: &Path, is_libdev_mode: bool) -> bool {
494 if !web_interface_root.join(WEB_INTERFACE_BUNDLE_FILE).is_file() {
495 return true;
496 }
497 if !is_libdev_mode {
498 return false;
499 }
500
501 current_web_interface_fingerprint(web_interface_root)
502 .and_then(|current| {
503 fs::read_to_string(web_interface_fingerprint_path(web_interface_root))
504 .map(|stored| stored.trim() != current)
505 })
506 .unwrap_or(true)
507}
508
509fn write_web_interface_fingerprint(web_interface_root: &Path) -> io::Result<()> {
510 let fingerprint = current_web_interface_fingerprint(web_interface_root)?;
511 fs::write(
512 web_interface_fingerprint_path(web_interface_root),
513 format!("{fingerprint}\n"),
514 )
515}
516
517fn web_interface_fingerprint_path(web_interface_root: &Path) -> PathBuf {
518 web_interface_root.join(WEB_INTERFACE_FINGERPRINT_FILE)
519}
520
521fn current_web_interface_fingerprint(web_interface_root: &Path) -> io::Result<String> {
522 let mut hasher = WebInterfaceStableHasher::new();
523 hasher.write(b"pax-web-interface-fingerprint-v1\0");
524
525 for input in web_interface_fingerprint_inputs(web_interface_root)? {
526 hasher.write(input.relative_path.as_bytes());
527 hasher.write(b"\0");
528 match input.path {
529 Some(path) => {
530 let bytes = fs::read(path)?;
531 hasher.write(b"file\0");
532 hasher.write(&(bytes.len() as u64).to_le_bytes());
533 hasher.write(&bytes);
534 }
535 None => hasher.write(b"missing\0"),
536 }
537 hasher.write(b"\0");
538 }
539
540 Ok(format!("{:016x}", hasher.finish()))
541}
542
543struct WebInterfaceFingerprintInput {
544 relative_path: String,
545 path: Option<PathBuf>,
546}
547
548fn web_interface_fingerprint_inputs(
549 web_interface_root: &Path,
550) -> io::Result<Vec<WebInterfaceFingerprintInput>> {
551 let mut inputs = Vec::new();
552
553 for relative_path in ["build-interface.sh", "package.json", "tsconfig.json"] {
554 let path = web_interface_root.join(relative_path);
555 inputs.push(WebInterfaceFingerprintInput {
556 relative_path: relative_path.to_string(),
557 path: path.is_file().then_some(path),
558 });
559 }
560
561 let src_path = web_interface_root.join("src");
562 if src_path.is_dir() {
563 for entry in WalkDir::new(&src_path) {
564 let entry = entry.map_err(|err| io::Error::new(io::ErrorKind::Other, err))?;
565 if !entry.file_type().is_file() {
566 continue;
567 }
568 let relative_path = entry
569 .path()
570 .strip_prefix(web_interface_root)
571 .map_err(|err| {
572 io::Error::new(
573 io::ErrorKind::InvalidData,
574 format!(
575 "failed to make web interface path relative to {}: {err}",
576 web_interface_root.display()
577 ),
578 )
579 })?;
580 inputs.push(WebInterfaceFingerprintInput {
581 relative_path: normalized_relative_path(relative_path),
582 path: Some(entry.path().to_path_buf()),
583 });
584 }
585 } else {
586 inputs.push(WebInterfaceFingerprintInput {
587 relative_path: "src".to_string(),
588 path: None,
589 });
590 }
591
592 inputs.sort_by(|left, right| left.relative_path.cmp(&right.relative_path));
593 Ok(inputs)
594}
595
596fn normalized_relative_path(path: &Path) -> String {
597 path.components()
598 .map(|component| component.as_os_str().to_string_lossy())
599 .collect::<Vec<_>>()
600 .join("/")
601}
602
603struct WebInterfaceStableHasher {
604 state: u64,
605}
606
607impl WebInterfaceStableHasher {
608 fn new() -> Self {
609 Self {
610 state: WEB_INTERFACE_HASH_OFFSET,
611 }
612 }
613
614 fn write(&mut self, bytes: &[u8]) {
615 for byte in bytes {
616 self.state ^= *byte as u64;
617 self.state = self.state.wrapping_mul(WEB_INTERFACE_HASH_PRIME);
618 }
619 }
620
621 fn finish(&self) -> u64 {
622 self.state
623 }
624}
625
626fn build_interface_dir_name(target: &RunTarget) -> &'static str {
627 match target {
628 RunTarget::Web => "web",
629 RunTarget::macOS => "macos",
630 RunTarget::iOS | RunTarget::iPadOS => "ios",
631 }
632}
633
634fn custom_interface_dir_candidates(target: &RunTarget) -> &'static [&'static str] {
635 match target {
636 RunTarget::Web => &["web"],
637 RunTarget::macOS => &["macos"],
638 RunTarget::iOS => &["ios"],
639 RunTarget::iPadOS => &["ipados", "ios"],
640 }
641}
642
643fn copy_interface_files_for_target(ctx: &RunContext, pax_dir: &PathBuf) {
644 let interface_path = pax_dir
645 .join(INTERFACE_DIR_NAME)
646 .join(build_interface_dir_name(&ctx.target));
647
648 let _ = fs::remove_dir_all(&interface_path);
649 let _ = fs::create_dir_all(&interface_path);
650
651 let custom_interface = custom_interface_dir_candidates(&ctx.target)
652 .iter()
653 .map(|candidate| {
654 let mut interface_path = pax_dir.parent().unwrap().join("interfaces").join(candidate);
655 if ctx.target == RunTarget::Web {
656 interface_path = interface_path.join("public");
657 }
658 interface_path
659 })
660 .find(|path| path.exists());
661
662 if let Some(custom_interface) = custom_interface {
663 copy_interface_files(&custom_interface, &interface_path);
664 } else {
665 copy_default_interface_files(&interface_path, ctx);
666 }
667
668 if matches!(
670 ctx.target,
671 RunTarget::macOS | RunTarget::iOS | RunTarget::iPadOS
672 ) {
673 let common_dest = pax_dir.join(INTERFACE_DIR_NAME).join("common");
674 copy_common_swift_files(ctx, &common_dest);
675 }
676}
677
678fn copy_interface_files(src: &Path, dest: &Path) {
679 copy_dir_recursively(src, dest, &[]).expect("Failed to copy interface files");
680}
681
682fn copy_default_interface_files(interface_path: &Path, ctx: &RunContext) {
683 let pax_compiler_root = Path::new(env!("CARGO_MANIFEST_DIR"));
684 let interface_src = match ctx.target {
685 RunTarget::Web => pax_compiler_root
686 .join("files")
687 .join("interfaces")
688 .join("web")
689 .join("public"),
690 RunTarget::macOS => pax_compiler_root
691 .join("files")
692 .join("interfaces")
693 .join("macos"),
694 RunTarget::iOS | RunTarget::iPadOS => pax_compiler_root
695 .join("files")
696 .join("interfaces")
697 .join("ios"),
698 };
699
700 if ctx.is_libdev_mode || interface_src.exists() {
701 copy_dir_recursively(&interface_src, interface_path, &[])
702 .expect("Failed to copy interface files");
703 } else {
704 match ctx.target {
706 RunTarget::Web => PAX_WEB_INTERFACE_TEMPLATE
707 .extract(interface_path)
708 .expect("Failed to extract web interface files"),
709 RunTarget::macOS => PAX_MACOS_INTERFACE_TEMPLATE
710 .extract(interface_path)
711 .expect("Failed to extract macos interface files"),
712 RunTarget::iOS | RunTarget::iPadOS => PAX_IOS_INTERFACE_TEMPLATE
713 .extract(interface_path)
714 .expect("Failed to extract ios interface files"),
715 }
716 }
717}
718
719fn copy_common_swift_files(ctx: &RunContext, common_dest: &Path) {
720 let _ = std::fs::remove_dir_all(common_dest);
721 std::fs::create_dir_all(common_dest).expect("Failed to create swift common destination");
722 let pax_compiler_root = Path::new(env!("CARGO_MANIFEST_DIR"));
723 let common_swift_cartridge_src = pax_compiler_root
724 .join("files")
725 .join("swift")
726 .join("pax-swift-cartridge");
727 let common_swift_common_src = pax_compiler_root
728 .join("files")
729 .join("swift")
730 .join("pax-swift-common");
731
732 if ctx.is_libdev_mode
733 || (common_swift_cartridge_src.exists() && common_swift_common_src.exists())
734 {
735 let common_swift_cartridge_dest = common_dest.join("pax-swift-cartridge");
736 let common_swift_common_dest = common_dest.join("pax-swift-common");
737
738 copy_dir_recursively(
739 &common_swift_cartridge_src,
740 &common_swift_cartridge_dest,
741 &[".build"],
742 )
743 .expect("Failed to copy swift cartridge files");
744 copy_dir_recursively(
745 &common_swift_common_src,
746 &common_swift_common_dest,
747 &[".build"],
748 )
749 .expect("Failed to copy swift common files");
750 } else {
751 let common_swift_common_dest = common_dest.join("pax-swift-common");
752 let common_swift_cartridge_dest = common_dest.join("pax-swift-cartridge");
753 fs::create_dir_all(&common_swift_common_dest)
754 .expect("Failed to create swift common destination");
755 fs::create_dir_all(&common_swift_cartridge_dest)
756 .expect("Failed to create swift cartridge destination");
757 PAX_SWIFT_COMMON_TEMPLATE
758 .extract(&common_swift_common_dest)
759 .expect("Failed to extract swift common template files");
760 PAX_SWIFT_CARTRIDGE_TEMPLATE
761 .extract(&common_swift_cartridge_dest)
762 .expect("Failed to extract swift cartridge template files");
763 }
764}
765
766fn vendor_apple_web_fonts(
767 ctx: &RunContext,
768 pax_dir: &Path,
769 manifest: &PaxManifest,
770) -> eyre::Result<(), Report> {
771 if !matches!(
772 ctx.target,
773 RunTarget::macOS | RunTarget::iOS | RunTarget::iPadOS
774 ) {
775 return Ok(());
776 }
777
778 let font_sources = collect_web_font_sources(manifest);
779 if font_sources.is_empty() {
780 return Ok(());
781 }
782
783 let resources_dir = pax_dir
784 .join(INTERFACE_DIR_NAME)
785 .join("common")
786 .join("pax-swift-cartridge")
787 .join("Sources")
788 .join("PaxCartridgeAssets")
789 .join("Resources");
790 fs::create_dir_all(&resources_dir)?;
791 if let Ok(existing_entries) = fs::read_dir(&resources_dir) {
792 for entry in existing_entries.flatten() {
793 let path = entry.path();
794 let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
795 continue;
796 };
797 if file_name.starts_with("pax-font-") {
798 let _ = fs::remove_file(path);
799 }
800 }
801 }
802
803 let client = Client::builder().build()?;
804
805 let mut vendored_assets: HashSet<String> = HashSet::new();
806 let mut vendored_count = 0usize;
807
808 for font_source in font_sources {
809 if let Err(error) = vendor_web_font_source(
810 &client,
811 &font_source,
812 &resources_dir,
813 &mut vendored_assets,
814 &mut vendored_count,
815 ) {
816 println!(
817 "{} ⚠️ Failed to vendor Apple font '{}' from {}: {}",
818 *PAX_BADGE, font_source.family, font_source.url, error
819 );
820 }
821 }
822
823 if vendored_count > 0 {
824 println!(
825 "{} 🔤 Vendored {} Apple font asset{} for bundled native builds",
826 *PAX_BADGE,
827 vendored_count,
828 if vendored_count == 1 { "" } else { "s" }
829 );
830 }
831
832 Ok(())
833}
834
835fn collect_web_font_sources(manifest: &PaxManifest) -> Vec<WebFontSource> {
836 let mut seen = HashSet::new();
837 let mut collected = Vec::new();
838
839 for component in manifest.components.values() {
840 if let Some(template) = &component.template {
841 for node in template.get_nodes() {
842 if let Some(settings) = &node.settings {
843 collect_setting_elements(settings, &mut seen, &mut collected);
844 }
845 }
846 }
847
848 if let Some(settings) = &component.settings {
849 collect_settings_block_elements(settings, &mut seen, &mut collected);
850 }
851
852 for timeline in &component.timelines {
853 if let Some(playhead) = &timeline.playhead {
854 collect_value_definition(playhead, &mut seen, &mut collected);
855 }
856 for element in &timeline.elements {
857 if let pax_manifest::TimelineBlockElement::SelectorBlock(_, selector_block) =
858 element
859 {
860 for element in &selector_block.elements {
861 if let pax_manifest::TimelineSelectorElement::Track(_, track) = element {
862 if let Some(playhead) = &track.playhead {
863 collect_value_definition(playhead, &mut seen, &mut collected);
864 }
865 if let Some(starting_value) = &track.starting_value {
866 collect_value_definition(starting_value, &mut seen, &mut collected);
867 }
868 for element in &track.elements {
869 if let pax_manifest::TimelineTrackElement::Keyframe(keyframe) =
870 element
871 {
872 collect_value_definition(
873 &keyframe.value,
874 &mut seen,
875 &mut collected,
876 );
877 }
878 }
879 }
880 }
881 }
882 }
883 }
884 }
885
886 collected
887}
888
889fn collect_settings_block_elements(
890 settings: &[SettingsBlockElement],
891 seen: &mut HashSet<WebFontSource>,
892 collected: &mut Vec<WebFontSource>,
893) {
894 for setting in settings {
895 match setting {
896 SettingsBlockElement::SelectorBlock(_, block) => {
897 collect_literal_block_definition(block, seen, collected);
898 }
899 SettingsBlockElement::Conditional(block) => {
900 for branch in &block.branches {
901 collect_settings_block_elements(&branch.elements, seen, collected);
902 }
903 }
904 SettingsBlockElement::Handler(_, _)
905 | SettingsBlockElement::Transition(_, _)
906 | SettingsBlockElement::Comment(_) => {}
907 }
908 }
909}
910
911fn collect_setting_elements(
912 settings: &[SettingElement],
913 seen: &mut HashSet<WebFontSource>,
914 collected: &mut Vec<WebFontSource>,
915) {
916 for setting in settings {
917 match setting {
918 SettingElement::Setting(key, value) => {
919 if key.token_value == "font" {
920 collect_font_object_sources(value, seen, collected);
921 }
922 collect_value_definition(value, seen, collected);
923 }
924 SettingElement::Comment(_) => {}
925 }
926 }
927}
928
929fn collect_literal_block_definition(
930 block: &LiteralBlockDefinition,
931 seen: &mut HashSet<WebFontSource>,
932 collected: &mut Vec<WebFontSource>,
933) {
934 collect_setting_elements(&block.elements, seen, collected);
935}
936
937fn collect_value_definition(
938 value: &ValueDefinition,
939 seen: &mut HashSet<WebFontSource>,
940 collected: &mut Vec<WebFontSource>,
941) {
942 match value {
943 ValueDefinition::Block(block) => collect_literal_block_definition(block, seen, collected),
944 ValueDefinition::Timeline(track) => {
945 if let Some(duration) = &track.duration {
946 collect_value_definition(duration, seen, collected);
947 }
948 if let Some(starting_value) = &track.starting_value {
949 collect_value_definition(starting_value, seen, collected);
950 }
951 for element in &track.elements {
952 if let pax_manifest::TimelineTrackElement::Keyframe(keyframe) = element {
953 collect_value_definition(&keyframe.value, seen, collected);
954 }
955 }
956 }
957 ValueDefinition::Transition(transition) => {
958 if let Some(starting_value) = &transition.starting_value {
959 collect_value_definition(starting_value, seen, collected);
960 }
961 for track in [&transition.enter, &transition.exit].into_iter().flatten() {
962 if let Some(duration) = &track.duration {
963 collect_value_definition(duration, seen, collected);
964 }
965 if let Some(starting_value) = &track.starting_value {
966 collect_value_definition(starting_value, seen, collected);
967 }
968 for element in &track.elements {
969 if let pax_manifest::TimelineTrackElement::Keyframe(keyframe) = element {
970 collect_value_definition(&keyframe.value, seen, collected);
971 }
972 }
973 }
974 }
975 ValueDefinition::Gradient(gradient) => {
976 match &gradient.shape {
977 GradientShapeDefinition::Linear { start, end } => {
978 if let Some(start) = start {
979 collect_value_definition(start, seen, collected);
980 }
981 if let Some(end) = end {
982 collect_value_definition(end, seen, collected);
983 }
984 }
985 GradientShapeDefinition::Radial { start, end, radius } => {
986 collect_value_definition(start, seen, collected);
987 collect_value_definition(end, seen, collected);
988 collect_value_definition(radius, seen, collected);
989 }
990 }
991 for element in &gradient.elements {
992 if let GradientElement::Stop(stop) = element {
993 collect_value_definition(&stop.color, seen, collected);
994 }
995 }
996 }
997 ValueDefinition::Expression(expression_info) => {
998 collect_font_sources_from_expression(&expression_info.expression, seen, collected);
999 }
1000 ValueDefinition::LiteralValue(literal_value) => {
1001 let Ok(serialized) = serde_json::to_value(literal_value) else {
1002 return;
1003 };
1004 collect_font_sources_from_serialized_json(&serialized, seen, collected);
1005 }
1006 ValueDefinition::Undefined
1007 | ValueDefinition::Identifier(_)
1008 | ValueDefinition::DoubleBinding(_)
1009 | ValueDefinition::EventBindingTarget(_) => {}
1010 }
1011}
1012
1013fn collect_font_sources_from_expression(
1014 expression: &PaxExpression,
1015 seen: &mut HashSet<WebFontSource>,
1016 collected: &mut Vec<WebFontSource>,
1017) {
1018 let Ok(serialized) = serde_json::to_value(expression) else {
1019 return;
1020 };
1021 collect_font_sources_from_serialized_json(&serialized, seen, collected);
1022}
1023
1024fn collect_font_object_sources(
1025 value: &ValueDefinition,
1026 seen: &mut HashSet<WebFontSource>,
1027 collected: &mut Vec<WebFontSource>,
1028) {
1029 if let Some(font_source) = parse_font_object_source(value) {
1030 if seen.insert(font_source.clone()) {
1031 collected.push(font_source);
1032 }
1033 }
1034
1035 let Ok(serialized) = serde_json::to_value(value) else {
1036 return;
1037 };
1038 collect_font_object_sources_from_serialized_json(&serialized, seen, collected);
1039}
1040
1041fn collect_font_object_sources_from_serialized_json(
1042 value: &JsonValue,
1043 seen: &mut HashSet<WebFontSource>,
1044 collected: &mut Vec<WebFontSource>,
1045) {
1046 match value {
1047 JsonValue::Object(map) => {
1048 if let Some(JsonValue::Array(fields)) = map.get("Object") {
1049 if let Some(font_source) = parse_font_object_fields(fields) {
1050 if seen.insert(font_source.clone()) {
1051 collected.push(font_source);
1052 }
1053 }
1054 }
1055
1056 for child in map.values() {
1057 collect_font_object_sources_from_serialized_json(child, seen, collected);
1058 }
1059 }
1060 JsonValue::Array(items) => {
1061 for item in items {
1062 collect_font_object_sources_from_serialized_json(item, seen, collected);
1063 }
1064 }
1065 JsonValue::Null | JsonValue::Bool(_) | JsonValue::Number(_) | JsonValue::String(_) => {}
1066 }
1067}
1068
1069fn collect_font_sources_from_serialized_json(
1070 value: &JsonValue,
1071 seen: &mut HashSet<WebFontSource>,
1072 collected: &mut Vec<WebFontSource>,
1073) {
1074 match value {
1075 JsonValue::Object(map) => {
1076 if let Some(JsonValue::Array(fields)) = map.get("Object") {
1077 for field in fields {
1078 let JsonValue::Array(pair) = field else {
1079 continue;
1080 };
1081 if pair.first().and_then(JsonValue::as_str) == Some("font") {
1082 if let Some(font_value) = pair.get(1) {
1083 collect_font_object_sources_from_serialized_json(
1084 font_value, seen, collected,
1085 );
1086 }
1087 }
1088 }
1089 }
1090
1091 if let Some(function_or_enum) = map.get("FunctionOrEnum") {
1092 if let Some(font_source) = parse_font_web_source(function_or_enum) {
1093 if seen.insert(font_source.clone()) {
1094 collected.push(font_source);
1095 }
1096 }
1097 }
1098 if let Some(enum_value) = map.get("Enum") {
1099 if let Some(font_source) = parse_font_web_source(enum_value) {
1100 if seen.insert(font_source.clone()) {
1101 collected.push(font_source);
1102 }
1103 }
1104 }
1105
1106 for child in map.values() {
1107 collect_font_sources_from_serialized_json(child, seen, collected);
1108 }
1109 }
1110 JsonValue::Array(items) => {
1111 for item in items {
1112 collect_font_sources_from_serialized_json(item, seen, collected);
1113 }
1114 }
1115 JsonValue::Null | JsonValue::Bool(_) | JsonValue::Number(_) | JsonValue::String(_) => {}
1116 }
1117}
1118
1119fn parse_font_web_source(value: &JsonValue) -> Option<WebFontSource> {
1120 let JsonValue::Array(parts) = value else {
1121 return None;
1122 };
1123 if parts.len() != 3 {
1124 return None;
1125 }
1126
1127 let name = parts.first()?.as_str()?;
1128 let enum_variant = parts.get(1)?.as_str()?;
1129 if name != "Font" || enum_variant != "Web" {
1130 return None;
1131 }
1132
1133 let JsonValue::Array(args) = parts.get(2)? else {
1134 return None;
1135 };
1136 if args.len() < 2 {
1137 return None;
1138 }
1139
1140 let family = extract_string_literal(&args[0])?;
1141 let url = extract_string_literal(&args[1])?;
1142
1143 Some(WebFontSource { family, url })
1144}
1145
1146fn parse_font_object_source(value: &ValueDefinition) -> Option<WebFontSource> {
1147 let (family, url) = match value {
1148 ValueDefinition::LiteralValue(PaxValue::Object(fields)) => {
1149 let string_field = |expected_key: &str| {
1150 fields.iter().find_map(|(key, value)| match value {
1151 PaxValue::String(value) if key == expected_key => Some(value.clone()),
1152 _ => None,
1153 })
1154 };
1155 let family = string_field("family");
1156 let url = string_field("url");
1157 (family, url)
1158 }
1159 ValueDefinition::Block(block) => {
1160 let mut family = None;
1161 let mut url = None;
1162 for element in &block.elements {
1163 let SettingElement::Setting(key, value) = element else {
1164 continue;
1165 };
1166 let ValueDefinition::LiteralValue(PaxValue::String(value)) = value else {
1167 continue;
1168 };
1169 match key.token_value.as_str() {
1170 "family" => family = Some(value.clone()),
1171 "url" => url = Some(value.clone()),
1172 _ => {}
1173 }
1174 }
1175 (family, url)
1176 }
1177 _ => return None,
1178 };
1179
1180 let family = family?;
1181 let url = url?;
1182 (!url.is_empty()).then_some(WebFontSource { family, url })
1183}
1184
1185fn parse_font_object_fields(fields: &[JsonValue]) -> Option<WebFontSource> {
1186 let mut family = None;
1187 let mut url = None;
1188 for field in fields {
1189 let JsonValue::Array(pair) = field else {
1190 continue;
1191 };
1192 let key = pair.first()?.as_str()?;
1193 let value = pair.get(1)?;
1194 match key {
1195 "family" => family = extract_string_literal(value),
1196 "url" => url = extract_string_literal(value),
1197 _ => {}
1198 }
1199 }
1200
1201 let family = family?;
1202 let url = url?;
1203 (!url.is_empty()).then_some(WebFontSource { family, url })
1204}
1205
1206fn extract_string_literal(value: &JsonValue) -> Option<String> {
1207 match value {
1208 JsonValue::String(value) => Some(value.clone()),
1209 JsonValue::Object(map) => {
1210 if let Some(string_value) = map.get("String") {
1211 return string_value.as_str().map(ToString::to_string);
1212 }
1213 if let Some(primary) = map.get("Primary") {
1214 return extract_string_literal(primary);
1215 }
1216 if let Some(literal) = map.get("Literal") {
1217 return literal.as_str().map(ToString::to_string);
1218 }
1219 None
1220 }
1221 JsonValue::Array(items) => items.iter().find_map(extract_string_literal),
1222 JsonValue::Null | JsonValue::Bool(_) | JsonValue::Number(_) => None,
1223 }
1224}
1225
1226fn vendor_web_font_source(
1227 client: &Client,
1228 font_source: &WebFontSource,
1229 resources_dir: &Path,
1230 vendored_assets: &mut HashSet<String>,
1231 vendored_count: &mut usize,
1232) -> eyre::Result<(), Report> {
1233 let url = Url::parse(&font_source.url)?;
1234 if url.as_str().contains("fonts.googleapis.com/css") {
1235 let css = client
1236 .get(url.clone())
1237 .header(reqwest::header::USER_AGENT, "curl/8.7.1")
1238 .send()?
1239 .error_for_status()?
1240 .text()?;
1241 let asset_urls = parse_css_font_urls(&css, &url);
1242 for asset_url in asset_urls {
1243 vendor_font_asset(
1244 client,
1245 &font_source.family,
1246 &asset_url,
1247 resources_dir,
1248 vendored_assets,
1249 vendored_count,
1250 )?;
1251 }
1252 } else {
1253 vendor_font_asset(
1254 client,
1255 &font_source.family,
1256 &url,
1257 resources_dir,
1258 vendored_assets,
1259 vendored_count,
1260 )?;
1261 }
1262
1263 Ok(())
1264}
1265
1266fn parse_css_font_urls(css: &str, base_url: &Url) -> Vec<Url> {
1267 let mut urls = Vec::new();
1268 let mut seen = HashSet::new();
1269
1270 let mut remaining = css;
1271 while let Some(start) = remaining.find("url(") {
1272 let after_prefix = &remaining[start + 4..];
1273 let Some(end) = after_prefix.find(')') else {
1274 break;
1275 };
1276 let raw_value = after_prefix[..end]
1277 .trim()
1278 .trim_matches(|character| matches!(character, '"' | '\''));
1279
1280 if let Ok(resolved_url) = base_url.join(raw_value) {
1281 if seen.insert(resolved_url.as_str().to_string()) {
1282 urls.push(resolved_url);
1283 }
1284 }
1285
1286 remaining = &after_prefix[end + 1..];
1287 }
1288
1289 urls
1290}
1291
1292fn vendor_font_asset(
1293 client: &Client,
1294 family: &str,
1295 asset_url: &Url,
1296 resources_dir: &Path,
1297 vendored_assets: &mut HashSet<String>,
1298 vendored_count: &mut usize,
1299) -> eyre::Result<(), Report> {
1300 if !vendored_assets.insert(asset_url.as_str().to_string()) {
1301 return Ok(());
1302 }
1303
1304 let response = client.get(asset_url.clone()).send()?.error_for_status()?;
1305 let bytes = response.bytes()?;
1306 let extension = asset_url
1307 .path_segments()
1308 .and_then(|segments| segments.last())
1309 .and_then(|segment| {
1310 segment
1311 .rsplit_once('.')
1312 .map(|(_, ext)| ext.to_ascii_lowercase())
1313 })
1314 .filter(|ext| !ext.is_empty())
1315 .unwrap_or_else(|| "font".to_string());
1316
1317 let file_name = format!(
1318 "pax-font-{}-{}.{}",
1319 sanitize_file_stem(family),
1320 stable_hash(asset_url.as_str()),
1321 extension
1322 );
1323 let destination = resources_dir.join(file_name);
1324 fs::write(&destination, bytes)?;
1325 *vendored_count += 1;
1326
1327 Ok(())
1328}
1329
1330fn sanitize_file_stem(value: &str) -> String {
1331 let sanitized = value
1332 .chars()
1333 .map(|character| {
1334 if character.is_ascii_alphanumeric() {
1335 character.to_ascii_lowercase()
1336 } else {
1337 '-'
1338 }
1339 })
1340 .collect::<String>();
1341 sanitized.trim_matches('-').to_string()
1342}
1343
1344fn stable_hash(value: &str) -> String {
1345 let mut hasher = DefaultHasher::new();
1346 value.hash(&mut hasher);
1347 format!("{:016x}", hasher.finish())
1348}
1349
1350#[cfg(test)]
1351mod tests {
1352 use super::*;
1353 use serde_json::json;
1354
1355 #[test]
1356 fn parses_font_web_literal_enum_shape() {
1357 let value = json!([
1358 "Font",
1359 "Web",
1360 [
1361 { "String": "Oxanium" },
1362 { "String": "https://fonts.googleapis.com/css2?family=Oxanium:wght@400;600;700;800&display=swap" },
1363 { "Enum": ["FontStyle", "Normal", []] },
1364 { "Enum": ["FontWeight", "Bold", []] }
1365 ]
1366 ]);
1367
1368 let source = parse_font_web_source(&value).expect("expected font source");
1369 assert_eq!(source.family, "Oxanium");
1370 assert_eq!(
1371 source.url,
1372 "https://fonts.googleapis.com/css2?family=Oxanium:wght@400;600;700;800&display=swap"
1373 );
1374 }
1375
1376 #[test]
1377 fn parses_font_object_shape() {
1378 let value = json!([
1379 ["family", { "String": "Oxanium" }],
1380 ["url", { "String": "https://fonts.googleapis.com/css2?family=Oxanium:wght@400;600;700;800&display=swap" }],
1381 ["weight", { "Numeric": { "I64": 700 } }]
1382 ]);
1383
1384 let source = parse_font_object_fields(value.as_array().unwrap())
1385 .expect("expected object font source");
1386 assert_eq!(source.family, "Oxanium");
1387 assert_eq!(
1388 source.url,
1389 "https://fonts.googleapis.com/css2?family=Oxanium:wght@400;600;700;800&display=swap"
1390 );
1391 }
1392
1393 #[test]
1394 fn collects_font_web_sources_from_literal_value_json() {
1395 let value = json!({
1396 "LiteralValue": {
1397 "Object": [
1398 [
1399 "font",
1400 {
1401 "Enum": [
1402 "Font",
1403 "Web",
1404 [
1405 { "String": "Space Mono" },
1406 { "String": "https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap" }
1407 ]
1408 ]
1409 }
1410 ]
1411 ]
1412 }
1413 });
1414
1415 let mut seen = HashSet::new();
1416 let mut collected = Vec::new();
1417 collect_font_sources_from_serialized_json(&value, &mut seen, &mut collected);
1418
1419 assert_eq!(collected.len(), 1);
1420 assert_eq!(collected[0].family, "Space Mono");
1421 assert_eq!(
1422 collected[0].url,
1423 "https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap"
1424 );
1425 }
1426
1427 #[test]
1428 fn collects_font_web_sources_from_contextual_object_json() {
1429 let value = json!({
1430 "LiteralValue": {
1431 "Object": [
1432 ["family", { "String": "Space Mono" }],
1433 ["url", { "String": "https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap" }],
1434 ["weight", { "Enum": ["FontWeight", "Bold", []] }]
1435 ]
1436 }
1437 });
1438
1439 let mut seen = HashSet::new();
1440 let mut collected = Vec::new();
1441 collect_font_object_sources_from_serialized_json(&value, &mut seen, &mut collected);
1442
1443 assert_eq!(collected.len(), 1);
1444 assert_eq!(collected[0].family, "Space Mono");
1445 assert_eq!(
1446 collected[0].url,
1447 "https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap"
1448 );
1449 }
1450
1451 #[test]
1452 fn collects_object_font_source_from_static_manifest() {
1453 let manifest = static_analysis::build_manifest(Path::new("../examples/src/increment"))
1454 .expect("increment manifest should build");
1455 let collected = collect_web_font_sources(&manifest);
1456
1457 assert!(collected.iter().any(|source| {
1458 source.family == "Roboto"
1459 && source.url
1460 == "https://fonts.googleapis.com/css2?family=Roboto:wght@300&display=swap"
1461 }));
1462 }
1463
1464 #[test]
1465 fn parses_css_font_urls_from_google_fonts_stylesheet() {
1466 let css = "@font-face {\n font-family: 'Oxanium';\n src: url(https://fonts.gstatic.com/s/oxanium/v20/RrQQboN_4yJ0JmiMe2LE0Q.woff2) format('woff2');\n}\n@font-face {\n src: url('https://fonts.gstatic.com/s/oxanium/v20/RrQQboN_4yJ0JmiMe2zE0Q.woff2') format('woff2');\n}";
1467 let base_url = Url::parse(
1468 "https://fonts.googleapis.com/css2?family=Oxanium:wght@400;600;700;800&display=swap",
1469 )
1470 .expect("expected valid base url");
1471
1472 let urls = parse_css_font_urls(css, &base_url);
1473 assert_eq!(urls.len(), 2);
1474 assert_eq!(
1475 urls[0].as_str(),
1476 "https://fonts.gstatic.com/s/oxanium/v20/RrQQboN_4yJ0JmiMe2LE0Q.woff2"
1477 );
1478 assert_eq!(
1479 urls[1].as_str(),
1480 "https://fonts.gstatic.com/s/oxanium/v20/RrQQboN_4yJ0JmiMe2zE0Q.woff2"
1481 );
1482 }
1483
1484 #[test]
1485 fn web_interface_bundle_rebuilds_when_bundle_is_missing() {
1486 let dir = web_interface_fixture();
1487 fs::remove_file(dir.path().join(WEB_INTERFACE_BUNDLE_FILE))
1488 .expect("bundle should be removable");
1489
1490 assert!(web_interface_bundle_needs_rebuild(dir.path(), true));
1491 }
1492
1493 #[test]
1494 fn web_interface_bundle_skips_fingerprint_check_for_non_libdev_cached_bundle() {
1495 let dir = web_interface_fixture();
1496
1497 assert!(!web_interface_bundle_needs_rebuild(dir.path(), false));
1498 }
1499
1500 #[test]
1501 fn web_interface_bundle_rebuilds_when_libdev_fingerprint_is_missing() {
1502 let dir = web_interface_fixture();
1503
1504 assert!(web_interface_bundle_needs_rebuild(dir.path(), true));
1505 }
1506
1507 #[test]
1508 fn web_interface_bundle_skips_rebuild_when_libdev_fingerprint_matches() {
1509 let dir = web_interface_fixture();
1510 write_web_interface_fingerprint(dir.path()).expect("fingerprint should be written");
1511
1512 assert!(!web_interface_bundle_needs_rebuild(dir.path(), true));
1513 }
1514
1515 #[test]
1516 fn web_interface_bundle_rebuilds_when_source_content_changes() {
1517 let dir = web_interface_fixture();
1518 write_web_interface_fingerprint(dir.path()).expect("fingerprint should be written");
1519 write_file(&dir.path().join("src/index.ts"), b"console.log('changed');");
1520
1521 assert!(web_interface_bundle_needs_rebuild(dir.path(), true));
1522 }
1523
1524 #[test]
1525 fn web_interface_bundle_rebuilds_when_source_file_is_added() {
1526 let dir = web_interface_fixture();
1527 write_web_interface_fingerprint(dir.path()).expect("fingerprint should be written");
1528 write_file(&dir.path().join("src/extra.ts"), b"console.log('extra');");
1529
1530 assert!(web_interface_bundle_needs_rebuild(dir.path(), true));
1531 }
1532
1533 #[test]
1534 fn web_interface_bundle_rebuilds_when_source_file_is_deleted() {
1535 let dir = web_interface_fixture();
1536 write_web_interface_fingerprint(dir.path()).expect("fingerprint should be written");
1537 fs::remove_file(dir.path().join("src/index.ts")).expect("source file should be removable");
1538
1539 assert!(web_interface_bundle_needs_rebuild(dir.path(), true));
1540 }
1541
1542 fn web_interface_fixture() -> tempfile::TempDir {
1543 let dir = tempfile::tempdir().expect("tempdir should be created");
1544 write_file(&dir.path().join("build-interface.sh"), b"#!/bin/sh\n");
1545 write_file(&dir.path().join("package.json"), b"{}");
1546 write_file(&dir.path().join("tsconfig.json"), b"{}");
1547 write_file(&dir.path().join("src/index.ts"), b"console.log('pax');");
1548 write_file(&dir.path().join(WEB_INTERFACE_BUNDLE_FILE), b"bundle");
1549 dir
1550 }
1551
1552 fn write_file(path: &Path, bytes: &[u8]) {
1553 if let Some(parent) = path.parent() {
1554 fs::create_dir_all(parent).expect("fixture parent should be created");
1555 }
1556 fs::write(path, bytes).expect("fixture file should be written");
1557 }
1558
1559 #[test]
1560 fn ipad_alias_maps_to_ipados_target() {
1561 assert_eq!(RunTarget::parse("ipad"), Ok(RunTarget::iPadOS));
1562 assert_eq!(RunTarget::parse("ipados"), Ok(RunTarget::iPadOS));
1563 }
1564
1565 #[test]
1566 fn invalid_target_returns_error_instead_of_unreachable() {
1567 let error = RunTarget::parse("fridge").expect_err("expected invalid target");
1568 assert!(error.contains("unsupported target `fridge`"));
1569 }
1570
1571 #[test]
1572 fn hot_reload_precedence_is_explicit_then_env_then_metadata_then_default() {
1573 assert_eq!(
1574 resolve_hot_reload_mode(
1575 false,
1576 Some(HotReloadMode::Logic),
1577 Some("invalid-lower-priority-value"),
1578 Some("also-invalid"),
1579 )
1580 .unwrap(),
1581 HotReloadMode::Logic
1582 );
1583 assert_eq!(
1584 resolve_hot_reload_mode(false, None, Some("pax"), Some("logic")).unwrap(),
1585 HotReloadMode::Pax
1586 );
1587 assert_eq!(
1588 resolve_hot_reload_mode(false, None, None, Some("off")).unwrap(),
1589 HotReloadMode::Off
1590 );
1591 assert_eq!(
1592 resolve_hot_reload_mode(false, None, None, None).unwrap(),
1593 HotReloadMode::Pax
1594 );
1595 assert_eq!(
1596 resolve_hot_reload_mode(true, Some(HotReloadMode::All), Some("all"), Some("all"),)
1597 .unwrap(),
1598 HotReloadMode::Off
1599 );
1600 }
1601
1602 #[test]
1603 fn created_project_uses_canonical_agent_instructions() {
1604 let dir = tempfile::tempdir().unwrap();
1605 let project = dir.path().join("generated-app");
1606
1607 perform_create(&CreateContext {
1608 path: project.to_string_lossy().to_string(),
1609 is_libdev_mode: false,
1610 version: env!("CARGO_PKG_VERSION").to_string(),
1611 example: None,
1612 })
1613 .unwrap();
1614
1615 assert_eq!(
1616 fs::read_to_string(project.join("AGENTS.md")).unwrap(),
1617 PAX_CREATE_AGENTS_TEMPLATE
1618 );
1619 #[cfg(unix)]
1620 assert_eq!(
1621 fs::read_link(project.join("CLAUDE.md")).unwrap(),
1622 PathBuf::from("AGENTS.md")
1623 );
1624 #[cfg(not(unix))]
1625 assert_eq!(
1626 fs::read_to_string(project.join("CLAUDE.md")).unwrap(),
1627 PAX_CREATE_AGENTS_TEMPLATE
1628 );
1629
1630 let manifest = fs::read_to_string(project.join("Cargo.toml")).unwrap();
1631 assert!(manifest.contains("name = \"generated-app\""));
1632 assert!(manifest.contains("title = \"generated-app\""));
1633 assert!(manifest.contains(&format!(
1634 "pax-kit = {{ version = \"{}\" }}",
1635 env!("CARGO_PKG_VERSION")
1636 )));
1637 let parsed = manifest.parse::<toml_edit::Document>().unwrap();
1638 assert_created_debug_profile(&parsed, "generated-app", "living-quilt");
1639 assert!(parsed["dependencies"]
1640 .as_table()
1641 .unwrap()
1642 .iter()
1643 .all(|(_, dependency)| dependency
1644 .as_inline_table()
1645 .map(|table| !table.contains_key("path"))
1646 .unwrap_or(true)));
1647 assert!(project.join("src/quilt_tile.pax").is_file());
1648 assert!(project.join("src/animated_pax_logo.pax").is_file());
1649 assert!(project.join("src/animated_pax_logo_banner.rs").is_file());
1650 }
1651
1652 #[test]
1653 fn create_supports_override_and_libdev_modes() {
1654 let dir = tempfile::tempdir().unwrap();
1655 let override_project = dir.path().join("counter-app");
1656 perform_create(&CreateContext {
1657 path: override_project.to_string_lossy().to_string(),
1658 is_libdev_mode: false,
1659 version: "9.8.7".to_string(),
1660 example: Some("increment".to_string()),
1661 })
1662 .unwrap();
1663 assert!(fs::read_to_string(override_project.join("src/lib.pax"))
1664 .unwrap()
1665 .contains("num_clicks"));
1666 assert!(fs::read_to_string(override_project.join("Cargo.toml"))
1667 .unwrap()
1668 .contains("pax-kit = { version = \"9.8.7\" }"));
1669 let override_manifest = fs::read_to_string(override_project.join("Cargo.toml"))
1670 .unwrap()
1671 .parse::<toml_edit::Document>()
1672 .unwrap();
1673 assert_created_debug_profile(&override_manifest, "counter-app", "increment");
1674
1675 let libdev_project = dir.path().join("libdev-app");
1676 perform_create(&CreateContext {
1677 path: libdev_project.to_string_lossy().to_string(),
1678 is_libdev_mode: true,
1679 version: "9.8.7".to_string(),
1680 example: Some("ink-and-light".to_string()),
1681 })
1682 .unwrap();
1683 assert_eq!(
1684 fs::read_to_string(libdev_project.join("AGENTS.md")).unwrap(),
1685 fs::read_to_string(
1686 Path::new(env!("CARGO_MANIFEST_DIR")).join("files/new-project/AGENTS.md")
1687 )
1688 .unwrap()
1689 );
1690 let libdev_manifest = fs::read_to_string(libdev_project.join("Cargo.toml"))
1691 .unwrap()
1692 .parse::<toml_edit::Document>()
1693 .unwrap();
1694 assert_created_debug_profile(&libdev_manifest, "libdev-app", "ink-and-light");
1695 assert!(libdev_manifest["dependencies"]
1696 .as_table()
1697 .unwrap()
1698 .iter()
1699 .all(|(_, dependency)| dependency
1700 .as_inline_table()
1701 .map(|table| !table.contains_key("path"))
1702 .unwrap_or(true)));
1703 }
1704
1705 #[test]
1706 fn unknown_example_does_not_create_destination() {
1707 let dir = tempfile::tempdir().unwrap();
1708 let project = dir.path().join("not-created");
1709 let error = perform_create(&CreateContext {
1710 path: project.to_string_lossy().to_string(),
1711 is_libdev_mode: false,
1712 version: env!("CARGO_PKG_VERSION").to_string(),
1713 example: Some("missing".to_string()),
1714 })
1715 .unwrap_err();
1716 assert!(error.contains("living-quilt"));
1717 assert!(error.contains("ink-and-light"));
1718 assert!(error.contains("increment"));
1719 assert!(!project.exists());
1720 }
1721
1722 fn assert_created_debug_profile(doc: &toml_edit::Document, app_name: &str, source_name: &str) {
1723 assert_eq!(doc["profile"]["dev"]["opt-level"].as_integer(), Some(1));
1724 let packages = doc["profile"]["dev"]["package"].as_table().unwrap();
1725 assert_eq!(packages[app_name]["opt-level"].as_integer(), Some(0));
1726 assert_eq!(packages.len(), 1);
1727 assert!(!packages.contains_key(source_name));
1728 }
1729
1730 #[test]
1731 fn created_project_renames_only_its_own_profile_overrides() {
1732 let mut doc = r#"
1733[package]
1734name = "source-example"
1735[profile.dev]
1736opt-level = 1
1737debug = true
1738[profile.dev.package.source-example]
1739opt-level = 0
1740[profile.dev.package.rand]
1741opt-level = 2
1742[profile.dev.build-override]
1743opt-level = 0
1744[profile.release]
1745lto = "thin"
1746[profile.release.package.source-example]
1747debug = 1
1748[profile.fast]
1749inherits = "dev"
1750[profile.fast.package.source-example]
1751codegen-units = 32
1752"#
1753 .parse::<toml_edit::Document>()
1754 .unwrap();
1755 rename_created_project_profile_overrides(&mut doc, "new-app").unwrap();
1756 let doc = doc.to_string().parse::<toml_edit::Document>().unwrap();
1758 for profile in ["dev", "release", "fast"] {
1759 let packages = doc["profile"][profile]["package"].as_table().unwrap();
1760 assert!(packages.contains_key("new-app"));
1761 assert!(!packages.contains_key("source-example"));
1762 }
1763 assert_eq!(doc["profile"]["dev"]["opt-level"].as_integer(), Some(1));
1764 assert_eq!(doc["profile"]["dev"]["debug"].as_bool(), Some(true));
1765 assert_eq!(
1766 doc["profile"]["dev"]["package"]["new-app"]["opt-level"].as_integer(),
1767 Some(0)
1768 );
1769 assert_eq!(
1770 doc["profile"]["dev"]["package"]["rand"]["opt-level"].as_integer(),
1771 Some(2)
1772 );
1773 assert_eq!(
1774 doc["profile"]["dev"]["build-override"]["opt-level"].as_integer(),
1775 Some(0)
1776 );
1777 assert_eq!(doc["profile"]["release"]["lto"].as_str(), Some("thin"));
1778 assert_eq!(
1779 doc["profile"]["release"]["package"]["new-app"]["debug"].as_integer(),
1780 Some(1)
1781 );
1782 assert_eq!(doc["profile"]["fast"]["inherits"].as_str(), Some("dev"));
1783 assert_eq!(
1784 doc["profile"]["fast"]["package"]["new-app"]["codegen-units"].as_integer(),
1785 Some(32)
1786 );
1787 }
1788
1789 #[test]
1790 fn created_project_profile_rename_preserves_absent_profiles_and_unchanged_names() {
1791 for source in [
1792 "[package]\nname = \"source-example\"\n",
1793 "[package]\nname = \"new-app\"\n[profile.dev.package.new-app]\nopt-level = 0\n",
1794 "[package]\nname = \"source-example\"\n[profile.dev.package.rand]\nopt-level = 2\n",
1795 ] {
1796 let mut doc = source.parse::<toml_edit::Document>().unwrap();
1797 rename_created_project_profile_overrides(&mut doc, "new-app").unwrap();
1798 assert_eq!(doc.to_string(), source);
1799 }
1800 }
1801
1802 #[test]
1803 fn created_project_profile_rename_rejects_colliding_dependency_overrides() {
1804 let source = "[package]\nname = \"source-example\"\n\
1805 [profile.dev.package.source-example]\nopt-level = 0\n\
1806 [profile.dev.package.new-app]\nopt-level = 2\n";
1807 let mut doc = source.parse::<toml_edit::Document>().unwrap();
1808 let error = rename_created_project_profile_overrides(&mut doc, "new-app").unwrap_err();
1809 assert!(error.contains("profile.dev.package.source-example"));
1810 assert!(error.contains("already exists"));
1811 assert_eq!(doc.to_string(), source);
1812 }
1813
1814 #[test]
1815 fn non_pax_path_dependencies_are_rejected() {
1816 let mut doc = "[dependencies]\nhelper = { version = \"1\", path = \"../helper\" }\n"
1817 .parse::<toml_edit::Document>()
1818 .unwrap();
1819 let error = sanitize_created_project_dependencies(&mut doc).unwrap_err();
1820 assert!(error.contains("helper"));
1821 }
1822
1823 #[test]
1824 fn invalid_selected_hot_reload_configuration_is_actionable() {
1825 let env_error = resolve_hot_reload_mode(false, None, Some("rust"), Some("pax"))
1826 .expect_err("selected environment value should be validated");
1827 assert!(env_error
1828 .to_string()
1829 .contains("Invalid PAX_HOT_RELOAD value"));
1830
1831 let metadata_error = resolve_hot_reload_mode(false, None, None, Some("templates"))
1832 .expect_err("selected metadata value should be validated");
1833 assert!(metadata_error
1834 .to_string()
1835 .contains("package.metadata.pax.dev.hot_reload"));
1836 }
1837
1838 #[test]
1839 fn mobile_rejects_logic_only_but_accepts_pax_and_all() {
1840 let error = validate_hot_reload_target(&RunTarget::iOS, HotReloadMode::Logic)
1841 .expect_err("mobile has no logic-only reload lane");
1842 assert!(error.to_string().contains("unavailable for iOS and iPadOS"));
1843 assert!(validate_hot_reload_target(&RunTarget::iOS, HotReloadMode::Pax).is_ok());
1844 assert!(validate_hot_reload_target(&RunTarget::iPadOS, HotReloadMode::All).is_ok());
1845 }
1846
1847 fn release_context(should_run_designtime: bool) -> RunContext {
1848 RunContext {
1849 target: RunTarget::Web,
1850 project_path: PathBuf::from("."),
1851 verbose: false,
1852 should_also_run: false,
1853 is_libdev_mode: false,
1854 process_child_ids: Arc::new(Mutex::new(vec![])),
1855 should_run_designtime,
1856 hot_reload: None,
1857 is_release: true,
1858 profile_wasm_size: false,
1859 ios_device: None,
1860 ios_development_team: None,
1861 lifecycle_observer: None,
1862 }
1863 }
1864
1865 struct RecordingLifecycleObserver {
1866 targets: Mutex<Vec<RunTarget>>,
1867 }
1868
1869 impl RunLifecycleObserver for RecordingLifecycleObserver {
1870 fn run_ready(&self, target: RunTarget) {
1871 self.targets.lock().unwrap().push(target);
1872 }
1873 }
1874
1875 #[test]
1876 fn run_lifecycle_notifier_emits_once_per_invocation() {
1877 let observer = Arc::new(RecordingLifecycleObserver {
1878 targets: Mutex::new(Vec::new()),
1879 });
1880 let mut ctx = release_context(false);
1881 ctx.target = RunTarget::iPadOS;
1882 ctx.lifecycle_observer = Some(observer.clone());
1883
1884 let notifier = RunLifecycleNotifier::new(&ctx);
1885 notifier.clone().into_callback().unwrap()();
1886 notifier.notify();
1887 RunLifecycleNotifier::new(&ctx).notify();
1888
1889 assert_eq!(
1890 *observer.targets.lock().unwrap(),
1891 vec![RunTarget::iPadOS, RunTarget::iPadOS]
1892 );
1893 }
1894
1895 #[test]
1896 fn release_build_rejects_devtime_features() {
1897 let error = match prepare_cartridge_sources(&release_context(true)) {
1898 Ok(_) => panic!("release builds should reject designtime cartridge contexts"),
1899 Err(error) => error,
1900 };
1901 assert!(error
1902 .to_string()
1903 .contains("Release builds do not support designtime features"));
1904 }
1905
1906 #[test]
1907 fn release_build_rejects_devtime_enabled_by_project_cargo_defaults() {
1908 let dir = tempfile::tempdir().unwrap();
1909 fs::create_dir_all(dir.path().join("src")).unwrap();
1910 fs::write(dir.path().join("src/lib.rs"), "pub fn app() {}\n").unwrap();
1911 fs::create_dir_all(dir.path().join("pax-engine/src")).unwrap();
1912 fs::write(
1913 dir.path().join("pax-engine/Cargo.toml"),
1914 r#"
1915[package]
1916name = "pax-engine"
1917version = "0.1.0"
1918edition = "2021"
1919
1920[features]
1921designtime = []
1922web = []
1923"#,
1924 )
1925 .unwrap();
1926 fs::write(
1927 dir.path().join("pax-engine/src/lib.rs"),
1928 "pub fn engine() {}\n",
1929 )
1930 .unwrap();
1931 fs::write(
1932 dir.path().join("Cargo.toml"),
1933 r#"
1934[package]
1935name = "release-boundary"
1936version = "0.1.0"
1937
1938[dependencies]
1939pax-engine = { path = "pax-engine" }
1940
1941[features]
1942default = ["authoring"]
1943authoring = ["pax-engine/designtime"]
1944web = ["pax-engine/web"]
1945"#,
1946 )
1947 .unwrap();
1948 let mut ctx = release_context(false);
1949 ctx.project_path = dir.path().to_path_buf();
1950
1951 validate_release_feature_boundary(&ctx).unwrap();
1952 let error =
1953 validate_release_cargo_feature_boundary(&ctx, &["wasm32-unknown-unknown"]).unwrap_err();
1954 let error = error.to_string();
1955 assert!(error.contains(
1956 "runtime dependency path `release-boundary -> pax-engine` enables `designtime`"
1957 ));
1958 assert!(error.contains("Pax enables development features explicitly"));
1959 }
1960}
1961
1962pub fn perform_eject(ctx: &RunContext) -> eyre::Result<(), Report> {
1965 let pax_dir = get_or_create_pax_directory(&ctx.project_path);
1966 eject_interface_files(ctx, &pax_dir);
1967 Ok(())
1968}
1969
1970fn eject_interface_files(ctx: &RunContext, pax_dir: &PathBuf) {
1971 let target_str: &str = (&ctx.target).into();
1972 let target_str_lower = &target_str.to_lowercase();
1973 let custom_interfaces_dir = pax_dir.parent().unwrap().join("interfaces");
1974 let mut target_custom_interface_dir = custom_interfaces_dir.join(target_str_lower);
1975 if ctx.target == RunTarget::Web {
1976 target_custom_interface_dir = target_custom_interface_dir.join("public");
1977 }
1978
1979 let _ = fs::create_dir_all(&target_custom_interface_dir);
1980
1981 let src_path = get_libdev_interface_path(ctx);
1982 if ctx.is_libdev_mode || src_path.exists() {
1983 let _ = copy_dir_recursively(&src_path, &target_custom_interface_dir, &[]);
1984 } else {
1985 let _ = extract_interface_template(ctx, &target_custom_interface_dir);
1986 }
1987
1988 println!(
1989 "Interface files ejected to: {}",
1990 target_custom_interface_dir.display()
1991 );
1992}
1993
1994fn get_libdev_interface_path(ctx: &RunContext) -> PathBuf {
1995 let pax_compiler_root = Path::new(env!("CARGO_MANIFEST_DIR"));
1996 match ctx.target {
1997 RunTarget::Web => pax_compiler_root
1998 .join("files")
1999 .join("interfaces")
2000 .join("web")
2001 .join("public"),
2002 RunTarget::macOS => pax_compiler_root
2003 .join("files")
2004 .join("interfaces")
2005 .join("macos")
2006 .join("pax-app-macos"),
2007 RunTarget::iOS | RunTarget::iPadOS => pax_compiler_root
2008 .join("files")
2009 .join("interfaces")
2010 .join("ios")
2011 .join("pax-app-ios"),
2012 }
2013}
2014
2015fn extract_interface_template(ctx: &RunContext, dest: &Path) -> Result<(), std::io::Error> {
2016 match ctx.target {
2017 RunTarget::Web => PAX_WEB_INTERFACE_TEMPLATE.extract(dest)?,
2018 RunTarget::macOS => PAX_MACOS_INTERFACE_TEMPLATE.extract(dest)?,
2019 RunTarget::iOS | RunTarget::iPadOS => PAX_IOS_INTERFACE_TEMPLATE.extract(dest)?,
2020 }
2021 Ok(())
2022}
2023
2024pub fn perform_clean(path: &str) {
2026 let path = PathBuf::from(path);
2027 let pax_dir = path.join(".pax");
2028 fs::remove_dir_all(&pax_dir).ok();
2029}
2030
2031pub struct CreateContext {
2032 pub path: String,
2033 pub is_libdev_mode: bool,
2034 pub version: String,
2035 pub example: Option<String>,
2036}
2037
2038pub fn perform_create(ctx: &CreateContext) -> Result<(), String> {
2039 let full_path = Path::new(&ctx.path);
2040
2041 if full_path.exists() {
2042 return Err(format!(
2043 "Destination `{}` already exists",
2044 full_path.display()
2045 ));
2046 }
2047 let selected = bundled_examples::selected_example_name(ctx.example.as_deref())?;
2048 let parent = full_path
2049 .parent()
2050 .filter(|path| !path.as_os_str().is_empty())
2051 .unwrap_or(Path::new("."));
2052 fs::create_dir_all(parent)
2053 .map_err(|error| format!("Failed to create {}: {error}", parent.display()))?;
2054 let staging = tempfile::Builder::new()
2055 .prefix(".pax-create-")
2056 .tempdir_in(parent)
2057 .map_err(|error| format!("Failed to stage project: {error}"))?;
2058 bundled_examples::extract_example(Some(&selected), staging.path())?;
2059 write_project_agent_instructions(staging.path(), ctx.is_libdev_mode)?;
2060
2061 let crate_name = full_path
2062 .file_name()
2063 .and_then(|name| name.to_str())
2064 .filter(|name| !name.is_empty())
2065 .ok_or_else(|| format!("Invalid project destination `{}`", full_path.display()))?
2066 .to_string();
2067
2068 let mut doc = fs::read_to_string(staging.path().join("Cargo.toml"))
2069 .map_err(|error| format!("Failed to read Cargo.toml: {error}"))?
2070 .parse::<toml_edit::Document>()
2071 .map_err(|error| format!("Failed to parse Cargo.toml: {error}"))?;
2072
2073 update_pax_dependency_versions(&mut doc, &ctx.version);
2074 sanitize_created_project_dependencies(&mut doc)?;
2075 rename_created_project_profile_overrides(&mut doc, &crate_name)?;
2076
2077 if let Some(package) = doc
2079 .as_table_mut()
2080 .entry("package")
2081 .or_insert_with(toml_edit::table)
2082 .as_table_mut()
2083 {
2084 if let Some(name_item) = package.get_mut("name") {
2085 *name_item = toml_edit::Item::Value(crate_name.clone().into());
2086 }
2087 if let Some(version_item) = package.get_mut("version") {
2088 *version_item = toml_edit::Item::Value(ctx.version.clone().into());
2089 }
2090 if let Some(metadata) = package
2091 .get_mut("metadata")
2092 .and_then(|item| item.as_table_mut())
2093 {
2094 if let Some(pax_metadata) = metadata.get_mut("pax").and_then(|item| item.as_table_mut())
2095 {
2096 if let Some(title_item) = pax_metadata.get_mut("title") {
2097 *title_item = toml_edit::Item::Value(crate_name.clone().into());
2098 }
2099 }
2100 }
2101 }
2102
2103 fs::write(staging.path().join("Cargo.toml"), doc.to_string())
2104 .map_err(|error| format!("Failed to write modified Cargo.toml: {error}"))?;
2105
2106 ensure_claude_md_link(staging.path())?;
2107
2108 let staging_path = staging.keep();
2109 fs::rename(&staging_path, full_path).map_err(|error| {
2110 let _ = fs::remove_dir_all(&staging_path);
2111 format!(
2112 "Failed to finalize project at {}: {error}",
2113 full_path.display()
2114 )
2115 })?;
2116
2117 println!(
2118 "\nCreated `{}` from bundled example `{}`.\nTo run:\n `cd {}`\n `pax-cli run --target=web`",
2119 crate_name,
2120 selected,
2121 full_path.to_str().unwrap(),
2122 );
2123 Ok(())
2124}
2125
2126fn rename_created_project_profile_overrides(
2127 doc: &mut toml_edit::Document,
2128 crate_name: &str,
2129) -> Result<(), String> {
2130 let original_name = doc["package"]["name"]
2131 .as_str()
2132 .ok_or_else(|| "Bundled example is missing package.name".to_string())?
2133 .to_string();
2134 if original_name == crate_name {
2135 return Ok(());
2136 }
2137 let Some(profiles) = doc
2138 .get_mut("profile")
2139 .and_then(|item| item.as_table_like_mut())
2140 else {
2141 return Ok(());
2142 };
2143
2144 for (profile_name, profile) in profiles.iter_mut() {
2147 let Some(packages) = profile
2148 .get_mut("package")
2149 .and_then(|item| item.as_table_like_mut())
2150 else {
2151 continue;
2152 };
2153 if !packages.contains_key(&original_name) {
2154 continue;
2155 }
2156 if packages.contains_key(crate_name) {
2157 return Err(format!(
2158 "Cannot rename profile.{profile_name}.package.{original_name} to \
2159 `{crate_name}`: a package override with that name already exists"
2160 ));
2161 }
2162 let settings = packages.remove(&original_name).unwrap();
2163 packages.insert(crate_name, settings);
2164 }
2165 Ok(())
2166}
2167
2168fn sanitize_created_project_dependencies(doc: &mut toml_edit::Document) -> Result<(), String> {
2169 let Some(dependencies) = doc
2170 .get_mut("dependencies")
2171 .and_then(|item| item.as_table_mut())
2172 else {
2173 return Ok(());
2174 };
2175 for (name, dependency) in dependencies.iter_mut() {
2176 if let toml_edit::Item::Value(toml_edit::Value::InlineTable(table)) = dependency {
2177 if table.contains_key("path") && !name.starts_with("pax-") {
2178 return Err(format!(
2179 "Bundled example dependency `{name}` uses a monorepo-relative path"
2180 ));
2181 }
2182 table.remove("path");
2183 } else if dependency
2184 .as_table()
2185 .map(|table| table.contains_key("path"))
2186 .unwrap_or(false)
2187 {
2188 return Err(format!(
2189 "Bundled example dependency `{name}` uses an unsupported path table"
2190 ));
2191 }
2192 }
2193 Ok(())
2194}
2195
2196fn write_project_agent_instructions(
2197 project_root: &Path,
2198 is_libdev_mode: bool,
2199) -> Result<(), String> {
2200 let destination = project_root.join("AGENTS.md");
2201 if is_libdev_mode {
2202 let source = Path::new(env!("CARGO_MANIFEST_DIR"))
2203 .join("files")
2204 .join("new-project")
2205 .join("AGENTS.md");
2206 fs::copy(&source, &destination).map_err(|err| {
2207 format!(
2208 "Failed to copy project agent instructions from {} to {}: {err}",
2209 source.display(),
2210 destination.display()
2211 )
2212 })?;
2213 } else {
2214 fs::write(&destination, PAX_CREATE_AGENTS_TEMPLATE).map_err(|err| {
2215 format!(
2216 "Failed to write project agent instructions to {}: {err}",
2217 destination.display()
2218 )
2219 })?;
2220 }
2221 Ok(())
2222}
2223
2224fn ensure_claude_md_link(project_root: &Path) -> Result<(), String> {
2225 let claude_path = project_root.join("CLAUDE.md");
2226 if let Ok(metadata) = claude_path.symlink_metadata() {
2227 if metadata.file_type().is_symlink() {
2228 return Ok(());
2229 }
2230 if metadata.is_dir() {
2231 fs::remove_dir_all(&claude_path).map_err(|error| error.to_string())?;
2232 } else {
2233 fs::remove_file(&claude_path).map_err(|error| error.to_string())?;
2234 }
2235 }
2236
2237 #[cfg(unix)]
2238 {
2239 std::os::unix::fs::symlink("AGENTS.md", &claude_path)
2240 .map_err(|error| format!("Failed to create CLAUDE.md symlink: {error}"))?;
2241 }
2242
2243 #[cfg(not(unix))]
2244 {
2245 fs::copy(project_root.join("AGENTS.md"), &claude_path)
2246 .map_err(|error| format!("Failed to copy CLAUDE.md from AGENTS.md: {error}"))?;
2247 }
2248 Ok(())
2249}
2250
2251impl RunTarget {
2252 pub fn parse(input: &str) -> Result<Self, String> {
2253 match input.to_lowercase().as_str() {
2254 "macos" => Ok(RunTarget::macOS),
2255 "web" => Ok(RunTarget::Web),
2256 "ios" => Ok(RunTarget::iOS),
2257 "ipados" | "ipad" => Ok(RunTarget::iPadOS),
2258 _ => Err(format!(
2259 "unsupported target `{input}`; expected one of: web, macos, ios, ipados"
2260 )),
2261 }
2262 }
2263}
2264
2265impl From<&str> for RunTarget {
2266 fn from(input: &str) -> Self {
2267 Self::parse(input).unwrap_or_else(|error| panic!("{error}"))
2268 }
2269}
2270
2271impl<'a> Into<&'a str> for &'a RunTarget {
2272 fn into(self) -> &'a str {
2273 match self {
2274 RunTarget::Web => "Web",
2275 RunTarget::macOS => "macOS",
2276 RunTarget::iOS => "iOS",
2277 RunTarget::iPadOS => "iPadOS",
2278 }
2279 }
2280}
2281
2282#[cfg(unix)]
2283fn pre_exec_hook() -> Result<(), std::io::Error> {
2284 unsafe {
2286 libc::setpgid(0, 0);
2287 }
2288 Ok(())
2289}