Skip to main content

pax_compiler/
lib.rs

1//! # The Pax Compiler Library
2//!
3//! `pax-compiler` is a collection of utilities to facilitate compiling Pax templates into Rust code.
4//!
5//! This library is structured into several modules, each providing different
6//! functionality:
7//!
8//! - `building`: Core structures and functions related to building management.
9//! - `utilities`: Helper functions and common routines used across the library.
10//!
11
12#[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
70/// Receives lifecycle notifications for a running Pax application.
71pub trait RunLifecycleObserver: Send + Sync {
72    /// Called once after the requested target has successfully become ready.
73    fn run_ready(&self, target: RunTarget);
74}
75
76/// Configuration for building or running a Pax project.
77///
78/// Callers that want project, environment, and debug defaults to participate
79/// in hot-reload selection should leave [`RunContext::hot_reload`] as `None`.
80#[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    /// An explicit debug hot-reload policy.
90    ///
91    /// For a running debug designtime session, `None` lets `PAX_HOT_RELOAD`, then
92    /// `[package.metadata.pax.dev].hot_reload`, then the debug default (`pax`)
93    /// select the policy. Release builds always force hot reload off.
94    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    /// An optional observer for successful run lifecycle milestones.
100    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    // Ready callbacks can cross multiple server-launch paths, so the notifier
133    // owns the invocation-local one-shot guard rather than each chassis.
134    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
232/// For the specified file path or current working directory, first compile Pax project,
233/// then run it with a patched build of the `chassis` appropriate for the specified platform
234/// See: pax-compiler-sequence-diagram.png
235pub 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    //7. Build full project from source
254    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    // Copy interface files for relevant path
326    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    //Hack: add a wrapper component so UniqueTemplateNodeIdentifier is a suitable uniqueid, even for root nodes
357    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    // Copy common files for macOS and iOS builds
669    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        // File src is include_dir — recursively extract files from include_dir into full_path
705        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        // Roundtrip through TOML as create does, including renamed table headers.
1757        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
1962/// Ejects the interface files for the specified target platform
1963/// Interface files will then be used to build the project
1964pub 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
2024/// Clean all `.pax` temp files
2025pub 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    // Update the `package` section
2078    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    // Package overrides follow the application's identity. Leaving the source
2145    // example's name here silently loses its fast, unoptimized app rebuilds.
2146    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    // Set a new process group for this command
2285    unsafe {
2286        libc::setpgid(0, 0);
2287    }
2288    Ok(())
2289}