#[macro_use]
extern crate serde;
extern crate core;
mod building;
mod bundled_examples;
mod cartridge_generation;
pub mod dev_session;
pub mod helpers;
mod hot_reload;
pub mod project_metadata;
mod route_metadata;
pub mod static_analysis;
pub mod svg_import;
pub mod design_server;
pub use hot_reload::HotReloadMode;
use color_eyre::eyre;
use color_eyre::eyre::Report;
use eyre::eyre;
use helpers::{copy_dir_recursively, wait_with_output};
use pax_manifest::{
ComponentDefinition, ComponentTemplate, GradientElement, GradientShapeDefinition,
LiteralBlockDefinition, PaxExpression, PaxManifest, SettingElement, SettingsBlockElement,
TemplateNodeDefinition, TypeId, ValueDefinition,
};
use pax_runtime_api::PaxValue;
use reqwest::blocking::Client;
use reqwest::Url;
use serde_json::Value as JsonValue;
use std::collections::hash_map::DefaultHasher;
use std::collections::HashSet;
use std::fs;
use std::io;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
#[cfg(unix)]
use std::os::unix::process::CommandExt;
use crate::building::build_project_with_cartridge;
use crate::cartridge_generation::generate_cartridge_partial_rs;
use crate::project_metadata::PaxProjectMetadata;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};
use walkdir::WalkDir;
use crate::helpers::{
get_or_create_pax_directory, update_pax_dependency_versions, INTERFACE_DIR_NAME, PAX_BADGE,
PAX_CREATE_AGENTS_TEMPLATE, PAX_IOS_INTERFACE_TEMPLATE, PAX_MACOS_INTERFACE_TEMPLATE,
PAX_SWIFT_CARTRIDGE_TEMPLATE, PAX_SWIFT_COMMON_TEMPLATE, PAX_WEB_INTERFACE_TEMPLATE,
};
pub trait RunLifecycleObserver: Send + Sync {
fn run_ready(&self, target: RunTarget);
}
#[derive(Clone)]
pub struct RunContext {
pub target: RunTarget,
pub project_path: PathBuf,
pub verbose: bool,
pub should_also_run: bool,
pub is_libdev_mode: bool,
pub process_child_ids: Arc<Mutex<Vec<u64>>>,
pub should_run_designtime: bool,
pub hot_reload: Option<HotReloadMode>,
pub is_release: bool,
pub profile_wasm_size: bool,
pub ios_device: Option<String>,
pub ios_development_team: Option<String>,
pub lifecycle_observer: Option<Arc<dyn RunLifecycleObserver>>,
}
#[derive(Clone)]
pub(crate) struct RunLifecycleNotifier {
observer: Option<Arc<dyn RunLifecycleObserver>>,
target: RunTarget,
notified: Arc<AtomicBool>,
}
impl RunLifecycleNotifier {
pub(crate) fn new(ctx: &RunContext) -> Self {
Self {
observer: ctx.lifecycle_observer.clone(),
target: ctx.target.clone(),
notified: Arc::new(AtomicBool::new(false)),
}
}
pub(crate) fn notify(&self) {
let Some(observer) = &self.observer else {
return;
};
if self
.notified
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
observer.run_ready(self.target.clone());
}
}
pub(crate) fn into_callback(self) -> Option<Box<dyn FnOnce() + Send>> {
if self.observer.is_some() {
Some(Box::new(move || self.notify()))
} else {
None
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct WebFontSource {
family: String,
url: String,
}
#[derive(Clone, Debug, PartialEq)]
pub enum RunTarget {
#[allow(non_camel_case_types)]
macOS,
Web,
#[allow(non_camel_case_types)]
iOS,
#[allow(non_camel_case_types)]
iPadOS,
}
const WEB_INTERFACE_BUNDLE_FILE: &str = "public/pax-interface-web.js";
const WEB_INTERFACE_FINGERPRINT_FILE: &str = ".pax-interface-web.fingerprint";
const WEB_INTERFACE_HASH_OFFSET: u64 = 0xcbf29ce484222325;
const WEB_INTERFACE_HASH_PRIME: u64 = 0x100000001b3;
pub(crate) struct BuildTimings {
total_start: Instant,
phases: Vec<BuildTimingPhase>,
}
struct BuildTimingPhase {
label: &'static str,
duration: Duration,
}
impl BuildTimings {
fn start() -> Self {
Self {
total_start: Instant::now(),
phases: Vec::new(),
}
}
fn record<T>(&mut self, label: &'static str, operation: impl FnOnce() -> T) -> T {
let start = Instant::now();
let result = operation();
self.phases.push(BuildTimingPhase {
label,
duration: start.elapsed(),
});
result
}
pub(crate) fn print_summary(&self) {
let total = self.total_start.elapsed();
let measured = self
.phases
.iter()
.map(|phase| phase.duration)
.fold(Duration::ZERO, |sum, duration| sum + duration);
let other = total.saturating_sub(measured);
println!(
"{} ⏱️ Build completed in {:.2}s",
*PAX_BADGE,
seconds(total)
);
for phase in &self.phases {
println!(
"{} {:<24} {:.2}s",
*PAX_BADGE,
phase.label,
seconds(phase.duration)
);
}
if seconds(other) >= 0.005 {
println!("{} {:<24} {:.2}s", *PAX_BADGE, "other", seconds(other));
}
}
}
fn seconds(duration: Duration) -> f64 {
duration.as_secs_f64()
}
pub(crate) struct PreparedCartridgeSources {
pub pax_dir: PathBuf,
pub userland_manifest: PaxManifest,
pub assets_dirs: Vec<String>,
pub project_metadata: PaxProjectMetadata,
}
pub fn perform_build(ctx: &RunContext) -> eyre::Result<(PaxManifest, Option<PathBuf>), Report> {
let mut timings = BuildTimings::start();
let prepared = prepare_cartridge_sources_with_timings(ctx, &mut timings)?;
let hot_reload = if ctx.should_also_run && ctx.should_run_designtime {
let mode = resolve_hot_reload_mode(
ctx.is_release,
ctx.hot_reload,
std::env::var("PAX_HOT_RELOAD").ok().as_deref(),
prepared.project_metadata.configured_hot_reload(),
)?;
validate_hot_reload_target(&ctx.target, mode)?;
mode
} else {
HotReloadMode::Off
};
let mut effective_ctx = ctx.clone();
effective_ctx.hot_reload = Some(hot_reload);
println!("{} 🧱 Building project with `cargo`", *PAX_BADGE);
let build_dir = build_project_with_cartridge(
&prepared.pax_dir,
&effective_ctx,
Arc::clone(&ctx.process_child_ids),
prepared.assets_dirs,
prepared.userland_manifest.clone(),
prepared.project_metadata.clone(),
&mut timings,
)?;
Ok((prepared.userland_manifest, build_dir))
}
fn resolve_hot_reload_mode(
is_release: bool,
explicit: Option<HotReloadMode>,
environment: Option<&str>,
project_metadata: Option<&str>,
) -> Result<HotReloadMode, Report> {
if is_release {
return Ok(HotReloadMode::Off);
}
if let Some(mode) = explicit {
return Ok(mode);
}
if let Some(value) = environment {
return value
.parse()
.map_err(|err: String| eyre!("Invalid PAX_HOT_RELOAD value: {err}"));
}
if let Some(value) = project_metadata {
return value.parse().map_err(|err: String| {
eyre!("Invalid package.metadata.pax.dev.hot_reload value: {err}")
});
}
Ok(HotReloadMode::default())
}
fn validate_hot_reload_target(target: &RunTarget, hot_reload: HotReloadMode) -> Result<(), Report> {
if matches!(target, RunTarget::iOS | RunTarget::iPadOS) && hot_reload == HotReloadMode::Logic {
return Err(eyre!(
"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."
));
}
Ok(())
}
pub(crate) fn prepare_cartridge_sources(
ctx: &RunContext,
) -> eyre::Result<PreparedCartridgeSources, Report> {
let mut timings = BuildTimings::start();
prepare_cartridge_sources_with_timings(ctx, &mut timings)
}
fn prepare_cartridge_sources_with_timings(
ctx: &RunContext,
timings: &mut BuildTimings,
) -> eyre::Result<PreparedCartridgeSources, Report> {
validate_release_feature_boundary(ctx)?;
if ctx.target == RunTarget::Web {
timings.record("web interface", || ensure_default_web_interface_bundle(ctx));
}
let project_metadata = timings.record("project metadata", || {
project_metadata::load_project_metadata(&ctx.project_path)
})?;
let pax_dir = get_or_create_pax_directory(&ctx.project_path);
timings.record("copy interface", || {
copy_interface_files_for_target(ctx, &pax_dir)
});
timings.record("project metadata interface", || {
project_metadata::apply_copied_interface_metadata(ctx, &pax_dir, &project_metadata)
})?;
let mut userland_manifest = timings.record("manifest", || {
static_analysis::build_manifest_with_options(
&ctx.project_path,
static_analysis::BuildManifestOptions {
is_designtime: ctx.should_run_designtime,
},
)
})?;
println!("{} 🔎 Built manifest via static analysis", *PAX_BADGE);
if ctx.target == RunTarget::Web {
timings.record("web route metadata", || {
route_metadata::prepare_web_route_metadata(
&pax_dir,
&userland_manifest,
&project_metadata,
ctx.is_release,
)
})?;
}
let merged_manifest = userland_manifest.clone();
let wrapper_type_id = TypeId::build_singleton("ROOT_COMPONENT", Some("RootComponent"));
let mut tnd = TemplateNodeDefinition::default();
tnd.type_id = userland_manifest.main_component_type_id.clone();
let mut wrapper_component_template = ComponentTemplate::new(wrapper_type_id.clone(), None);
wrapper_component_template.add(tnd);
userland_manifest.components.insert(
wrapper_type_id.clone(),
ComponentDefinition {
type_id: wrapper_type_id.clone(),
is_main_component: false,
is_primitive: false,
is_struct_only_component: false,
module_path: "".to_string(),
primitive_instance_import_path: None,
template: Some(wrapper_component_template),
settings: None,
timelines: vec![],
route_branch: None,
},
);
if matches!(
ctx.target,
RunTarget::macOS | RunTarget::iOS | RunTarget::iPadOS
) {
timings.record("apple web fonts", || {
vendor_apple_web_fonts(ctx, &pax_dir, &merged_manifest)
})?;
}
println!("{} 🦀 Generating Rust", *PAX_BADGE);
timings.record("generate rust", || {
generate_cartridge_partial_rs(
&pax_dir,
&merged_manifest,
&userland_manifest,
ctx.should_run_designtime,
ctx.is_release && !ctx.should_run_designtime,
);
});
Ok(PreparedCartridgeSources {
pax_dir,
userland_manifest,
assets_dirs: merged_manifest.assets_dirs,
project_metadata,
})
}
fn validate_release_feature_boundary(ctx: &RunContext) -> Result<(), Report> {
if !ctx.is_release {
return Ok(());
}
if ctx.should_run_designtime {
return Err(eyre!(
"Release builds do not support designtime features. Use a debug build for designtime sessions."
));
}
Ok(())
}
pub(crate) fn validate_release_cargo_feature_boundary(
ctx: &RunContext,
target_triples: &[&str],
) -> Result<(), Report> {
if !ctx.is_release {
return Ok(());
}
let requested_features = vec![match ctx.target {
RunTarget::Web => "web",
RunTarget::macOS => "macos",
RunTarget::iOS | RunTarget::iPadOS => "ios",
}];
let cargo_features = helpers::pax_project_feature_args(&ctx.project_path, &requested_features);
let activators = helpers::pax_project_release_devtime_activators(
&ctx.project_path,
&cargo_features,
target_triples,
)
.map_err(|err| eyre!("Could not verify the release Cargo feature boundary: {err}"))?;
if !activators.is_empty() {
return Err(eyre!(
"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.",
activators.join("; ")
));
}
Ok(())
}
fn ensure_default_web_interface_bundle(ctx: &RunContext) {
let pax_compiler_root = Path::new(env!("CARGO_MANIFEST_DIR"));
let web_interface_root = pax_compiler_root
.join("files")
.join("interfaces")
.join("web");
if !web_interface_root.exists() {
return;
}
if !web_interface_bundle_needs_rebuild(&web_interface_root, ctx.is_libdev_mode) {
return;
}
let mut cmd = Command::new("bash");
cmd.arg("./build-interface.sh")
.current_dir(&web_interface_root)
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit());
#[cfg(unix)]
unsafe {
cmd.pre_exec(pre_exec_hook);
}
let child = cmd
.spawn()
.expect("failed to start web interface bundle build");
let output = wait_with_output(&ctx.process_child_ids, child);
if !output.status.success() {
panic!(
"failed to build the default Pax web interface at {:?}",
web_interface_root
);
}
if ctx.is_libdev_mode {
if let Err(err) = write_web_interface_fingerprint(&web_interface_root) {
eprintln!(
"{} ⚠️ Failed to write web interface fingerprint: {}",
*PAX_BADGE, err
);
}
}
}
fn web_interface_bundle_needs_rebuild(web_interface_root: &Path, is_libdev_mode: bool) -> bool {
if !web_interface_root.join(WEB_INTERFACE_BUNDLE_FILE).is_file() {
return true;
}
if !is_libdev_mode {
return false;
}
current_web_interface_fingerprint(web_interface_root)
.and_then(|current| {
fs::read_to_string(web_interface_fingerprint_path(web_interface_root))
.map(|stored| stored.trim() != current)
})
.unwrap_or(true)
}
fn write_web_interface_fingerprint(web_interface_root: &Path) -> io::Result<()> {
let fingerprint = current_web_interface_fingerprint(web_interface_root)?;
fs::write(
web_interface_fingerprint_path(web_interface_root),
format!("{fingerprint}\n"),
)
}
fn web_interface_fingerprint_path(web_interface_root: &Path) -> PathBuf {
web_interface_root.join(WEB_INTERFACE_FINGERPRINT_FILE)
}
fn current_web_interface_fingerprint(web_interface_root: &Path) -> io::Result<String> {
let mut hasher = WebInterfaceStableHasher::new();
hasher.write(b"pax-web-interface-fingerprint-v1\0");
for input in web_interface_fingerprint_inputs(web_interface_root)? {
hasher.write(input.relative_path.as_bytes());
hasher.write(b"\0");
match input.path {
Some(path) => {
let bytes = fs::read(path)?;
hasher.write(b"file\0");
hasher.write(&(bytes.len() as u64).to_le_bytes());
hasher.write(&bytes);
}
None => hasher.write(b"missing\0"),
}
hasher.write(b"\0");
}
Ok(format!("{:016x}", hasher.finish()))
}
struct WebInterfaceFingerprintInput {
relative_path: String,
path: Option<PathBuf>,
}
fn web_interface_fingerprint_inputs(
web_interface_root: &Path,
) -> io::Result<Vec<WebInterfaceFingerprintInput>> {
let mut inputs = Vec::new();
for relative_path in ["build-interface.sh", "package.json", "tsconfig.json"] {
let path = web_interface_root.join(relative_path);
inputs.push(WebInterfaceFingerprintInput {
relative_path: relative_path.to_string(),
path: path.is_file().then_some(path),
});
}
let src_path = web_interface_root.join("src");
if src_path.is_dir() {
for entry in WalkDir::new(&src_path) {
let entry = entry.map_err(|err| io::Error::new(io::ErrorKind::Other, err))?;
if !entry.file_type().is_file() {
continue;
}
let relative_path = entry
.path()
.strip_prefix(web_interface_root)
.map_err(|err| {
io::Error::new(
io::ErrorKind::InvalidData,
format!(
"failed to make web interface path relative to {}: {err}",
web_interface_root.display()
),
)
})?;
inputs.push(WebInterfaceFingerprintInput {
relative_path: normalized_relative_path(relative_path),
path: Some(entry.path().to_path_buf()),
});
}
} else {
inputs.push(WebInterfaceFingerprintInput {
relative_path: "src".to_string(),
path: None,
});
}
inputs.sort_by(|left, right| left.relative_path.cmp(&right.relative_path));
Ok(inputs)
}
fn normalized_relative_path(path: &Path) -> String {
path.components()
.map(|component| component.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/")
}
struct WebInterfaceStableHasher {
state: u64,
}
impl WebInterfaceStableHasher {
fn new() -> Self {
Self {
state: WEB_INTERFACE_HASH_OFFSET,
}
}
fn write(&mut self, bytes: &[u8]) {
for byte in bytes {
self.state ^= *byte as u64;
self.state = self.state.wrapping_mul(WEB_INTERFACE_HASH_PRIME);
}
}
fn finish(&self) -> u64 {
self.state
}
}
fn build_interface_dir_name(target: &RunTarget) -> &'static str {
match target {
RunTarget::Web => "web",
RunTarget::macOS => "macos",
RunTarget::iOS | RunTarget::iPadOS => "ios",
}
}
fn custom_interface_dir_candidates(target: &RunTarget) -> &'static [&'static str] {
match target {
RunTarget::Web => &["web"],
RunTarget::macOS => &["macos"],
RunTarget::iOS => &["ios"],
RunTarget::iPadOS => &["ipados", "ios"],
}
}
fn copy_interface_files_for_target(ctx: &RunContext, pax_dir: &PathBuf) {
let interface_path = pax_dir
.join(INTERFACE_DIR_NAME)
.join(build_interface_dir_name(&ctx.target));
let _ = fs::remove_dir_all(&interface_path);
let _ = fs::create_dir_all(&interface_path);
let custom_interface = custom_interface_dir_candidates(&ctx.target)
.iter()
.map(|candidate| {
let mut interface_path = pax_dir.parent().unwrap().join("interfaces").join(candidate);
if ctx.target == RunTarget::Web {
interface_path = interface_path.join("public");
}
interface_path
})
.find(|path| path.exists());
if let Some(custom_interface) = custom_interface {
copy_interface_files(&custom_interface, &interface_path);
} else {
copy_default_interface_files(&interface_path, ctx);
}
if matches!(
ctx.target,
RunTarget::macOS | RunTarget::iOS | RunTarget::iPadOS
) {
let common_dest = pax_dir.join(INTERFACE_DIR_NAME).join("common");
copy_common_swift_files(ctx, &common_dest);
}
}
fn copy_interface_files(src: &Path, dest: &Path) {
copy_dir_recursively(src, dest, &[]).expect("Failed to copy interface files");
}
fn copy_default_interface_files(interface_path: &Path, ctx: &RunContext) {
let pax_compiler_root = Path::new(env!("CARGO_MANIFEST_DIR"));
let interface_src = match ctx.target {
RunTarget::Web => pax_compiler_root
.join("files")
.join("interfaces")
.join("web")
.join("public"),
RunTarget::macOS => pax_compiler_root
.join("files")
.join("interfaces")
.join("macos"),
RunTarget::iOS | RunTarget::iPadOS => pax_compiler_root
.join("files")
.join("interfaces")
.join("ios"),
};
if ctx.is_libdev_mode || interface_src.exists() {
copy_dir_recursively(&interface_src, interface_path, &[])
.expect("Failed to copy interface files");
} else {
match ctx.target {
RunTarget::Web => PAX_WEB_INTERFACE_TEMPLATE
.extract(interface_path)
.expect("Failed to extract web interface files"),
RunTarget::macOS => PAX_MACOS_INTERFACE_TEMPLATE
.extract(interface_path)
.expect("Failed to extract macos interface files"),
RunTarget::iOS | RunTarget::iPadOS => PAX_IOS_INTERFACE_TEMPLATE
.extract(interface_path)
.expect("Failed to extract ios interface files"),
}
}
}
fn copy_common_swift_files(ctx: &RunContext, common_dest: &Path) {
let _ = std::fs::remove_dir_all(common_dest);
std::fs::create_dir_all(common_dest).expect("Failed to create swift common destination");
let pax_compiler_root = Path::new(env!("CARGO_MANIFEST_DIR"));
let common_swift_cartridge_src = pax_compiler_root
.join("files")
.join("swift")
.join("pax-swift-cartridge");
let common_swift_common_src = pax_compiler_root
.join("files")
.join("swift")
.join("pax-swift-common");
if ctx.is_libdev_mode
|| (common_swift_cartridge_src.exists() && common_swift_common_src.exists())
{
let common_swift_cartridge_dest = common_dest.join("pax-swift-cartridge");
let common_swift_common_dest = common_dest.join("pax-swift-common");
copy_dir_recursively(
&common_swift_cartridge_src,
&common_swift_cartridge_dest,
&[".build"],
)
.expect("Failed to copy swift cartridge files");
copy_dir_recursively(
&common_swift_common_src,
&common_swift_common_dest,
&[".build"],
)
.expect("Failed to copy swift common files");
} else {
let common_swift_common_dest = common_dest.join("pax-swift-common");
let common_swift_cartridge_dest = common_dest.join("pax-swift-cartridge");
fs::create_dir_all(&common_swift_common_dest)
.expect("Failed to create swift common destination");
fs::create_dir_all(&common_swift_cartridge_dest)
.expect("Failed to create swift cartridge destination");
PAX_SWIFT_COMMON_TEMPLATE
.extract(&common_swift_common_dest)
.expect("Failed to extract swift common template files");
PAX_SWIFT_CARTRIDGE_TEMPLATE
.extract(&common_swift_cartridge_dest)
.expect("Failed to extract swift cartridge template files");
}
}
fn vendor_apple_web_fonts(
ctx: &RunContext,
pax_dir: &Path,
manifest: &PaxManifest,
) -> eyre::Result<(), Report> {
if !matches!(
ctx.target,
RunTarget::macOS | RunTarget::iOS | RunTarget::iPadOS
) {
return Ok(());
}
let font_sources = collect_web_font_sources(manifest);
if font_sources.is_empty() {
return Ok(());
}
let resources_dir = pax_dir
.join(INTERFACE_DIR_NAME)
.join("common")
.join("pax-swift-cartridge")
.join("Sources")
.join("PaxCartridgeAssets")
.join("Resources");
fs::create_dir_all(&resources_dir)?;
if let Ok(existing_entries) = fs::read_dir(&resources_dir) {
for entry in existing_entries.flatten() {
let path = entry.path();
let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
continue;
};
if file_name.starts_with("pax-font-") {
let _ = fs::remove_file(path);
}
}
}
let client = Client::builder().build()?;
let mut vendored_assets: HashSet<String> = HashSet::new();
let mut vendored_count = 0usize;
for font_source in font_sources {
if let Err(error) = vendor_web_font_source(
&client,
&font_source,
&resources_dir,
&mut vendored_assets,
&mut vendored_count,
) {
println!(
"{} ⚠️ Failed to vendor Apple font '{}' from {}: {}",
*PAX_BADGE, font_source.family, font_source.url, error
);
}
}
if vendored_count > 0 {
println!(
"{} 🔤 Vendored {} Apple font asset{} for bundled native builds",
*PAX_BADGE,
vendored_count,
if vendored_count == 1 { "" } else { "s" }
);
}
Ok(())
}
fn collect_web_font_sources(manifest: &PaxManifest) -> Vec<WebFontSource> {
let mut seen = HashSet::new();
let mut collected = Vec::new();
for component in manifest.components.values() {
if let Some(template) = &component.template {
for node in template.get_nodes() {
if let Some(settings) = &node.settings {
collect_setting_elements(settings, &mut seen, &mut collected);
}
}
}
if let Some(settings) = &component.settings {
collect_settings_block_elements(settings, &mut seen, &mut collected);
}
for timeline in &component.timelines {
if let Some(playhead) = &timeline.playhead {
collect_value_definition(playhead, &mut seen, &mut collected);
}
for element in &timeline.elements {
if let pax_manifest::TimelineBlockElement::SelectorBlock(_, selector_block) =
element
{
for element in &selector_block.elements {
if let pax_manifest::TimelineSelectorElement::Track(_, track) = element {
if let Some(playhead) = &track.playhead {
collect_value_definition(playhead, &mut seen, &mut collected);
}
if let Some(starting_value) = &track.starting_value {
collect_value_definition(starting_value, &mut seen, &mut collected);
}
for element in &track.elements {
if let pax_manifest::TimelineTrackElement::Keyframe(keyframe) =
element
{
collect_value_definition(
&keyframe.value,
&mut seen,
&mut collected,
);
}
}
}
}
}
}
}
}
collected
}
fn collect_settings_block_elements(
settings: &[SettingsBlockElement],
seen: &mut HashSet<WebFontSource>,
collected: &mut Vec<WebFontSource>,
) {
for setting in settings {
match setting {
SettingsBlockElement::SelectorBlock(_, block) => {
collect_literal_block_definition(block, seen, collected);
}
SettingsBlockElement::Conditional(block) => {
for branch in &block.branches {
collect_settings_block_elements(&branch.elements, seen, collected);
}
}
SettingsBlockElement::Handler(_, _)
| SettingsBlockElement::Transition(_, _)
| SettingsBlockElement::Comment(_) => {}
}
}
}
fn collect_setting_elements(
settings: &[SettingElement],
seen: &mut HashSet<WebFontSource>,
collected: &mut Vec<WebFontSource>,
) {
for setting in settings {
match setting {
SettingElement::Setting(key, value) => {
if key.token_value == "font" {
collect_font_object_sources(value, seen, collected);
}
collect_value_definition(value, seen, collected);
}
SettingElement::Comment(_) => {}
}
}
}
fn collect_literal_block_definition(
block: &LiteralBlockDefinition,
seen: &mut HashSet<WebFontSource>,
collected: &mut Vec<WebFontSource>,
) {
collect_setting_elements(&block.elements, seen, collected);
}
fn collect_value_definition(
value: &ValueDefinition,
seen: &mut HashSet<WebFontSource>,
collected: &mut Vec<WebFontSource>,
) {
match value {
ValueDefinition::Block(block) => collect_literal_block_definition(block, seen, collected),
ValueDefinition::Timeline(track) => {
if let Some(duration) = &track.duration {
collect_value_definition(duration, seen, collected);
}
if let Some(starting_value) = &track.starting_value {
collect_value_definition(starting_value, seen, collected);
}
for element in &track.elements {
if let pax_manifest::TimelineTrackElement::Keyframe(keyframe) = element {
collect_value_definition(&keyframe.value, seen, collected);
}
}
}
ValueDefinition::Transition(transition) => {
if let Some(starting_value) = &transition.starting_value {
collect_value_definition(starting_value, seen, collected);
}
for track in [&transition.enter, &transition.exit].into_iter().flatten() {
if let Some(duration) = &track.duration {
collect_value_definition(duration, seen, collected);
}
if let Some(starting_value) = &track.starting_value {
collect_value_definition(starting_value, seen, collected);
}
for element in &track.elements {
if let pax_manifest::TimelineTrackElement::Keyframe(keyframe) = element {
collect_value_definition(&keyframe.value, seen, collected);
}
}
}
}
ValueDefinition::Gradient(gradient) => {
match &gradient.shape {
GradientShapeDefinition::Linear { start, end } => {
if let Some(start) = start {
collect_value_definition(start, seen, collected);
}
if let Some(end) = end {
collect_value_definition(end, seen, collected);
}
}
GradientShapeDefinition::Radial { start, end, radius } => {
collect_value_definition(start, seen, collected);
collect_value_definition(end, seen, collected);
collect_value_definition(radius, seen, collected);
}
}
for element in &gradient.elements {
if let GradientElement::Stop(stop) = element {
collect_value_definition(&stop.color, seen, collected);
}
}
}
ValueDefinition::Expression(expression_info) => {
collect_font_sources_from_expression(&expression_info.expression, seen, collected);
}
ValueDefinition::LiteralValue(literal_value) => {
let Ok(serialized) = serde_json::to_value(literal_value) else {
return;
};
collect_font_sources_from_serialized_json(&serialized, seen, collected);
}
ValueDefinition::Undefined
| ValueDefinition::Identifier(_)
| ValueDefinition::DoubleBinding(_)
| ValueDefinition::EventBindingTarget(_) => {}
}
}
fn collect_font_sources_from_expression(
expression: &PaxExpression,
seen: &mut HashSet<WebFontSource>,
collected: &mut Vec<WebFontSource>,
) {
let Ok(serialized) = serde_json::to_value(expression) else {
return;
};
collect_font_sources_from_serialized_json(&serialized, seen, collected);
}
fn collect_font_object_sources(
value: &ValueDefinition,
seen: &mut HashSet<WebFontSource>,
collected: &mut Vec<WebFontSource>,
) {
if let Some(font_source) = parse_font_object_source(value) {
if seen.insert(font_source.clone()) {
collected.push(font_source);
}
}
let Ok(serialized) = serde_json::to_value(value) else {
return;
};
collect_font_object_sources_from_serialized_json(&serialized, seen, collected);
}
fn collect_font_object_sources_from_serialized_json(
value: &JsonValue,
seen: &mut HashSet<WebFontSource>,
collected: &mut Vec<WebFontSource>,
) {
match value {
JsonValue::Object(map) => {
if let Some(JsonValue::Array(fields)) = map.get("Object") {
if let Some(font_source) = parse_font_object_fields(fields) {
if seen.insert(font_source.clone()) {
collected.push(font_source);
}
}
}
for child in map.values() {
collect_font_object_sources_from_serialized_json(child, seen, collected);
}
}
JsonValue::Array(items) => {
for item in items {
collect_font_object_sources_from_serialized_json(item, seen, collected);
}
}
JsonValue::Null | JsonValue::Bool(_) | JsonValue::Number(_) | JsonValue::String(_) => {}
}
}
fn collect_font_sources_from_serialized_json(
value: &JsonValue,
seen: &mut HashSet<WebFontSource>,
collected: &mut Vec<WebFontSource>,
) {
match value {
JsonValue::Object(map) => {
if let Some(JsonValue::Array(fields)) = map.get("Object") {
for field in fields {
let JsonValue::Array(pair) = field else {
continue;
};
if pair.first().and_then(JsonValue::as_str) == Some("font") {
if let Some(font_value) = pair.get(1) {
collect_font_object_sources_from_serialized_json(
font_value, seen, collected,
);
}
}
}
}
if let Some(function_or_enum) = map.get("FunctionOrEnum") {
if let Some(font_source) = parse_font_web_source(function_or_enum) {
if seen.insert(font_source.clone()) {
collected.push(font_source);
}
}
}
if let Some(enum_value) = map.get("Enum") {
if let Some(font_source) = parse_font_web_source(enum_value) {
if seen.insert(font_source.clone()) {
collected.push(font_source);
}
}
}
for child in map.values() {
collect_font_sources_from_serialized_json(child, seen, collected);
}
}
JsonValue::Array(items) => {
for item in items {
collect_font_sources_from_serialized_json(item, seen, collected);
}
}
JsonValue::Null | JsonValue::Bool(_) | JsonValue::Number(_) | JsonValue::String(_) => {}
}
}
fn parse_font_web_source(value: &JsonValue) -> Option<WebFontSource> {
let JsonValue::Array(parts) = value else {
return None;
};
if parts.len() != 3 {
return None;
}
let name = parts.first()?.as_str()?;
let enum_variant = parts.get(1)?.as_str()?;
if name != "Font" || enum_variant != "Web" {
return None;
}
let JsonValue::Array(args) = parts.get(2)? else {
return None;
};
if args.len() < 2 {
return None;
}
let family = extract_string_literal(&args[0])?;
let url = extract_string_literal(&args[1])?;
Some(WebFontSource { family, url })
}
fn parse_font_object_source(value: &ValueDefinition) -> Option<WebFontSource> {
let (family, url) = match value {
ValueDefinition::LiteralValue(PaxValue::Object(fields)) => {
let string_field = |expected_key: &str| {
fields.iter().find_map(|(key, value)| match value {
PaxValue::String(value) if key == expected_key => Some(value.clone()),
_ => None,
})
};
let family = string_field("family");
let url = string_field("url");
(family, url)
}
ValueDefinition::Block(block) => {
let mut family = None;
let mut url = None;
for element in &block.elements {
let SettingElement::Setting(key, value) = element else {
continue;
};
let ValueDefinition::LiteralValue(PaxValue::String(value)) = value else {
continue;
};
match key.token_value.as_str() {
"family" => family = Some(value.clone()),
"url" => url = Some(value.clone()),
_ => {}
}
}
(family, url)
}
_ => return None,
};
let family = family?;
let url = url?;
(!url.is_empty()).then_some(WebFontSource { family, url })
}
fn parse_font_object_fields(fields: &[JsonValue]) -> Option<WebFontSource> {
let mut family = None;
let mut url = None;
for field in fields {
let JsonValue::Array(pair) = field else {
continue;
};
let key = pair.first()?.as_str()?;
let value = pair.get(1)?;
match key {
"family" => family = extract_string_literal(value),
"url" => url = extract_string_literal(value),
_ => {}
}
}
let family = family?;
let url = url?;
(!url.is_empty()).then_some(WebFontSource { family, url })
}
fn extract_string_literal(value: &JsonValue) -> Option<String> {
match value {
JsonValue::String(value) => Some(value.clone()),
JsonValue::Object(map) => {
if let Some(string_value) = map.get("String") {
return string_value.as_str().map(ToString::to_string);
}
if let Some(primary) = map.get("Primary") {
return extract_string_literal(primary);
}
if let Some(literal) = map.get("Literal") {
return literal.as_str().map(ToString::to_string);
}
None
}
JsonValue::Array(items) => items.iter().find_map(extract_string_literal),
JsonValue::Null | JsonValue::Bool(_) | JsonValue::Number(_) => None,
}
}
fn vendor_web_font_source(
client: &Client,
font_source: &WebFontSource,
resources_dir: &Path,
vendored_assets: &mut HashSet<String>,
vendored_count: &mut usize,
) -> eyre::Result<(), Report> {
let url = Url::parse(&font_source.url)?;
if url.as_str().contains("fonts.googleapis.com/css") {
let css = client
.get(url.clone())
.header(reqwest::header::USER_AGENT, "curl/8.7.1")
.send()?
.error_for_status()?
.text()?;
let asset_urls = parse_css_font_urls(&css, &url);
for asset_url in asset_urls {
vendor_font_asset(
client,
&font_source.family,
&asset_url,
resources_dir,
vendored_assets,
vendored_count,
)?;
}
} else {
vendor_font_asset(
client,
&font_source.family,
&url,
resources_dir,
vendored_assets,
vendored_count,
)?;
}
Ok(())
}
fn parse_css_font_urls(css: &str, base_url: &Url) -> Vec<Url> {
let mut urls = Vec::new();
let mut seen = HashSet::new();
let mut remaining = css;
while let Some(start) = remaining.find("url(") {
let after_prefix = &remaining[start + 4..];
let Some(end) = after_prefix.find(')') else {
break;
};
let raw_value = after_prefix[..end]
.trim()
.trim_matches(|character| matches!(character, '"' | '\''));
if let Ok(resolved_url) = base_url.join(raw_value) {
if seen.insert(resolved_url.as_str().to_string()) {
urls.push(resolved_url);
}
}
remaining = &after_prefix[end + 1..];
}
urls
}
fn vendor_font_asset(
client: &Client,
family: &str,
asset_url: &Url,
resources_dir: &Path,
vendored_assets: &mut HashSet<String>,
vendored_count: &mut usize,
) -> eyre::Result<(), Report> {
if !vendored_assets.insert(asset_url.as_str().to_string()) {
return Ok(());
}
let response = client.get(asset_url.clone()).send()?.error_for_status()?;
let bytes = response.bytes()?;
let extension = asset_url
.path_segments()
.and_then(|segments| segments.last())
.and_then(|segment| {
segment
.rsplit_once('.')
.map(|(_, ext)| ext.to_ascii_lowercase())
})
.filter(|ext| !ext.is_empty())
.unwrap_or_else(|| "font".to_string());
let file_name = format!(
"pax-font-{}-{}.{}",
sanitize_file_stem(family),
stable_hash(asset_url.as_str()),
extension
);
let destination = resources_dir.join(file_name);
fs::write(&destination, bytes)?;
*vendored_count += 1;
Ok(())
}
fn sanitize_file_stem(value: &str) -> String {
let sanitized = value
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() {
character.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>();
sanitized.trim_matches('-').to_string()
}
fn stable_hash(value: &str) -> String {
let mut hasher = DefaultHasher::new();
value.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn parses_font_web_literal_enum_shape() {
let value = json!([
"Font",
"Web",
[
{ "String": "Oxanium" },
{ "String": "https://fonts.googleapis.com/css2?family=Oxanium:wght@400;600;700;800&display=swap" },
{ "Enum": ["FontStyle", "Normal", []] },
{ "Enum": ["FontWeight", "Bold", []] }
]
]);
let source = parse_font_web_source(&value).expect("expected font source");
assert_eq!(source.family, "Oxanium");
assert_eq!(
source.url,
"https://fonts.googleapis.com/css2?family=Oxanium:wght@400;600;700;800&display=swap"
);
}
#[test]
fn parses_font_object_shape() {
let value = json!([
["family", { "String": "Oxanium" }],
["url", { "String": "https://fonts.googleapis.com/css2?family=Oxanium:wght@400;600;700;800&display=swap" }],
["weight", { "Numeric": { "I64": 700 } }]
]);
let source = parse_font_object_fields(value.as_array().unwrap())
.expect("expected object font source");
assert_eq!(source.family, "Oxanium");
assert_eq!(
source.url,
"https://fonts.googleapis.com/css2?family=Oxanium:wght@400;600;700;800&display=swap"
);
}
#[test]
fn collects_font_web_sources_from_literal_value_json() {
let value = json!({
"LiteralValue": {
"Object": [
[
"font",
{
"Enum": [
"Font",
"Web",
[
{ "String": "Space Mono" },
{ "String": "https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap" }
]
]
}
]
]
}
});
let mut seen = HashSet::new();
let mut collected = Vec::new();
collect_font_sources_from_serialized_json(&value, &mut seen, &mut collected);
assert_eq!(collected.len(), 1);
assert_eq!(collected[0].family, "Space Mono");
assert_eq!(
collected[0].url,
"https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap"
);
}
#[test]
fn collects_font_web_sources_from_contextual_object_json() {
let value = json!({
"LiteralValue": {
"Object": [
["family", { "String": "Space Mono" }],
["url", { "String": "https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap" }],
["weight", { "Enum": ["FontWeight", "Bold", []] }]
]
}
});
let mut seen = HashSet::new();
let mut collected = Vec::new();
collect_font_object_sources_from_serialized_json(&value, &mut seen, &mut collected);
assert_eq!(collected.len(), 1);
assert_eq!(collected[0].family, "Space Mono");
assert_eq!(
collected[0].url,
"https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap"
);
}
#[test]
fn collects_object_font_source_from_static_manifest() {
let manifest = static_analysis::build_manifest(Path::new("../examples/src/increment"))
.expect("increment manifest should build");
let collected = collect_web_font_sources(&manifest);
assert!(collected.iter().any(|source| {
source.family == "Roboto"
&& source.url
== "https://fonts.googleapis.com/css2?family=Roboto:wght@300&display=swap"
}));
}
#[test]
fn parses_css_font_urls_from_google_fonts_stylesheet() {
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}";
let base_url = Url::parse(
"https://fonts.googleapis.com/css2?family=Oxanium:wght@400;600;700;800&display=swap",
)
.expect("expected valid base url");
let urls = parse_css_font_urls(css, &base_url);
assert_eq!(urls.len(), 2);
assert_eq!(
urls[0].as_str(),
"https://fonts.gstatic.com/s/oxanium/v20/RrQQboN_4yJ0JmiMe2LE0Q.woff2"
);
assert_eq!(
urls[1].as_str(),
"https://fonts.gstatic.com/s/oxanium/v20/RrQQboN_4yJ0JmiMe2zE0Q.woff2"
);
}
#[test]
fn web_interface_bundle_rebuilds_when_bundle_is_missing() {
let dir = web_interface_fixture();
fs::remove_file(dir.path().join(WEB_INTERFACE_BUNDLE_FILE))
.expect("bundle should be removable");
assert!(web_interface_bundle_needs_rebuild(dir.path(), true));
}
#[test]
fn web_interface_bundle_skips_fingerprint_check_for_non_libdev_cached_bundle() {
let dir = web_interface_fixture();
assert!(!web_interface_bundle_needs_rebuild(dir.path(), false));
}
#[test]
fn web_interface_bundle_rebuilds_when_libdev_fingerprint_is_missing() {
let dir = web_interface_fixture();
assert!(web_interface_bundle_needs_rebuild(dir.path(), true));
}
#[test]
fn web_interface_bundle_skips_rebuild_when_libdev_fingerprint_matches() {
let dir = web_interface_fixture();
write_web_interface_fingerprint(dir.path()).expect("fingerprint should be written");
assert!(!web_interface_bundle_needs_rebuild(dir.path(), true));
}
#[test]
fn web_interface_bundle_rebuilds_when_source_content_changes() {
let dir = web_interface_fixture();
write_web_interface_fingerprint(dir.path()).expect("fingerprint should be written");
write_file(&dir.path().join("src/index.ts"), b"console.log('changed');");
assert!(web_interface_bundle_needs_rebuild(dir.path(), true));
}
#[test]
fn web_interface_bundle_rebuilds_when_source_file_is_added() {
let dir = web_interface_fixture();
write_web_interface_fingerprint(dir.path()).expect("fingerprint should be written");
write_file(&dir.path().join("src/extra.ts"), b"console.log('extra');");
assert!(web_interface_bundle_needs_rebuild(dir.path(), true));
}
#[test]
fn web_interface_bundle_rebuilds_when_source_file_is_deleted() {
let dir = web_interface_fixture();
write_web_interface_fingerprint(dir.path()).expect("fingerprint should be written");
fs::remove_file(dir.path().join("src/index.ts")).expect("source file should be removable");
assert!(web_interface_bundle_needs_rebuild(dir.path(), true));
}
fn web_interface_fixture() -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir should be created");
write_file(&dir.path().join("build-interface.sh"), b"#!/bin/sh\n");
write_file(&dir.path().join("package.json"), b"{}");
write_file(&dir.path().join("tsconfig.json"), b"{}");
write_file(&dir.path().join("src/index.ts"), b"console.log('pax');");
write_file(&dir.path().join(WEB_INTERFACE_BUNDLE_FILE), b"bundle");
dir
}
fn write_file(path: &Path, bytes: &[u8]) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("fixture parent should be created");
}
fs::write(path, bytes).expect("fixture file should be written");
}
#[test]
fn ipad_alias_maps_to_ipados_target() {
assert_eq!(RunTarget::parse("ipad"), Ok(RunTarget::iPadOS));
assert_eq!(RunTarget::parse("ipados"), Ok(RunTarget::iPadOS));
}
#[test]
fn invalid_target_returns_error_instead_of_unreachable() {
let error = RunTarget::parse("fridge").expect_err("expected invalid target");
assert!(error.contains("unsupported target `fridge`"));
}
#[test]
fn hot_reload_precedence_is_explicit_then_env_then_metadata_then_default() {
assert_eq!(
resolve_hot_reload_mode(
false,
Some(HotReloadMode::Logic),
Some("invalid-lower-priority-value"),
Some("also-invalid"),
)
.unwrap(),
HotReloadMode::Logic
);
assert_eq!(
resolve_hot_reload_mode(false, None, Some("pax"), Some("logic")).unwrap(),
HotReloadMode::Pax
);
assert_eq!(
resolve_hot_reload_mode(false, None, None, Some("off")).unwrap(),
HotReloadMode::Off
);
assert_eq!(
resolve_hot_reload_mode(false, None, None, None).unwrap(),
HotReloadMode::Pax
);
assert_eq!(
resolve_hot_reload_mode(true, Some(HotReloadMode::All), Some("all"), Some("all"),)
.unwrap(),
HotReloadMode::Off
);
}
#[test]
fn created_project_uses_canonical_agent_instructions() {
let dir = tempfile::tempdir().unwrap();
let project = dir.path().join("generated-app");
perform_create(&CreateContext {
path: project.to_string_lossy().to_string(),
is_libdev_mode: false,
version: env!("CARGO_PKG_VERSION").to_string(),
example: None,
})
.unwrap();
assert_eq!(
fs::read_to_string(project.join("AGENTS.md")).unwrap(),
PAX_CREATE_AGENTS_TEMPLATE
);
#[cfg(unix)]
assert_eq!(
fs::read_link(project.join("CLAUDE.md")).unwrap(),
PathBuf::from("AGENTS.md")
);
#[cfg(not(unix))]
assert_eq!(
fs::read_to_string(project.join("CLAUDE.md")).unwrap(),
PAX_CREATE_AGENTS_TEMPLATE
);
let manifest = fs::read_to_string(project.join("Cargo.toml")).unwrap();
assert!(manifest.contains("name = \"generated-app\""));
assert!(manifest.contains("title = \"generated-app\""));
assert!(manifest.contains(&format!(
"pax-kit = {{ version = \"{}\" }}",
env!("CARGO_PKG_VERSION")
)));
let parsed = manifest.parse::<toml_edit::Document>().unwrap();
assert_created_debug_profile(&parsed, "generated-app", "living-quilt");
assert!(parsed["dependencies"]
.as_table()
.unwrap()
.iter()
.all(|(_, dependency)| dependency
.as_inline_table()
.map(|table| !table.contains_key("path"))
.unwrap_or(true)));
assert!(project.join("src/quilt_tile.pax").is_file());
assert!(project.join("src/animated_pax_logo.pax").is_file());
assert!(project.join("src/animated_pax_logo_banner.rs").is_file());
}
#[test]
fn create_supports_override_and_libdev_modes() {
let dir = tempfile::tempdir().unwrap();
let override_project = dir.path().join("counter-app");
perform_create(&CreateContext {
path: override_project.to_string_lossy().to_string(),
is_libdev_mode: false,
version: "9.8.7".to_string(),
example: Some("increment".to_string()),
})
.unwrap();
assert!(fs::read_to_string(override_project.join("src/lib.pax"))
.unwrap()
.contains("num_clicks"));
assert!(fs::read_to_string(override_project.join("Cargo.toml"))
.unwrap()
.contains("pax-kit = { version = \"9.8.7\" }"));
let override_manifest = fs::read_to_string(override_project.join("Cargo.toml"))
.unwrap()
.parse::<toml_edit::Document>()
.unwrap();
assert_created_debug_profile(&override_manifest, "counter-app", "increment");
let libdev_project = dir.path().join("libdev-app");
perform_create(&CreateContext {
path: libdev_project.to_string_lossy().to_string(),
is_libdev_mode: true,
version: "9.8.7".to_string(),
example: Some("ink-and-light".to_string()),
})
.unwrap();
assert_eq!(
fs::read_to_string(libdev_project.join("AGENTS.md")).unwrap(),
fs::read_to_string(
Path::new(env!("CARGO_MANIFEST_DIR")).join("files/new-project/AGENTS.md")
)
.unwrap()
);
let libdev_manifest = fs::read_to_string(libdev_project.join("Cargo.toml"))
.unwrap()
.parse::<toml_edit::Document>()
.unwrap();
assert_created_debug_profile(&libdev_manifest, "libdev-app", "ink-and-light");
assert!(libdev_manifest["dependencies"]
.as_table()
.unwrap()
.iter()
.all(|(_, dependency)| dependency
.as_inline_table()
.map(|table| !table.contains_key("path"))
.unwrap_or(true)));
}
#[test]
fn unknown_example_does_not_create_destination() {
let dir = tempfile::tempdir().unwrap();
let project = dir.path().join("not-created");
let error = perform_create(&CreateContext {
path: project.to_string_lossy().to_string(),
is_libdev_mode: false,
version: env!("CARGO_PKG_VERSION").to_string(),
example: Some("missing".to_string()),
})
.unwrap_err();
assert!(error.contains("living-quilt"));
assert!(error.contains("ink-and-light"));
assert!(error.contains("increment"));
assert!(!project.exists());
}
fn assert_created_debug_profile(doc: &toml_edit::Document, app_name: &str, source_name: &str) {
assert_eq!(doc["profile"]["dev"]["opt-level"].as_integer(), Some(1));
let packages = doc["profile"]["dev"]["package"].as_table().unwrap();
assert_eq!(packages[app_name]["opt-level"].as_integer(), Some(0));
assert_eq!(packages.len(), 1);
assert!(!packages.contains_key(source_name));
}
#[test]
fn created_project_renames_only_its_own_profile_overrides() {
let mut doc = r#"
[package]
name = "source-example"
[profile.dev]
opt-level = 1
debug = true
[profile.dev.package.source-example]
opt-level = 0
[profile.dev.package.rand]
opt-level = 2
[profile.dev.build-override]
opt-level = 0
[profile.release]
lto = "thin"
[profile.release.package.source-example]
debug = 1
[profile.fast]
inherits = "dev"
[profile.fast.package.source-example]
codegen-units = 32
"#
.parse::<toml_edit::Document>()
.unwrap();
rename_created_project_profile_overrides(&mut doc, "new-app").unwrap();
let doc = doc.to_string().parse::<toml_edit::Document>().unwrap();
for profile in ["dev", "release", "fast"] {
let packages = doc["profile"][profile]["package"].as_table().unwrap();
assert!(packages.contains_key("new-app"));
assert!(!packages.contains_key("source-example"));
}
assert_eq!(doc["profile"]["dev"]["opt-level"].as_integer(), Some(1));
assert_eq!(doc["profile"]["dev"]["debug"].as_bool(), Some(true));
assert_eq!(
doc["profile"]["dev"]["package"]["new-app"]["opt-level"].as_integer(),
Some(0)
);
assert_eq!(
doc["profile"]["dev"]["package"]["rand"]["opt-level"].as_integer(),
Some(2)
);
assert_eq!(
doc["profile"]["dev"]["build-override"]["opt-level"].as_integer(),
Some(0)
);
assert_eq!(doc["profile"]["release"]["lto"].as_str(), Some("thin"));
assert_eq!(
doc["profile"]["release"]["package"]["new-app"]["debug"].as_integer(),
Some(1)
);
assert_eq!(doc["profile"]["fast"]["inherits"].as_str(), Some("dev"));
assert_eq!(
doc["profile"]["fast"]["package"]["new-app"]["codegen-units"].as_integer(),
Some(32)
);
}
#[test]
fn created_project_profile_rename_preserves_absent_profiles_and_unchanged_names() {
for source in [
"[package]\nname = \"source-example\"\n",
"[package]\nname = \"new-app\"\n[profile.dev.package.new-app]\nopt-level = 0\n",
"[package]\nname = \"source-example\"\n[profile.dev.package.rand]\nopt-level = 2\n",
] {
let mut doc = source.parse::<toml_edit::Document>().unwrap();
rename_created_project_profile_overrides(&mut doc, "new-app").unwrap();
assert_eq!(doc.to_string(), source);
}
}
#[test]
fn created_project_profile_rename_rejects_colliding_dependency_overrides() {
let source = "[package]\nname = \"source-example\"\n\
[profile.dev.package.source-example]\nopt-level = 0\n\
[profile.dev.package.new-app]\nopt-level = 2\n";
let mut doc = source.parse::<toml_edit::Document>().unwrap();
let error = rename_created_project_profile_overrides(&mut doc, "new-app").unwrap_err();
assert!(error.contains("profile.dev.package.source-example"));
assert!(error.contains("already exists"));
assert_eq!(doc.to_string(), source);
}
#[test]
fn non_pax_path_dependencies_are_rejected() {
let mut doc = "[dependencies]\nhelper = { version = \"1\", path = \"../helper\" }\n"
.parse::<toml_edit::Document>()
.unwrap();
let error = sanitize_created_project_dependencies(&mut doc).unwrap_err();
assert!(error.contains("helper"));
}
#[test]
fn invalid_selected_hot_reload_configuration_is_actionable() {
let env_error = resolve_hot_reload_mode(false, None, Some("rust"), Some("pax"))
.expect_err("selected environment value should be validated");
assert!(env_error
.to_string()
.contains("Invalid PAX_HOT_RELOAD value"));
let metadata_error = resolve_hot_reload_mode(false, None, None, Some("templates"))
.expect_err("selected metadata value should be validated");
assert!(metadata_error
.to_string()
.contains("package.metadata.pax.dev.hot_reload"));
}
#[test]
fn mobile_rejects_logic_only_but_accepts_pax_and_all() {
let error = validate_hot_reload_target(&RunTarget::iOS, HotReloadMode::Logic)
.expect_err("mobile has no logic-only reload lane");
assert!(error.to_string().contains("unavailable for iOS and iPadOS"));
assert!(validate_hot_reload_target(&RunTarget::iOS, HotReloadMode::Pax).is_ok());
assert!(validate_hot_reload_target(&RunTarget::iPadOS, HotReloadMode::All).is_ok());
}
fn release_context(should_run_designtime: bool) -> RunContext {
RunContext {
target: RunTarget::Web,
project_path: PathBuf::from("."),
verbose: false,
should_also_run: false,
is_libdev_mode: false,
process_child_ids: Arc::new(Mutex::new(vec![])),
should_run_designtime,
hot_reload: None,
is_release: true,
profile_wasm_size: false,
ios_device: None,
ios_development_team: None,
lifecycle_observer: None,
}
}
struct RecordingLifecycleObserver {
targets: Mutex<Vec<RunTarget>>,
}
impl RunLifecycleObserver for RecordingLifecycleObserver {
fn run_ready(&self, target: RunTarget) {
self.targets.lock().unwrap().push(target);
}
}
#[test]
fn run_lifecycle_notifier_emits_once_per_invocation() {
let observer = Arc::new(RecordingLifecycleObserver {
targets: Mutex::new(Vec::new()),
});
let mut ctx = release_context(false);
ctx.target = RunTarget::iPadOS;
ctx.lifecycle_observer = Some(observer.clone());
let notifier = RunLifecycleNotifier::new(&ctx);
notifier.clone().into_callback().unwrap()();
notifier.notify();
RunLifecycleNotifier::new(&ctx).notify();
assert_eq!(
*observer.targets.lock().unwrap(),
vec![RunTarget::iPadOS, RunTarget::iPadOS]
);
}
#[test]
fn release_build_rejects_devtime_features() {
let error = match prepare_cartridge_sources(&release_context(true)) {
Ok(_) => panic!("release builds should reject designtime cartridge contexts"),
Err(error) => error,
};
assert!(error
.to_string()
.contains("Release builds do not support designtime features"));
}
#[test]
fn release_build_rejects_devtime_enabled_by_project_cargo_defaults() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("src")).unwrap();
fs::write(dir.path().join("src/lib.rs"), "pub fn app() {}\n").unwrap();
fs::create_dir_all(dir.path().join("pax-engine/src")).unwrap();
fs::write(
dir.path().join("pax-engine/Cargo.toml"),
r#"
[package]
name = "pax-engine"
version = "0.1.0"
edition = "2021"
[features]
designtime = []
web = []
"#,
)
.unwrap();
fs::write(
dir.path().join("pax-engine/src/lib.rs"),
"pub fn engine() {}\n",
)
.unwrap();
fs::write(
dir.path().join("Cargo.toml"),
r#"
[package]
name = "release-boundary"
version = "0.1.0"
[dependencies]
pax-engine = { path = "pax-engine" }
[features]
default = ["authoring"]
authoring = ["pax-engine/designtime"]
web = ["pax-engine/web"]
"#,
)
.unwrap();
let mut ctx = release_context(false);
ctx.project_path = dir.path().to_path_buf();
validate_release_feature_boundary(&ctx).unwrap();
let error =
validate_release_cargo_feature_boundary(&ctx, &["wasm32-unknown-unknown"]).unwrap_err();
let error = error.to_string();
assert!(error.contains(
"runtime dependency path `release-boundary -> pax-engine` enables `designtime`"
));
assert!(error.contains("Pax enables development features explicitly"));
}
}
pub fn perform_eject(ctx: &RunContext) -> eyre::Result<(), Report> {
let pax_dir = get_or_create_pax_directory(&ctx.project_path);
eject_interface_files(ctx, &pax_dir);
Ok(())
}
fn eject_interface_files(ctx: &RunContext, pax_dir: &PathBuf) {
let target_str: &str = (&ctx.target).into();
let target_str_lower = &target_str.to_lowercase();
let custom_interfaces_dir = pax_dir.parent().unwrap().join("interfaces");
let mut target_custom_interface_dir = custom_interfaces_dir.join(target_str_lower);
if ctx.target == RunTarget::Web {
target_custom_interface_dir = target_custom_interface_dir.join("public");
}
let _ = fs::create_dir_all(&target_custom_interface_dir);
let src_path = get_libdev_interface_path(ctx);
if ctx.is_libdev_mode || src_path.exists() {
let _ = copy_dir_recursively(&src_path, &target_custom_interface_dir, &[]);
} else {
let _ = extract_interface_template(ctx, &target_custom_interface_dir);
}
println!(
"Interface files ejected to: {}",
target_custom_interface_dir.display()
);
}
fn get_libdev_interface_path(ctx: &RunContext) -> PathBuf {
let pax_compiler_root = Path::new(env!("CARGO_MANIFEST_DIR"));
match ctx.target {
RunTarget::Web => pax_compiler_root
.join("files")
.join("interfaces")
.join("web")
.join("public"),
RunTarget::macOS => pax_compiler_root
.join("files")
.join("interfaces")
.join("macos")
.join("pax-app-macos"),
RunTarget::iOS | RunTarget::iPadOS => pax_compiler_root
.join("files")
.join("interfaces")
.join("ios")
.join("pax-app-ios"),
}
}
fn extract_interface_template(ctx: &RunContext, dest: &Path) -> Result<(), std::io::Error> {
match ctx.target {
RunTarget::Web => PAX_WEB_INTERFACE_TEMPLATE.extract(dest)?,
RunTarget::macOS => PAX_MACOS_INTERFACE_TEMPLATE.extract(dest)?,
RunTarget::iOS | RunTarget::iPadOS => PAX_IOS_INTERFACE_TEMPLATE.extract(dest)?,
}
Ok(())
}
pub fn perform_clean(path: &str) {
let path = PathBuf::from(path);
let pax_dir = path.join(".pax");
fs::remove_dir_all(&pax_dir).ok();
}
pub struct CreateContext {
pub path: String,
pub is_libdev_mode: bool,
pub version: String,
pub example: Option<String>,
}
pub fn perform_create(ctx: &CreateContext) -> Result<(), String> {
let full_path = Path::new(&ctx.path);
if full_path.exists() {
return Err(format!(
"Destination `{}` already exists",
full_path.display()
));
}
let selected = bundled_examples::selected_example_name(ctx.example.as_deref())?;
let parent = full_path
.parent()
.filter(|path| !path.as_os_str().is_empty())
.unwrap_or(Path::new("."));
fs::create_dir_all(parent)
.map_err(|error| format!("Failed to create {}: {error}", parent.display()))?;
let staging = tempfile::Builder::new()
.prefix(".pax-create-")
.tempdir_in(parent)
.map_err(|error| format!("Failed to stage project: {error}"))?;
bundled_examples::extract_example(Some(&selected), staging.path())?;
write_project_agent_instructions(staging.path(), ctx.is_libdev_mode)?;
let crate_name = full_path
.file_name()
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty())
.ok_or_else(|| format!("Invalid project destination `{}`", full_path.display()))?
.to_string();
let mut doc = fs::read_to_string(staging.path().join("Cargo.toml"))
.map_err(|error| format!("Failed to read Cargo.toml: {error}"))?
.parse::<toml_edit::Document>()
.map_err(|error| format!("Failed to parse Cargo.toml: {error}"))?;
update_pax_dependency_versions(&mut doc, &ctx.version);
sanitize_created_project_dependencies(&mut doc)?;
rename_created_project_profile_overrides(&mut doc, &crate_name)?;
if let Some(package) = doc
.as_table_mut()
.entry("package")
.or_insert_with(toml_edit::table)
.as_table_mut()
{
if let Some(name_item) = package.get_mut("name") {
*name_item = toml_edit::Item::Value(crate_name.clone().into());
}
if let Some(version_item) = package.get_mut("version") {
*version_item = toml_edit::Item::Value(ctx.version.clone().into());
}
if let Some(metadata) = package
.get_mut("metadata")
.and_then(|item| item.as_table_mut())
{
if let Some(pax_metadata) = metadata.get_mut("pax").and_then(|item| item.as_table_mut())
{
if let Some(title_item) = pax_metadata.get_mut("title") {
*title_item = toml_edit::Item::Value(crate_name.clone().into());
}
}
}
}
fs::write(staging.path().join("Cargo.toml"), doc.to_string())
.map_err(|error| format!("Failed to write modified Cargo.toml: {error}"))?;
ensure_claude_md_link(staging.path())?;
let staging_path = staging.keep();
fs::rename(&staging_path, full_path).map_err(|error| {
let _ = fs::remove_dir_all(&staging_path);
format!(
"Failed to finalize project at {}: {error}",
full_path.display()
)
})?;
println!(
"\nCreated `{}` from bundled example `{}`.\nTo run:\n `cd {}`\n `pax-cli run --target=web`",
crate_name,
selected,
full_path.to_str().unwrap(),
);
Ok(())
}
fn rename_created_project_profile_overrides(
doc: &mut toml_edit::Document,
crate_name: &str,
) -> Result<(), String> {
let original_name = doc["package"]["name"]
.as_str()
.ok_or_else(|| "Bundled example is missing package.name".to_string())?
.to_string();
if original_name == crate_name {
return Ok(());
}
let Some(profiles) = doc
.get_mut("profile")
.and_then(|item| item.as_table_like_mut())
else {
return Ok(());
};
for (profile_name, profile) in profiles.iter_mut() {
let Some(packages) = profile
.get_mut("package")
.and_then(|item| item.as_table_like_mut())
else {
continue;
};
if !packages.contains_key(&original_name) {
continue;
}
if packages.contains_key(crate_name) {
return Err(format!(
"Cannot rename profile.{profile_name}.package.{original_name} to \
`{crate_name}`: a package override with that name already exists"
));
}
let settings = packages.remove(&original_name).unwrap();
packages.insert(crate_name, settings);
}
Ok(())
}
fn sanitize_created_project_dependencies(doc: &mut toml_edit::Document) -> Result<(), String> {
let Some(dependencies) = doc
.get_mut("dependencies")
.and_then(|item| item.as_table_mut())
else {
return Ok(());
};
for (name, dependency) in dependencies.iter_mut() {
if let toml_edit::Item::Value(toml_edit::Value::InlineTable(table)) = dependency {
if table.contains_key("path") && !name.starts_with("pax-") {
return Err(format!(
"Bundled example dependency `{name}` uses a monorepo-relative path"
));
}
table.remove("path");
} else if dependency
.as_table()
.map(|table| table.contains_key("path"))
.unwrap_or(false)
{
return Err(format!(
"Bundled example dependency `{name}` uses an unsupported path table"
));
}
}
Ok(())
}
fn write_project_agent_instructions(
project_root: &Path,
is_libdev_mode: bool,
) -> Result<(), String> {
let destination = project_root.join("AGENTS.md");
if is_libdev_mode {
let source = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("files")
.join("new-project")
.join("AGENTS.md");
fs::copy(&source, &destination).map_err(|err| {
format!(
"Failed to copy project agent instructions from {} to {}: {err}",
source.display(),
destination.display()
)
})?;
} else {
fs::write(&destination, PAX_CREATE_AGENTS_TEMPLATE).map_err(|err| {
format!(
"Failed to write project agent instructions to {}: {err}",
destination.display()
)
})?;
}
Ok(())
}
fn ensure_claude_md_link(project_root: &Path) -> Result<(), String> {
let claude_path = project_root.join("CLAUDE.md");
if let Ok(metadata) = claude_path.symlink_metadata() {
if metadata.file_type().is_symlink() {
return Ok(());
}
if metadata.is_dir() {
fs::remove_dir_all(&claude_path).map_err(|error| error.to_string())?;
} else {
fs::remove_file(&claude_path).map_err(|error| error.to_string())?;
}
}
#[cfg(unix)]
{
std::os::unix::fs::symlink("AGENTS.md", &claude_path)
.map_err(|error| format!("Failed to create CLAUDE.md symlink: {error}"))?;
}
#[cfg(not(unix))]
{
fs::copy(project_root.join("AGENTS.md"), &claude_path)
.map_err(|error| format!("Failed to copy CLAUDE.md from AGENTS.md: {error}"))?;
}
Ok(())
}
impl RunTarget {
pub fn parse(input: &str) -> Result<Self, String> {
match input.to_lowercase().as_str() {
"macos" => Ok(RunTarget::macOS),
"web" => Ok(RunTarget::Web),
"ios" => Ok(RunTarget::iOS),
"ipados" | "ipad" => Ok(RunTarget::iPadOS),
_ => Err(format!(
"unsupported target `{input}`; expected one of: web, macos, ios, ipados"
)),
}
}
}
impl From<&str> for RunTarget {
fn from(input: &str) -> Self {
Self::parse(input).unwrap_or_else(|error| panic!("{error}"))
}
}
impl<'a> Into<&'a str> for &'a RunTarget {
fn into(self) -> &'a str {
match self {
RunTarget::Web => "Web",
RunTarget::macOS => "macOS",
RunTarget::iOS => "iOS",
RunTarget::iPadOS => "iPadOS",
}
}
}
#[cfg(unix)]
fn pre_exec_hook() -> Result<(), std::io::Error> {
unsafe {
libc::setpgid(0, 0);
}
Ok(())
}