1use std::{
4 collections::{BTreeMap, BTreeSet},
5 fmt,
6 path::{Path, PathBuf},
7 str::FromStr,
8};
9
10use crate::project::Project;
11use cargo_lock::{Dependency as LockedDependency, Lockfile};
12use cargo_toml::{Dependency, DependencyDetail, PatchSet};
13use eyre::{Result, WrapErr, bail, eyre};
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16use smol::process::Command;
17use zenwave::{Client as _, Method, StatusCode};
18
19#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "lowercase")]
22pub enum FrameworkChannel {
23 Dev,
26 Nightly,
29 #[default]
31 Stable,
32}
33
34impl fmt::Display for FrameworkChannel {
35 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
36 formatter.write_str(match self {
37 Self::Dev => "dev",
38 Self::Nightly => "nightly",
39 Self::Stable => "stable",
40 })
41 }
42}
43
44impl FromStr for FrameworkChannel {
45 type Err = String;
46
47 fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
48 match value {
49 "dev" => Ok(Self::Dev),
50 "nightly" => Ok(Self::Nightly),
51 "stable" => Ok(Self::Stable),
52 _ => Err("framework channel must be dev, nightly or stable".into()),
53 }
54 }
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60struct FrameworkRelease {
61 repository: String,
63 revision: String,
65 tag: String,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70#[serde(tag = "channel", rename_all = "lowercase")]
71enum Source {
72 Stable {
75 #[serde(default, skip_serializing_if = "Option::is_none")]
78 release: Option<FrameworkRelease>,
79 },
80 Dev {
81 repository: String,
82 revision: String,
83 lock_sha256: String,
84 },
85 Nightly {
86 repository: String,
87 revision: String,
88 tag: String,
89 lock_sha256: String,
90 },
91 #[serde(skip)]
94 Local { root: PathBuf },
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct ResolvedFramework {
100 #[serde(flatten)]
101 source: Source,
102 #[serde(
103 default,
104 rename = "minimum-cli-version",
105 skip_serializing_if = "Option::is_none"
106 )]
107 minimum_cli_version: Option<cargo_toml::SemVer>,
108 #[serde(
112 default,
113 rename = "rust-version",
114 skip_serializing_if = "Option::is_none"
115 )]
116 rust_version: Option<cargo_toml::SemVer>,
117 #[serde(default, skip_serializing_if = "toml::Table::is_empty")]
120 metadata: toml::Table,
121 scaffold: BTreeMap<String, String>,
122 #[serde(
128 default,
129 rename = "experimental-packages",
130 skip_serializing_if = "BTreeMap::is_empty"
131 )]
132 experimental_packages: BTreeMap<String, ExperimentalPackage>,
133 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
134 packages: BTreeMap<String, DependencyDetail>,
135 #[serde(default, skip_serializing_if = "PatchSet::is_empty")]
136 patches: PatchSet,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145pub struct ExperimentalPackage {
146 pub git: String,
148 pub rev: String,
150 pub version: String,
152}
153
154#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)]
155struct LockedPackage {
156 name: String,
157 version: String,
158 source: Option<String>,
159}
160
161impl From<&cargo_lock::Package> for LockedPackage {
162 fn from(package: &cargo_lock::Package) -> Self {
163 Self {
164 name: package.name.to_string(),
165 version: package.version.to_string(),
166 source: package.source.as_ref().map(ToString::to_string),
167 }
168 }
169}
170
171#[derive(Debug, Deserialize)]
172struct Release {
173 tag_name: String,
174 draft: bool,
175 prerelease: bool,
176 published_at: Option<String>,
177 assets: Vec<ReleaseAsset>,
178}
179
180#[derive(Debug, Deserialize)]
181struct ReleaseAsset {
182 name: String,
183 browser_download_url: String,
184}
185
186#[derive(Deserialize)]
187struct Certification {
188 schema_version: u32,
189 channel: FrameworkChannel,
190 repository: String,
191 revision: String,
192 tag: String,
193 lockfiles: BTreeMap<String, String>,
194 #[serde(default)]
196 submodules: BTreeMap<String, String>,
197 scaffold: BTreeMap<String, String>,
198 #[serde(default, rename = "experimental-packages")]
203 experimental_packages: BTreeMap<String, ExperimentalPackage>,
204 metadata: toml::Table,
207}
208
209pub(crate) fn rewrite_patch_tables(
214 document: &mut toml_edit::DocumentMut,
215 previous: &PatchSet,
216 patches: &PatchSet,
217) -> Result<()> {
218 for (source, dependencies) in previous {
219 if let Some(table) = document
220 .get_mut("patch")
221 .and_then(|patch| patch.get_mut(source))
222 .and_then(toml_edit::Item::as_table_like_mut)
223 {
224 for name in dependencies.keys() {
225 table.remove(name);
226 }
227 }
228 }
229 let patches = toml_edit::ser::to_document(patches)?;
230 for (source, dependencies) in patches.iter() {
231 let patch = document
235 .entry("patch")
236 .or_insert_with(toml_edit::table)
237 .as_table_mut()
238 .ok_or_else(|| eyre!("[patch] is not a table"))?;
239 patch.set_implicit(true);
240 let table = patch
241 .entry(source)
242 .or_insert_with(toml_edit::table)
243 .as_table_like_mut()
244 .ok_or_else(|| eyre!("[patch.{source}] is not a table"))?;
245 for (name, dependency) in dependencies
246 .as_table_like()
247 .expect("serialized patch dependencies are tables")
248 .iter()
249 {
250 table.insert(name, dependency.clone());
251 }
252 }
253 if let Some(patch) = document
254 .get_mut("patch")
255 .and_then(toml_edit::Item::as_table_mut)
256 {
257 let empty: Vec<String> = patch
258 .iter()
259 .filter(|(_, sources)| {
260 sources
261 .as_table_like()
262 .is_some_and(toml_edit::TableLike::is_empty)
263 })
264 .map(|(source, _)| source.to_owned())
265 .collect();
266 for source in empty {
267 patch.remove(&source);
268 }
269 if patch.is_empty() {
270 document.remove("patch");
271 }
272 }
273 Ok(())
274}
275
276impl ResolvedFramework {
277 #[must_use]
280 pub const fn channel(&self) -> Option<FrameworkChannel> {
281 match self.source {
282 Source::Stable { .. } => Some(FrameworkChannel::Stable),
283 Source::Dev { .. } => Some(FrameworkChannel::Dev),
284 Source::Nightly { .. } => Some(FrameworkChannel::Nightly),
285 Source::Local { .. } => None,
286 }
287 }
288
289 pub(crate) async fn for_manifest(
298 manifest: &crate::project::Manifest,
299 project_root: &Path,
300 ) -> Result<Self> {
301 if let Some(framework) = &manifest.framework {
302 return framework.clone().validated().wrap_err(
303 "the recorded framework selection predates a metadata key this CLI \
304 requires; re-run `water channel` to resolve it again",
305 );
306 }
307 let Some(waterui_path) = &manifest.waterui_path else {
308 bail!(
309 "the project records no framework selection; run `water channel` \
310 to select one or point `waterui_path` at a checkout"
311 );
312 };
313 Self::for_local_checkout(&project_root.join(waterui_path)).await
314 }
315
316 pub(crate) async fn for_local_checkout(root: &Path) -> Result<Self> {
321 let manifest: toml::Value = toml::from_str(
322 &smol::fs::read_to_string(root.join("Cargo.toml"))
323 .await
324 .wrap_err_with(|| {
325 format!(
326 "the WaterUI checkout at {} has no Cargo.toml",
327 root.display()
328 )
329 })?,
330 )?;
331 let metadata = framework_metadata(&manifest)?;
332 let minimum_cli_version = minimum_cli_version(&metadata)?;
333 let rust_version = manifest_rust_version(&manifest)?;
334 if let Some(minimum) = &minimum_cli_version {
335 validate_installed_cli(minimum, &checkout_cli_update())?;
336 }
337 let mut scaffold = framework_scaffold(&manifest)?;
338 let lock: Lockfile = smol::fs::read_to_string(root.join("Cargo.lock"))
339 .await
340 .wrap_err_with(|| {
341 format!(
342 "the WaterUI checkout at {} has no Cargo.lock",
343 root.display()
344 )
345 })?
346 .parse()?;
347 let mut submodules = BTreeMap::new();
348 for path in BACKEND_SUBMODULES {
352 if declares_backend_revision(&scaffold, path) {
353 continue;
354 }
355 submodules.insert(
356 (*path).to_owned(),
357 local_submodule_revision(root, path).await?,
358 );
359 }
360 if !declares_apple_backend_pin(&scaffold) {
364 submodules.insert(
365 "backends/apple".to_owned(),
366 local_submodule_revision(root, "backends/apple").await?,
367 );
368 }
369 complete_scaffold(&mut scaffold, &submodules, &lock)?;
370 Self {
371 source: Source::Local {
372 root: root.to_path_buf(),
373 },
374 minimum_cli_version,
375 rust_version,
376 metadata,
377 scaffold,
378 experimental_packages: BTreeMap::new(),
381 packages: BTreeMap::new(),
382 patches: PatchSet::default(),
383 }
384 .validated()
385 }
386
387 fn validated(mut self) -> Result<Self> {
392 self.android_min_api_level()?;
393 if matches!(self.source, Source::Stable { .. }) {
399 let withheld = split_experimental_packages(&mut self.scaffold);
400 self.experimental_packages.extend(withheld);
401 }
402 Ok(self)
403 }
404
405 pub(crate) async fn resolve_manifest(path: &Path) -> Result<(Self, Option<Vec<u8>>)> {
414 let repository = framework_repository();
415 let slug = repository_slug(repository)?;
416 let certification = load_manifest(path, repository).await?;
417 let revision = certification.revision.clone();
418 Self::construct(repository, slug, &revision, Some(certification)).await
419 }
420
421 pub(crate) fn validate_cli(&self) -> Result<()> {
422 if let Some(minimum) = &self.minimum_cli_version {
423 let update = match &self.source {
424 Source::Stable { .. } => registry_cli_update(minimum),
425 Source::Dev { .. } | Source::Nightly { .. } | Source::Local { .. } => {
426 checkout_cli_update()
427 }
428 };
429 validate_installed_cli(minimum, &update)?;
430 }
431 Ok(())
432 }
433
434 pub(crate) fn scaffold_value(&self, key: &str) -> &str {
435 self.scaffold
436 .get(key)
437 .unwrap_or_else(|| panic!("resolved framework carries no `{key}` scaffold metadata"))
438 }
439
440 pub(crate) fn require_distributable(&self, name: &str) -> Result<()> {
449 let Some(package) = self.experimental_packages.get(name) else {
450 return Ok(());
451 };
452 let channel = self
453 .channel()
454 .map_or_else(|| "local".to_owned(), |channel| channel.to_string());
455 bail!(
456 "`{name}` is an experimental package the {channel} framework channel does not \
457 distribute — it is pinned to {} at {} with no registry release. \
458 Scaffold it on `--channel dev` or `--channel nightly`.",
459 package.git,
460 package.rev,
461 );
462 }
463
464 #[must_use]
469 pub const fn rust_version(&self) -> Option<&cargo_toml::SemVer> {
470 self.rust_version.as_ref()
471 }
472
473 pub(crate) fn apple_backend_version(&self) -> Option<&str> {
477 self.scaffold
478 .get("apple-backend-version")
479 .map(String::as_str)
480 }
481
482 pub(crate) fn apple_backend_revision(&self) -> Option<&str> {
487 self.scaffold
488 .get("apple-backend-revision")
489 .map(String::as_str)
490 }
491
492 pub(crate) fn android_min_api_level(&self) -> Result<u32> {
502 const KEY: &str = "package.metadata.waterui.android-min-api-level";
503 let origin = match &self.source {
504 Source::Stable { release } => release.as_ref().map_or_else(
505 || "the stable framework manifest".to_owned(),
506 |release| format!("the framework manifest certified by {}", release.tag),
507 ),
508 Source::Dev {
509 repository,
510 revision,
511 ..
512 }
513 | Source::Nightly {
514 repository,
515 revision,
516 ..
517 } => format!("the framework manifest at {repository}@{revision}"),
518 Source::Local { root } => format!("{}", root.join("Cargo.toml").display()),
519 };
520 let value = self
521 .metadata
522 .get("android-min-api-level")
523 .ok_or_else(|| eyre!("{origin} does not declare {KEY}"))?;
524 value
525 .as_integer()
526 .and_then(|level| u32::try_from(level).ok())
527 .ok_or_else(|| eyre!("{origin} declares an invalid {KEY}: {value}"))
528 }
529
530 pub(crate) fn patches(&self) -> PatchSet {
531 self.patches.clone()
532 }
533
534 pub(crate) fn update_manifest(
539 &self,
540 document: &mut toml_edit::DocumentMut,
541 previous_patches: &PatchSet,
542 ) -> Result<()> {
543 for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
544 if let Some(dependencies) = document
545 .get_mut(section)
546 .and_then(toml_edit::Item::as_table_like_mut)
547 {
548 self.update_dependencies(dependencies)?;
549 }
550 }
551 if let Some(targets) = document
552 .get_mut("target")
553 .and_then(toml_edit::Item::as_table_like_mut)
554 {
555 for (_, target) in targets.iter_mut() {
556 for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
557 if let Some(dependencies) = target
558 .get_mut(section)
559 .and_then(toml_edit::Item::as_table_like_mut)
560 {
561 self.update_dependencies(dependencies)?;
562 }
563 }
564 }
565 }
566 rewrite_patch_tables(document, previous_patches, &self.patches)
567 }
568
569 fn update_dependencies(&self, dependencies: &mut dyn toml_edit::TableLike) -> Result<()> {
570 for (name, dependency) in dependencies.iter_mut() {
571 let package = dependency
572 .get("package")
573 .and_then(toml_edit::Item::as_str)
574 .unwrap_or(&name)
575 .to_owned();
576 if !self.scaffold.contains_key(&format!("{package}-version")) {
577 continue;
578 }
579 if dependency.is_str() {
580 let decor = dependency
581 .as_value()
582 .expect("string dependency")
583 .decor()
584 .clone();
585 let mut value = toml_edit::Value::InlineTable(toml_edit::InlineTable::new());
586 *value.decor_mut() = decor;
587 *dependency = toml_edit::Item::Value(value);
588 }
589 let table = dependency
590 .as_table_like_mut()
591 .ok_or_else(|| eyre!("invalid dependency {name}"))?;
592 if table.contains_key("workspace") {
593 bail!(
594 "{name} inherits its source; select the framework at its Cargo workspace root"
595 );
596 }
597 for key in [
598 "version",
599 "git",
600 "rev",
601 "branch",
602 "tag",
603 "path",
604 "registry",
605 "registry-index",
606 ] {
607 table.remove(key);
608 }
609 let source = toml_edit::ser::to_document(&self.dependency(&package))?;
610 for key in ["version", "git", "rev"] {
611 if let Some(value) = source.get(key) {
612 table.insert(key, value.clone());
613 }
614 }
615 }
616 Ok(())
617 }
618
619 pub(crate) fn validate_dependencies(
620 &self,
621 metadata: &cargo_metadata::Metadata,
622 contents: &[u8],
623 ) -> Result<()> {
624 let source = match &self.source {
625 Source::Stable { .. } | Source::Local { .. } => return Ok(()),
626 Source::Dev {
627 repository,
628 revision,
629 ..
630 }
631 | Source::Nightly {
632 repository,
633 revision,
634 ..
635 } => format!("git+{repository}?rev={revision}#{revision}"),
636 };
637 let locked = self.cargo_lock(contents)?;
638 let allowed = self.allowed_packages(&locked.packages);
639 let ecosystem: BTreeSet<&str> = locked
644 .packages
645 .iter()
646 .map(|package| package.name.as_str())
647 .chain(self.packages.keys().map(String::as_str))
648 .collect();
649 let packages: BTreeMap<_, _> = metadata
650 .packages
651 .iter()
652 .map(|package| (package.id.clone(), package))
653 .collect();
654 let resolve = metadata
655 .resolve
656 .as_ref()
657 .ok_or_else(|| eyre!("framework verification requires a resolved Cargo graph"))?;
658 let nodes: BTreeMap<_, _> = resolve
659 .nodes
660 .iter()
661 .map(|node| (node.id.clone(), node))
662 .collect();
663 let is_framework_source = |package: &cargo_metadata::Package| {
664 package
665 .source
666 .as_ref()
667 .is_some_and(|candidate| candidate.repr == source)
668 };
669 if !metadata.packages.iter().any(is_framework_source) {
670 bail!("the project does not resolve its selected framework revision");
671 }
672 let mut pending: Vec<_> = metadata
673 .packages
674 .iter()
675 .filter(|package| {
676 is_framework_source(package) || self.packages.contains_key(package.name.as_str())
677 })
678 .map(|package| package.id.clone())
679 .collect();
680 let mut visited = BTreeSet::new();
681 while let Some(id) = pending.pop() {
682 if !visited.insert(id.clone()) {
683 continue;
684 }
685 let package = packages[&id];
686 let identity = LockedPackage {
687 name: package.name.to_string(),
688 version: package.version.to_string(),
689 source: package.source.as_ref().map(|source| source.repr.clone()),
690 };
691 if !allowed.contains(&identity)
692 && ecosystem.contains(identity.name.as_str())
693 && !self.sanctioned_source(&identity)
694 {
695 bail!(
696 "framework dependency {} {} differs from Water.lock; select a compatible channel explicitly",
697 identity.name,
698 identity.version
699 );
700 }
701 pending.extend(nodes[&id].dependencies.iter().cloned());
702 }
703 Ok(())
704 }
705
706 pub(crate) async fn prepare_build(
707 &self,
708 project: &Project,
709 directory: &std::path::Path,
710 features: &[String],
711 ) -> Result<()> {
712 self.validate_cli()?;
713 let project_lock: Lockfile = smol::fs::read_to_string(project.lockfile_path().await?)
714 .await?
715 .parse()?;
716 let lock_path = directory.join("Cargo.lock");
717 let previous: Option<Lockfile> = match smol::fs::read_to_string(&lock_path).await {
718 Ok(contents) => Some(contents.parse()?),
719 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
720 Err(error) => return Err(error.into()),
721 };
722 let allow_new = previous.as_ref().is_none_or(|previous| {
723 let previous: BTreeSet<_> = previous
724 .packages
725 .iter()
726 .map(LockedDependency::from)
727 .collect();
728 project_lock
729 .packages
730 .iter()
731 .filter(|package| package.name.as_str() == "waterui")
732 .any(|package| !previous.contains(&LockedDependency::from(package)))
733 });
734 let canonical = if self.channel() == Some(FrameworkChannel::Stable) {
735 None
736 } else {
737 Some(smol::fs::read(project.root().join("Water.lock")).await?)
738 };
739 let mut packages = BTreeMap::new();
740 if let Some(previous) = &previous {
741 packages.extend(
742 previous
743 .packages
744 .iter()
745 .map(|package| (LockedDependency::from(package), package.clone())),
746 );
747 }
748 if let Some(canonical) = &canonical {
749 packages.extend(
750 self.cargo_lock(canonical)?
751 .packages
752 .into_iter()
753 .map(|package| (LockedDependency::from(&package), package)),
754 );
755 }
756 packages.extend(
757 project_lock
758 .packages
759 .iter()
760 .map(|package| (LockedDependency::from(package), package.clone())),
761 );
762 let allowed = self.allowed_packages(packages.values());
763 let mut seed = project_lock;
764 seed.packages = packages.into_values().collect();
765 smol::fs::write(&lock_path, seed.to_string()).await?;
766 let root = directory.to_path_buf();
767 let features = features.to_vec();
768 let result = async {
769 let metadata = smol::unblock(move || {
770 cargo_metadata::MetadataCommand::new().current_dir(root)
771 .features(cargo_metadata::CargoOpt::SomeFeatures(features)).exec()
772 }).await?;
773 validate_resolved_cli(&metadata)?;
774 if !allow_new {
775 for package in &metadata.packages {
776 if package.source.is_some() && !allowed.contains(&LockedPackage {
777 name: package.name.to_string(),
778 version: package.version.to_string(),
779 source: package.source.as_ref().map(|source| source.repr.clone()),
780 }) {
781 bail!("generated build would change locked dependency {}; update the framework channel explicitly", package.name);
782 }
783 }
784 }
785 if let Some(canonical) = canonical {
786 self.validate_dependencies(&metadata, &canonical)?;
787 }
788 Ok(())
789 }.await;
790 if let Err(error) = &result {
791 let restore = if let Some(previous) = previous {
792 smol::fs::write(&lock_path, previous.to_string()).await
793 } else {
794 smol::fs::remove_file(&lock_path).await
795 };
796 restore.wrap_err_with(|| {
797 format!("failed to preserve the previous lock after resolution failed: {error}")
798 })?;
799 }
800 result
801 }
802
803 pub(crate) fn cargo_lock(&self, contents: &[u8]) -> Result<Lockfile> {
804 let (repository, revision, expected) = match &self.source {
805 Source::Stable { .. } => bail!("stable uses the application's Cargo.lock"),
806 Source::Local { .. } => {
807 bail!("a local framework checkout has no canonical lock")
808 }
809 Source::Dev {
810 repository,
811 revision,
812 lock_sha256,
813 }
814 | Source::Nightly {
815 repository,
816 revision,
817 lock_sha256,
818 ..
819 } => (repository, revision, lock_sha256),
820 };
821 if hex::encode(Sha256::digest(contents)) != *expected {
822 bail!("Water.lock does not match the selected framework revision");
823 }
824 let mut lock: Lockfile = std::str::from_utf8(contents)?.parse()?;
825 let source = format!("git+{repository}?rev={revision}#{revision}")
826 .parse::<cargo_lock::SourceId>()?;
827 let mut local = BTreeMap::new();
828 for package in &mut lock.packages {
829 if package.source.is_none() {
830 package.source = Some(source.clone());
831 local.insert(
832 (package.name.clone(), package.version.clone()),
833 LockedDependency::from(&*package),
834 );
835 }
836 }
837 for package in &mut lock.packages {
838 for dependency in &mut package.dependencies {
839 if dependency.source.is_none()
840 && let Some(replacement) =
841 local.get(&(dependency.name.clone(), dependency.version.clone()))
842 {
843 *dependency = replacement.clone();
844 }
845 }
846 }
847 Ok(lock)
848 }
849
850 pub(crate) const fn git_source(&self) -> Option<(&str, &str)> {
857 match &self.source {
858 Source::Stable { .. } | Source::Local { .. } => None,
859 Source::Dev {
860 repository,
861 revision,
862 ..
863 }
864 | Source::Nightly {
865 repository,
866 revision,
867 ..
868 } => Some((repository.as_str(), revision.as_str())),
869 }
870 }
871
872 fn allowed_packages<'p>(
880 &self,
881 packages: impl IntoIterator<Item = &'p cargo_lock::Package>,
882 ) -> BTreeSet<LockedPackage> {
883 let mut allowed = BTreeSet::new();
884 let Some((repository, revision)) = self.git_source() else {
885 return packages.into_iter().map(LockedPackage::from).collect();
886 };
887 let framework_source = format!("git+{repository}?rev={revision}#{revision}");
888 let pinned: BTreeMap<&str, String> = self
890 .patches
891 .values()
892 .flatten()
893 .filter_map(|(name, dependency)| {
894 let Dependency::Detailed(detail) = dependency else {
895 return None;
896 };
897 let (git, rev) = detail.git.as_deref().zip(detail.rev.as_deref())?;
898 Some((name.as_str(), format!("git+{git}?rev={rev}#{rev}")))
899 })
900 .collect();
901 for package in packages {
902 let identity = LockedPackage::from(package);
903 if let Some(source) = &identity.source
904 && let Some(pin) = pinned.get(identity.name.as_str())
905 {
906 if source == &framework_source {
907 allowed.insert(LockedPackage {
908 source: Some(pin.clone()),
909 ..identity.clone()
910 });
911 } else if source == pin {
912 allowed.insert(LockedPackage {
913 source: Some(framework_source.clone()),
914 ..identity.clone()
915 });
916 }
917 }
918 allowed.insert(identity);
919 }
920 allowed
921 }
922
923 fn sanctioned_source(&self, identity: &LockedPackage) -> bool {
929 let Some(detail) = self.packages.get(identity.name.as_str()) else {
930 return false;
931 };
932 let Some(source) = &identity.source else {
933 return false;
934 };
935 if let (Some(git), Some(rev)) = (&detail.git, &detail.rev) {
936 let Ok(source) = source.parse::<cargo_lock::SourceId>() else {
937 return false;
938 };
939 let declared = cargo_lock::package::GitReference::Rev(rev.clone());
940 return source.is_git()
941 && source.git_reference() == Some(&declared)
942 && canonical_git_url(source.url().as_str()) == canonical_git_url(git);
943 }
944 source.as_str() == "registry+https://github.com/rust-lang/crates.io-index"
945 && detail.version.as_ref().is_some_and(|requirement| {
946 identity
947 .version
948 .parse::<cargo_toml::SemVer>()
949 .is_ok_and(|version| requirement.matches(&version))
950 })
951 }
952
953 pub(crate) fn dependency(&self, name: &str) -> DependencyDetail {
954 match &self.source {
955 Source::Stable { .. } => {
956 let requirement = self.scaffold_value(&format!("{name}-version"));
957 let git = self.scaffold.get(&format!("{name}-git"));
960 DependencyDetail {
961 version: Some(
962 git.map_or_else(|| format!("={requirement}"), |_| requirement.to_owned())
963 .parse()
964 .expect("resolved package version is valid"),
965 ),
966 git: git.cloned(),
967 rev: git.map(|_| self.scaffold_value(&format!("{name}-rev")).to_owned()),
968 ..Default::default()
969 }
970 }
971 Source::Dev { .. } | Source::Nightly { .. } => self.packages[name].clone(),
972 Source::Local { .. } => {
973 unreachable!("a local checkout resolves framework crates by path")
974 }
975 }
976 }
977
978 pub(crate) async fn resolve(channel: FrameworkChannel) -> Result<(Self, Option<Vec<u8>>)> {
990 let repository = framework_repository();
991 let slug = repository_slug(repository)?;
992 match channel {
993 FrameworkChannel::Stable | FrameworkChannel::Nightly => {
994 let certification = latest_certification(repository, channel).await?;
995 let revision = certification.revision.clone();
996 Self::construct(repository, slug, &revision, Some(certification)).await
997 }
998 FrameworkChannel::Dev => {
999 let revision = resolve_dev(repository, slug).await?;
1000 Self::construct(repository, slug, &revision, None).await
1001 }
1002 }
1003 }
1004
1005 async fn construct(
1016 repository: &str,
1017 slug: &str,
1018 revision: &str,
1019 certification: Option<Certification>,
1020 ) -> Result<(Self, Option<Vec<u8>>)> {
1021 validate_revision(revision)?;
1022 let base = format!("https://raw.githubusercontent.com/{slug}/{revision}");
1023 let manifest_bytes = fetch(&format!("{base}/Cargo.toml")).await?;
1024 let root: toml::Value = toml::from_str(std::str::from_utf8(&manifest_bytes)?)?;
1025 let metadata = framework_metadata(&root)?;
1026 let minimum_cli_version = minimum_cli_version(&metadata)?;
1027 let rust_version = manifest_rust_version(&root)?;
1028 let mut scaffold = framework_scaffold(&root)?;
1029 let lock_bytes = fetch(&format!("{base}/Cargo.lock")).await?;
1030 let lock_sha256 = hex::encode(Sha256::digest(&lock_bytes));
1031 let lock: Lockfile = std::str::from_utf8(&lock_bytes)?.parse()?;
1032
1033 let channel = certification
1034 .as_ref()
1035 .map_or(FrameworkChannel::Dev, |certification| certification.channel);
1036 let experimental_packages = if channel == FrameworkChannel::Stable {
1042 split_experimental_packages(&mut scaffold)
1043 } else {
1044 BTreeMap::new()
1045 };
1046 if let Some(minimum) = &minimum_cli_version {
1047 let update = match channel {
1048 FrameworkChannel::Stable => registry_cli_update(minimum),
1049 FrameworkChannel::Dev | FrameworkChannel::Nightly => checkout_cli_update(),
1050 };
1051 validate_installed_cli(minimum, &update)?;
1052 }
1053
1054 let submodule_repositories = match channel {
1059 FrameworkChannel::Stable => BTreeMap::new(),
1060 FrameworkChannel::Dev | FrameworkChannel::Nightly => {
1061 match fetch_optional(&format!("{base}/.gitmodules")).await? {
1062 Some(bytes) => parse_gitmodules(std::str::from_utf8(&bytes)?),
1063 None => BTreeMap::new(),
1065 }
1066 }
1067 };
1068
1069 let (source, submodules) = if let Some(certification) = &certification {
1070 (
1071 certified_source(
1072 certification,
1073 repository,
1074 revision,
1075 &metadata,
1076 &scaffold,
1077 &experimental_packages,
1078 &lock_sha256,
1079 )?,
1080 certification.submodules.clone(),
1081 )
1082 } else {
1083 (
1084 Source::Dev {
1085 repository: repository.to_owned(),
1086 revision: revision.to_owned(),
1087 lock_sha256,
1088 },
1089 dev_submodules(slug, revision, &scaffold, &submodule_repositories).await?,
1090 )
1091 };
1092 complete_scaffold(&mut scaffold, &submodules, &lock)?;
1093 channel_apple_backend_pin(channel, &mut scaffold, certification.as_ref()).await?;
1094
1095 let (packages, patches, lockfile) = match channel {
1096 FrameworkChannel::Stable => (BTreeMap::new(), PatchSet::default(), None),
1100 FrameworkChannel::Dev | FrameworkChannel::Nightly => {
1101 let patches: PatchSet = root
1102 .get("patch")
1103 .cloned()
1104 .map(toml::Value::try_into)
1105 .transpose()?
1106 .unwrap_or_default();
1107 let pins = submodule_pins(submodule_repositories, &submodules);
1111 let patches = rebase_patches_onto_source(patches, repository, revision, &pins);
1112 let packages = resolve_packages(&scaffold, &lock, repository, revision)?;
1113 (packages, patches, Some(lock_bytes))
1114 }
1115 };
1116 Ok((
1117 Self {
1118 source,
1119 minimum_cli_version,
1120 rust_version,
1121 metadata,
1122 scaffold,
1123 experimental_packages,
1124 packages,
1125 patches,
1126 }
1127 .validated()?,
1128 lockfile,
1129 ))
1130 }
1131}
1132
1133async fn channel_apple_backend_pin(
1142 channel: FrameworkChannel,
1143 scaffold: &mut BTreeMap<String, String>,
1144 certification: Option<&Certification>,
1145) -> Result<()> {
1146 match channel {
1147 FrameworkChannel::Dev if scaffold.contains_key("apple-backend-version") => {
1148 let url = scaffold.get("apple-backend-url").ok_or_else(|| {
1149 eyre!("framework manifest declares apple-backend-version without apple-backend-url")
1150 })?;
1151 let revision = backend_dev_revision(url).await?;
1152 scaffold.insert("apple-backend-revision".to_owned(), revision);
1153 }
1154 FrameworkChannel::Nightly => {
1155 if let Some(revision) = certification
1156 .and_then(|certification| certification.scaffold.get("apple-backend-revision"))
1157 {
1158 validate_revision(revision)
1159 .wrap_err("nightly certification scaffold `apple-backend-revision`")?;
1160 scaffold.insert("apple-backend-revision".to_owned(), revision.clone());
1161 }
1162 }
1163 FrameworkChannel::Stable | FrameworkChannel::Dev => {}
1164 }
1165 Ok(())
1166}
1167
1168async fn dev_submodules(
1173 slug: &str,
1174 revision: &str,
1175 scaffold: &BTreeMap<String, String>,
1176 submodule_repositories: &BTreeMap<String, String>,
1177) -> Result<BTreeMap<String, String>> {
1178 let mut submodules = BTreeMap::new();
1179 for path in BACKEND_SUBMODULES {
1180 if declares_backend_revision(scaffold, path) {
1181 continue;
1182 }
1183 submodules.insert(
1184 (*path).to_owned(),
1185 submodule_revision(slug, revision, path).await?,
1186 );
1187 }
1188 if !declares_apple_backend_pin(scaffold) {
1191 submodules.insert(
1192 "backends/apple".to_owned(),
1193 submodule_revision(slug, revision, "backends/apple").await?,
1194 );
1195 }
1196 for path in submodule_repositories.keys() {
1201 if !submodules.contains_key(path)
1202 && let Some(commit) = submodule_pin(slug, revision, path).await?
1203 {
1204 submodules.insert(path.clone(), commit);
1205 }
1206 }
1207 Ok(submodules)
1208}
1209
1210fn submodule_pins(
1213 repositories: BTreeMap<String, String>,
1214 submodules: &BTreeMap<String, String>,
1215) -> BTreeMap<String, SubmodulePin> {
1216 repositories
1217 .into_iter()
1218 .filter_map(|(path, url)| {
1219 submodules.get(&path).map(|commit| {
1220 (
1221 path,
1222 SubmodulePin {
1223 repository: canonical_git_url(&url).to_owned(),
1224 commit: commit.clone(),
1225 },
1226 )
1227 })
1228 })
1229 .collect()
1230}
1231
1232fn certified_source(
1237 certification: &Certification,
1238 repository: &str,
1239 revision: &str,
1240 metadata: &toml::Table,
1241 scaffold: &BTreeMap<String, String>,
1242 experimental_packages: &BTreeMap<String, ExperimentalPackage>,
1243 lock_sha256: &str,
1244) -> Result<Source> {
1245 let channel = certification.channel;
1246 if certification.metadata != *metadata {
1247 bail!("{channel} framework metadata does not match its certification");
1248 }
1249 for (key, value) in scaffold {
1250 if certification.scaffold.get(key) != Some(value) {
1251 bail!("{channel} certification scaffold `{key}` does not match the framework manifest");
1252 }
1253 }
1254 if certification.experimental_packages != *experimental_packages {
1258 bail!("{channel} certification experimental packages do not match the framework manifest");
1259 }
1260 for name in experimental_packages.keys() {
1261 if certification
1262 .scaffold
1263 .contains_key(&format!("{name}-version"))
1264 {
1265 bail!("{channel} certification scaffold `{name}-version` names a withheld package");
1266 }
1267 }
1268 let expected = certification
1269 .lockfiles
1270 .get("Cargo.lock")
1271 .ok_or_else(|| eyre!("{channel} certification has no dependency lock"))?;
1272 if lock_sha256 != *expected {
1273 bail!("{channel} dependency lock does not match its certification");
1274 }
1275 let release = FrameworkRelease {
1276 repository: repository.to_owned(),
1277 revision: revision.to_owned(),
1278 tag: certification.tag.clone(),
1279 };
1280 Ok(match certification.channel {
1281 FrameworkChannel::Stable => Source::Stable {
1282 release: Some(release),
1283 },
1284 FrameworkChannel::Nightly => Source::Nightly {
1285 repository: repository.to_owned(),
1286 revision: revision.to_owned(),
1287 tag: certification.tag.clone(),
1288 lock_sha256: lock_sha256.to_owned(),
1289 },
1290 FrameworkChannel::Dev => unreachable!("verify_certification rejects a dev manifest"),
1291 })
1292}
1293
1294const BACKEND_SUBMODULES: &[&str] = &["backends/android"];
1301
1302fn declares_apple_backend_pin(scaffold: &BTreeMap<String, String>) -> bool {
1303 scaffold.contains_key("apple-backend-version")
1304 || declares_backend_revision(scaffold, "backends/apple")
1305}
1306
1307fn declares_backend_revision(scaffold: &BTreeMap<String, String>, submodule_path: &str) -> bool {
1310 scaffold.contains_key(&format!(
1311 "{}-backend-revision",
1312 backend_name(submodule_path)
1313 ))
1314}
1315
1316const FRAMEWORK_PACKAGES: &[&str] = &[
1320 "waterui",
1321 "waterui-core",
1322 "waterui-testing",
1323 "waterui-ffi",
1324 "waterui-locale",
1325 "waterui-browser-cef",
1326 "waterui-preview",
1327 "waterui-preview-protocol",
1328 "waterui-mcp",
1329];
1330
1331fn backend_name(submodule_path: &str) -> &str {
1332 submodule_path
1333 .rsplit('/')
1334 .next()
1335 .expect("a submodule path has a basename")
1336}
1337
1338fn framework_repository() -> &'static str {
1343 env!("WATERUI_FRAMEWORK_REPOSITORY").trim_end_matches(".git")
1344}
1345
1346fn repository_slug(repository: &str) -> Result<&str> {
1348 repository
1349 .strip_prefix("https://github.com/")
1350 .ok_or_else(|| eyre!("{repository} must identify its GitHub source"))
1351}
1352
1353fn framework_metadata(manifest: &toml::Value) -> Result<toml::Table> {
1357 manifest
1358 .get("package")
1359 .and_then(|package| package.get("metadata"))
1360 .and_then(|metadata| metadata.get("waterui"))
1361 .map_or_else(
1362 || Ok(toml::Table::new()),
1363 |metadata| {
1364 metadata
1365 .clone()
1366 .try_into()
1367 .wrap_err("invalid package.metadata.waterui metadata")
1368 },
1369 )
1370}
1371
1372fn framework_scaffold(manifest: &toml::Value) -> Result<BTreeMap<String, String>> {
1383 let metadata = framework_metadata(manifest)?;
1384 let workspace = manifest
1385 .get("workspace")
1386 .and_then(|workspace| workspace.get("dependencies"))
1387 .and_then(toml::Value::as_table)
1388 .ok_or_else(|| eyre!("framework manifest has no [workspace.dependencies]"))?;
1389 let packages = metadata
1390 .get("scaffold-packages")
1391 .and_then(toml::Value::as_array)
1392 .ok_or_else(|| {
1393 eyre!("framework manifest has no package.metadata.waterui.scaffold-packages")
1394 })?;
1395 let mut scaffold = BTreeMap::new();
1396 for package in packages {
1397 let name = package.as_str().ok_or_else(|| {
1398 eyre!("package.metadata.waterui.scaffold-packages entries must be crate names")
1399 })?;
1400 let dependency = workspace.get(name).ok_or_else(|| {
1401 eyre!("scaffold package {name} has no [workspace.dependencies] requirement")
1402 })?;
1403 let requirement = dependency
1404 .as_str()
1405 .or_else(|| dependency.get("version").and_then(toml::Value::as_str))
1406 .ok_or_else(|| eyre!("workspace.dependencies.{name} declares no version"))?;
1407 scaffold.insert(format!("{name}-version"), requirement.to_owned());
1408 if let Some(git) = dependency.get("git").and_then(toml::Value::as_str) {
1412 let revision = dependency
1413 .get("rev")
1414 .and_then(toml::Value::as_str)
1415 .ok_or_else(|| {
1416 eyre!("workspace.dependencies.{name} must pin an immutable Git revision")
1417 })?;
1418 validate_revision(revision)
1419 .wrap_err_with(|| format!("workspace.dependencies.{name}.rev"))?;
1420 scaffold.insert(format!("{name}-git"), git.to_owned());
1421 scaffold.insert(format!("{name}-rev"), revision.to_owned());
1422 }
1423 }
1424 for (key, value) in &metadata {
1425 if !(key.ends_with("-backend-url")
1426 || key.ends_with("-backend-version")
1427 || key.ends_with("-backend-revision"))
1428 {
1429 continue;
1430 }
1431 let value = value
1432 .as_str()
1433 .ok_or_else(|| eyre!("package.metadata.waterui.{key} must be a string"))?;
1434 if key.ends_with("-backend-revision") {
1435 validate_revision(value).wrap_err_with(|| format!("package.metadata.waterui.{key}"))?;
1436 }
1437 scaffold.insert(key.clone(), value.to_owned());
1438 }
1439 Ok(scaffold)
1440}
1441
1442fn split_experimental_packages(
1448 scaffold: &mut BTreeMap<String, String>,
1449) -> BTreeMap<String, ExperimentalPackage> {
1450 let mut experimental = BTreeMap::new();
1451 for (key, value) in std::mem::take(scaffold) {
1455 if let Some(name) = key.strip_suffix("-git") {
1456 experimental.insert(
1457 name.to_owned(),
1458 ExperimentalPackage {
1459 version: String::new(),
1460 git: value,
1461 rev: String::new(),
1462 },
1463 );
1464 } else {
1465 scaffold.insert(key, value);
1466 }
1467 }
1468 for (name, package) in &mut experimental {
1469 package.rev = scaffold
1470 .remove(&format!("{name}-rev"))
1471 .expect("a `-git` scaffold entry carries `-rev`");
1472 package.version = scaffold
1473 .remove(&format!("{name}-version"))
1474 .expect("a `-git` scaffold entry carries `-version`");
1475 }
1476 experimental
1477}
1478
1479pub(crate) fn manifest_rust_version(manifest: &toml::Value) -> Result<Option<cargo_toml::SemVer>> {
1484 let declared = manifest
1485 .get("workspace")
1486 .and_then(|workspace| workspace.get("package"))
1487 .and_then(|package| package.get("rust-version"))
1488 .or_else(|| {
1489 manifest
1490 .get("package")
1491 .and_then(|package| package.get("rust-version"))
1492 });
1493 declared
1494 .map(|value| {
1495 let text = value
1496 .as_str()
1497 .ok_or_else(|| eyre!("rust-version must be a string"))?;
1498 crate::utils::parse_semver_version(text).wrap_err("invalid rust-version")
1499 })
1500 .transpose()
1501}
1502
1503fn minimum_cli_version(metadata: &toml::Table) -> Result<Option<cargo_toml::SemVer>> {
1507 metadata
1508 .get("minimum-cli-version")
1509 .cloned()
1510 .map(toml::Value::try_into)
1511 .transpose()
1512 .wrap_err("invalid package.metadata.waterui.minimum-cli-version")
1513}
1514
1515fn checkout_cli_update() -> String {
1519 format!(
1520 "cargo install {} --git {} --locked",
1521 env!("CARGO_PKG_NAME"),
1522 env!("CARGO_PKG_REPOSITORY")
1523 )
1524}
1525
1526fn registry_cli_update(minimum: &cargo_toml::SemVer) -> String {
1527 format!(
1528 "cargo install {} --version '>={minimum}' --locked",
1529 env!("CARGO_PKG_NAME")
1530 )
1531}
1532
1533fn validate_installed_cli(minimum: &cargo_toml::SemVer, update: &str) -> Result<()> {
1534 let current = env!("CARGO_PKG_VERSION")
1535 .parse()
1536 .expect("CLI package version is valid");
1537 validate_cli_version(minimum, ¤t, update)
1538}
1539
1540fn validate_cli_version(
1541 minimum: &cargo_toml::SemVer,
1542 current: &cargo_toml::SemVer,
1543 update: &str,
1544) -> Result<()> {
1545 if current.cmp_precedence(minimum).is_lt() {
1546 bail!(
1547 "This WaterUI framework requires waterui-cli >= {minimum}, but the running CLI is {current}.\nUpdate the CLI: {update}\nThen verify the installed version with `water --version`."
1548 );
1549 }
1550 Ok(())
1551}
1552
1553pub(crate) async fn validate_local_cli(root: &Path) -> Result<()> {
1554 let contents = smol::fs::read_to_string(root.join("Cargo.toml")).await?;
1555 let manifest = toml::from_str(&contents)?;
1556 if let Some(minimum) = minimum_cli_version(&framework_metadata(&manifest)?)? {
1557 validate_installed_cli(&minimum, &checkout_cli_update())?;
1558 }
1559 Ok(())
1560}
1561
1562pub(crate) fn validate_resolved_cli(metadata: &cargo_metadata::Metadata) -> Result<()> {
1563 for package in metadata
1564 .packages
1565 .iter()
1566 .filter(|package| package.name.as_str() == "waterui")
1567 {
1568 let Some(value) = package
1569 .metadata
1570 .get("waterui")
1571 .and_then(|metadata| metadata.get("minimum-cli-version"))
1572 else {
1573 continue;
1574 };
1575 let minimum: cargo_toml::SemVer = serde_json::from_value(value.clone())
1576 .wrap_err("invalid package.metadata.waterui.minimum-cli-version")?;
1577 let source = package
1578 .source
1579 .as_ref()
1580 .map(|source| source.repr.parse::<cargo_lock::SourceId>())
1581 .transpose()?;
1582 let update = match source {
1583 Some(source) if !source.is_git() => registry_cli_update(&minimum),
1584 Some(_) | None => checkout_cli_update(),
1585 };
1586 validate_installed_cli(&minimum, &update)?;
1587 }
1588 Ok(())
1589}
1590
1591fn resolve_packages(
1592 scaffold: &BTreeMap<String, String>,
1593 lock: &Lockfile,
1594 repository: &str,
1595 revision: &str,
1596) -> Result<BTreeMap<String, DependencyDetail>> {
1597 let mut packages = BTreeMap::new();
1598 for (key, version) in scaffold {
1599 let Some(name) = key.strip_suffix("-version") else {
1600 continue;
1601 };
1602 if name.ends_with("-backend") {
1605 continue;
1606 }
1607 let requirement: cargo_toml::VersionReq = version.parse().wrap_err_with(|| {
1614 eyre!(
1615 "framework scaffold requirement {name} = {version:?} is not a version requirement"
1616 )
1617 })?;
1618 let candidates: Vec<_> = lock
1619 .packages
1620 .iter()
1621 .filter(|package| {
1622 package.name.as_str() == name && requirement.matches(&package.version)
1623 })
1624 .collect();
1625 if let Some(git) = scaffold.get(&format!("{name}-git")) {
1630 let pinned = scaffold.get(&format!("{name}-rev")).ok_or_else(|| {
1631 eyre!("framework scaffold declares {name}-git without {name}-rev")
1632 })?;
1633 match candidates.as_slice() {
1634 [] => {}
1635 [package] => assert_declared_git_source(name, package, git, pinned)?,
1636 _ => bail!("framework lock has multiple sources for {name} {version}"),
1637 }
1638 packages.insert(
1639 name.to_owned(),
1640 DependencyDetail {
1641 version: Some(version.parse()?),
1642 git: Some(git.clone()),
1643 rev: Some(pinned.clone()),
1644 ..Default::default()
1645 },
1646 );
1647 continue;
1648 }
1649 let package = match candidates.as_slice() {
1650 [package] => *package,
1651 [] => {
1657 packages.insert(
1658 name.to_owned(),
1659 DependencyDetail {
1660 version: Some(format!("={version}").parse()?),
1661 ..Default::default()
1662 },
1663 );
1664 continue;
1665 }
1666 _ => bail!("framework lock has multiple sources for {name} {version}"),
1667 };
1668 let mut dependency = DependencyDetail::default();
1669 match &package.source {
1670 None => {
1671 dependency.git = Some(repository.to_owned());
1672 dependency.rev = Some(revision.to_owned());
1673 }
1674 Some(source) if source.is_default_registry() => {
1675 dependency.version = Some(format!("={version}").parse()?);
1676 }
1677 Some(source) if source.is_git() => {
1678 let Some(cargo_lock::package::GitReference::Rev(revision)) = source.git_reference()
1679 else {
1680 bail!(
1681 "framework package {name} must use an immutable Git revision in the framework manifest"
1682 );
1683 };
1684 validate_revision(revision)?;
1685 if source.precise() != Some(revision.as_str()) {
1686 bail!("framework package {name} does not resolve its declared revision");
1687 }
1688 dependency.git = Some(source.url().to_string());
1689 dependency.rev = Some(revision.clone());
1690 }
1691 Some(source) => bail!("unsupported framework package source for {name}: {source}"),
1692 }
1693 packages.insert(name.to_owned(), dependency);
1694 }
1695 Ok(packages)
1696}
1697
1698fn assert_declared_git_source(
1702 name: &str,
1703 package: &cargo_lock::Package,
1704 git: &str,
1705 revision: &str,
1706) -> Result<()> {
1707 let Some(source) = &package.source else {
1708 bail!("framework package {name} is a workspace member, not the declared {git}");
1709 };
1710 let declared = cargo_lock::package::GitReference::Rev(revision.to_owned());
1711 if !(source.is_git()
1712 && source.git_reference() == Some(&declared)
1713 && source.precise() == Some(revision)
1714 && canonical_git_url(source.url().as_str()) == canonical_git_url(git))
1715 {
1716 bail!("framework package {name} resolves {source}, not the declared {git}@{revision}");
1717 }
1718 Ok(())
1719}
1720
1721#[derive(Deserialize)]
1722struct SubmoduleEntry {
1723 sha: String,
1724 #[serde(rename = "type")]
1725 kind: String,
1726}
1727
1728fn complete_scaffold(
1733 scaffold: &mut BTreeMap<String, String>,
1734 submodules: &BTreeMap<String, String>,
1735 lock: &Lockfile,
1736) -> Result<()> {
1737 for &submodule in BACKEND_SUBMODULES {
1741 if declares_backend_revision(scaffold, submodule) {
1742 continue;
1743 }
1744 let commit = submodules
1745 .get(submodule)
1746 .ok_or_else(|| eyre!("framework records no {submodule} submodule pin"))?;
1747 validate_revision(commit)?;
1748 scaffold.insert(
1749 format!("{}-backend-revision", backend_name(submodule)),
1750 commit.clone(),
1751 );
1752 }
1753 if !declares_apple_backend_pin(scaffold) {
1757 let commit = submodules
1758 .get("backends/apple")
1759 .ok_or_else(|| eyre!("framework records no Apple backend pin"))?;
1760 validate_revision(commit)?;
1761 scaffold.insert("apple-backend-revision".to_owned(), commit.clone());
1762 }
1763 for &name in FRAMEWORK_PACKAGES {
1764 let candidates: Vec<_> = lock
1765 .packages
1766 .iter()
1767 .filter(|package| package.name.as_str() == name)
1768 .collect();
1769 let version = match candidates.as_slice() {
1770 [package] => package.version.to_string(),
1771 [] => bail!("framework lock has no package named {name}"),
1772 _ => bail!("framework lock has multiple packages named {name}"),
1773 };
1774 scaffold.insert(format!("{name}-version"), version);
1775 }
1776 Ok(())
1777}
1778
1779async fn local_submodule_revision(root: &Path, path: &str) -> Result<String> {
1783 let treeish = format!("HEAD:{path}");
1784 let output = Command::new("git")
1785 .arg("-C")
1786 .arg(root)
1787 .args(["rev-parse", treeish.as_str()])
1788 .output()
1789 .await?;
1790 if !output.status.success() {
1791 bail!(
1792 "the WaterUI checkout at {} records no {path} submodule pin: {}",
1793 root.display(),
1794 String::from_utf8_lossy(&output.stderr).trim()
1795 );
1796 }
1797 let revision = std::str::from_utf8(&output.stdout)?.trim().to_owned();
1798 validate_revision(&revision)?;
1799 Ok(revision)
1800}
1801
1802async fn submodule_revision(slug: &str, revision: &str, path: &str) -> Result<String> {
1806 let bytes = fetch(&format!(
1807 "https://api.github.com/repos/{slug}/contents/{path}?ref={revision}"
1808 ))
1809 .await?;
1810 let entry: SubmoduleEntry = serde_json::from_slice(&bytes)?;
1811 if entry.kind != "submodule" {
1812 bail!(
1813 "{path} at {slug}@{revision} is a {}, not a submodule",
1814 entry.kind
1815 );
1816 }
1817 Ok(entry.sha)
1818}
1819
1820async fn submodule_pin(slug: &str, revision: &str, path: &str) -> Result<Option<String>> {
1824 let Some(bytes) = fetch_optional(&format!(
1825 "https://api.github.com/repos/{slug}/contents/{path}?ref={revision}"
1826 ))
1827 .await?
1828 else {
1829 return Ok(None);
1830 };
1831 let Ok(entry) = serde_json::from_slice::<SubmoduleEntry>(&bytes) else {
1834 return Ok(None);
1835 };
1836 Ok((entry.kind == "submodule").then_some(entry.sha))
1837}
1838
1839fn parse_gitmodules(contents: &str) -> BTreeMap<String, String> {
1843 let mut submodules = BTreeMap::new();
1844 let mut submodule = false;
1845 let mut path = None::<String>;
1846 let mut url = None::<String>;
1847 for line in contents.lines().map(str::trim) {
1848 if line.starts_with('[') {
1849 if submodule && let (Some(path), Some(url)) = (path.take(), url.take()) {
1850 submodules.insert(path, url);
1851 }
1852 submodule = line.starts_with("[submodule");
1853 continue;
1854 }
1855 if !submodule {
1856 continue;
1857 }
1858 if let Some((key, value)) = line.split_once('=') {
1859 match key.trim() {
1860 "path" => path = Some(value.trim().trim_matches('"').to_owned()),
1861 "url" => url = Some(value.trim().trim_matches('"').to_owned()),
1862 _ => {}
1863 }
1864 }
1865 }
1866 if submodule && let (Some(path), Some(url)) = (path, url) {
1867 submodules.insert(path, url);
1868 }
1869 submodules
1870}
1871
1872fn validate_revision(revision: &str) -> Result<()> {
1873 if revision.len() != 40 || !revision.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1874 bail!("framework revision must be a full Git commit hash");
1875 }
1876 Ok(())
1877}
1878
1879async fn fetch(url: &str) -> Result<Vec<u8>> {
1880 fetch_optional(url)
1881 .await?
1882 .ok_or_else(|| eyre!("framework resolution returned HTTP 404 from {url}"))
1883}
1884
1885async fn fetch_optional(url: &str) -> Result<Option<Vec<u8>>> {
1890 let mut client = zenwave::client();
1891 let response = match client
1892 .method(Method::GET, url)?
1893 .header("User-Agent", env!("CARGO_PKG_NAME"))?
1894 .await
1895 {
1896 Ok(response) => response,
1897 Err(zenwave::Error::Http { status, .. }) if status == StatusCode::NOT_FOUND => {
1898 return Ok(None);
1899 }
1900 Err(error) => return Err(error.into()),
1901 };
1902 Ok(Some(response.into_body().into_bytes().await?.to_vec()))
1903}
1904
1905#[cfg(test)]
1906pub(crate) mod test_fixtures {
1907 use std::process::Command as StdCommand;
1908
1909 use super::*;
1910
1911 pub fn stable_framework() -> ResolvedFramework {
1917 let revision = |seed: char| seed.to_string().repeat(40);
1918 let scaffold = FRAMEWORK_PACKAGES
1919 .iter()
1920 .map(|name| (format!("{name}-version"), "0.4.1".to_owned()))
1921 .chain([
1922 ("hydrolysis-version".to_owned(), "0.2.1".to_owned()),
1923 ("hydrolysis-m3-version".to_owned(), "0.2.0".to_owned()),
1924 (
1925 "apple-backend-url".to_owned(),
1926 "https://github.com/water-rs/apple-backend.git".to_owned(),
1927 ),
1928 ("apple-backend-version".to_owned(), "0.3.0-dev.2".to_owned()),
1929 (
1930 "android-backend-url".to_owned(),
1931 "https://github.com/water-rs/android-backend.git".to_owned(),
1932 ),
1933 ("android-backend-revision".to_owned(), revision('c')),
1934 ])
1935 .collect();
1936 ResolvedFramework {
1937 source: Source::Stable {
1938 release: Some(FrameworkRelease {
1939 repository: framework_repository().to_owned(),
1940 revision: revision('a'),
1941 tag: "v0.4.1".to_owned(),
1942 }),
1943 },
1944 minimum_cli_version: None,
1945 rust_version: None,
1946 metadata: toml::toml! {
1947 android-min-api-level = 26
1948 },
1949 scaffold,
1950 experimental_packages: experimental_scaffold_packages(),
1951 packages: BTreeMap::new(),
1952 patches: PatchSet::default(),
1953 }
1954 }
1955
1956 fn experimental_scaffold_packages() -> BTreeMap<String, ExperimentalPackage> {
1962 let experimental = |version: &str, git: &str, seed: char| ExperimentalPackage {
1963 version: version.to_owned(),
1964 git: git.to_owned(),
1965 rev: seed.to_string().repeat(40),
1966 };
1967 BTreeMap::from([
1968 (
1969 "waterui-dew".to_owned(),
1970 experimental("0.2.1", "https://github.com/water-rs/dew", 'b'),
1971 ),
1972 (
1973 "waterui-gtk".to_owned(),
1974 experimental("0.2.0", "https://github.com/water-rs/gtk-backend", 'g'),
1975 ),
1976 (
1977 "waterui-winui".to_owned(),
1978 experimental("0.1.0", "https://github.com/water-rs/waterui-winui", 'e'),
1979 ),
1980 ])
1981 }
1982
1983 pub fn dev_framework() -> ResolvedFramework {
1990 let mut framework = stable_framework();
1991 let revision = 'a'.to_string().repeat(40);
1992 framework.source = Source::Dev {
1993 repository: framework_repository().to_owned(),
1994 revision: revision.clone(),
1995 lock_sha256: 'f'.to_string().repeat(64),
1996 };
1997 framework.scaffold.insert(
1998 "apple-backend-revision".to_owned(),
1999 'd'.to_string().repeat(40),
2000 );
2001 for (name, package) in std::mem::take(&mut framework.experimental_packages) {
2002 framework
2003 .scaffold
2004 .insert(format!("{name}-version"), package.version);
2005 framework
2006 .scaffold
2007 .insert(format!("{name}-git"), package.git);
2008 framework
2009 .scaffold
2010 .insert(format!("{name}-rev"), package.rev);
2011 }
2012 framework.packages = resolve_packages(
2013 &framework.scaffold,
2014 &test_lock(),
2015 framework_repository(),
2016 &revision,
2017 )
2018 .expect("the fixture lock resolves every scaffold requirement");
2019 framework
2020 }
2021
2022 pub fn nightly_framework(backend_revision: bool) -> ResolvedFramework {
2027 let mut framework = dev_framework();
2028 if !backend_revision {
2029 framework.scaffold.remove("apple-backend-revision");
2030 }
2031 framework.source = Source::Nightly {
2032 repository: framework_repository().to_owned(),
2033 revision: 'a'.to_string().repeat(40),
2034 tag: "nightly-2026.09.15".to_owned(),
2035 lock_sha256: 'f'.to_string().repeat(64),
2036 };
2037 framework
2038 }
2039
2040 pub fn write_local_checkout(root: &Path) {
2045 std::fs::create_dir_all(root).expect("checkout dir");
2046 std::fs::write(root.join("Cargo.toml"), local_checkout_manifest()).expect("manifest");
2047 let lock = test_lock();
2048 std::fs::write(root.join("Cargo.lock"), lock.to_string()).expect("lockfile");
2049 let git = |args: &[String]| {
2050 let status = StdCommand::new("git")
2051 .arg("-C")
2052 .arg(root)
2053 .args(args)
2054 .status()
2055 .expect("git must run");
2056 assert!(status.success(), "git {args:?} failed");
2057 };
2058 git(&["init".to_owned(), "-q".to_owned()]);
2059 git(&[
2060 "add".to_owned(),
2061 "Cargo.toml".to_owned(),
2062 "Cargo.lock".to_owned(),
2063 ]);
2064 git(&[
2065 "-c".to_owned(),
2066 "user.name=waterui-test".to_owned(),
2067 "-c".to_owned(),
2068 "user.email=waterui-test@waterui.dev".to_owned(),
2069 "commit".to_owned(),
2070 "-qm".to_owned(),
2071 "init".to_owned(),
2072 ]);
2073 }
2074
2075 pub fn write_apple_revision_checkout(root: &Path, revision: &str) {
2076 write_local_checkout(root);
2077 let manifest_path = root.join("Cargo.toml");
2078 let mut manifest: toml::Value =
2079 toml::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap();
2080 let metadata = manifest["package"]["metadata"]["waterui"]
2081 .as_table_mut()
2082 .unwrap();
2083 metadata.remove("apple-backend-version");
2084 metadata.insert(
2085 "apple-backend-revision".to_owned(),
2086 toml::Value::String(revision.to_owned()),
2087 );
2088 std::fs::write(manifest_path, toml::to_string(&manifest).unwrap()).unwrap();
2089 }
2090
2091 pub fn write_pre_decoupling_checkout(root: &Path) {
2095 write_local_checkout(root);
2096 write_submodule_pin(root, "backends/apple", 'b');
2097 write_submodule_pin(root, "backends/android", 'c');
2098 let manifest = local_checkout_manifest()
2099 .lines()
2100 .filter(|line| {
2101 let line = line.trim_start();
2102 !(line.starts_with("apple-backend-version")
2103 || line.starts_with("android-backend-revision"))
2104 })
2105 .collect::<Vec<_>>()
2106 .join("\n");
2107 assert!(
2108 !manifest.contains("apple-backend-version")
2109 && !manifest.contains("android-backend-revision"),
2110 "the fixture manifest moved; the pre-decoupling rewrite must be revisited"
2111 );
2112 std::fs::write(root.join("Cargo.toml"), manifest).expect("manifest");
2113 let git = |args: &[&str]| {
2114 let status = StdCommand::new("git")
2115 .arg("-C")
2116 .arg(root)
2117 .args(args)
2118 .status()
2119 .expect("git must run");
2120 assert!(status.success(), "git {args:?} failed");
2121 };
2122 git(&["add", "Cargo.toml"]);
2123 git(&[
2124 "-c",
2125 "user.name=waterui-test",
2126 "-c",
2127 "user.email=waterui-test@waterui.dev",
2128 "commit",
2129 "-qm",
2130 "pre-decoupling manifest",
2131 ]);
2132 }
2133
2134 fn write_submodule_pin(root: &Path, path: &str, seed: char) {
2137 let status = StdCommand::new("git")
2138 .arg("-C")
2139 .arg(root)
2140 .args([
2141 "update-index",
2142 "--add",
2143 "--cacheinfo",
2144 &format!("160000,{},{}", seed.to_string().repeat(40), path),
2145 ])
2146 .status()
2147 .expect("git must run");
2148 assert!(status.success(), "git update-index failed");
2149 }
2150
2151 fn local_checkout_manifest() -> &'static str {
2154 include_str!("../../tests/fixtures/framework_checkout_manifest.toml")
2155 }
2156
2157 pub fn test_lock() -> Lockfile {
2159 Lockfile {
2160 packages: FRAMEWORK_PACKAGES
2161 .iter()
2162 .map(|name| package(name, "0.4.1", None))
2163 .collect(),
2164 version: cargo_lock::ResolveVersion::V4,
2165 root: None,
2166 metadata: BTreeMap::default(),
2167 patch: cargo_lock::Patch::default(),
2168 }
2169 }
2170
2171 pub fn package(name: &str, version: &str, source: Option<&str>) -> cargo_lock::Package {
2172 cargo_lock::Package {
2173 name: name.parse().unwrap(),
2174 version: version.parse().unwrap(),
2175 source: source.map(|source| source.parse().unwrap()),
2176 checksum: None,
2177 dependencies: Vec::new(),
2178 replace: None,
2179 }
2180 }
2181}
2182
2183async fn resolve_dev(repository: &str, slug: &str) -> Result<String> {
2184 gated_dev_head(repository, slug, "dev.yml", "framework").await
2185}
2186
2187async fn backend_dev_revision(url: &str) -> Result<String> {
2193 gated_dev_head(
2194 url,
2195 repository_slug(url.trim_end_matches(".git"))?,
2196 "ci.yml",
2197 "Apple backend",
2198 )
2199 .await
2200}
2201
2202async fn gated_dev_head(repository: &str, slug: &str, gate: &str, what: &str) -> Result<String> {
2206 let output = Command::new("git")
2207 .args(["ls-remote", repository, "refs/heads/dev"])
2208 .output()
2209 .await?;
2210 if !output.status.success() {
2211 bail!(
2212 "could not resolve {what} dev: {}",
2213 String::from_utf8_lossy(&output.stderr)
2214 );
2215 }
2216 let revision = std::str::from_utf8(&output.stdout)?
2217 .split_whitespace()
2218 .next()
2219 .ok_or_else(|| eyre!("{what} repository has no dev branch"))?
2220 .to_owned();
2221 validate_revision(&revision)?;
2222 let response = fetch(&format!("https://api.github.com/repos/{slug}/actions/workflows/{gate}/runs?branch=dev&head_sha={revision}&status=success&event=push&per_page=1")).await?;
2223 let runs: serde_json::Value = serde_json::from_slice(&response)?;
2224 let checked = runs["workflow_runs"].as_array().is_some_and(|runs| {
2225 runs.iter().any(|run| {
2226 run["head_sha"].as_str() == Some(&revision) && run["conclusion"] == "success"
2227 })
2228 });
2229 if !checked {
2230 bail!("{what} dev revision {revision} has not passed its compilation gate");
2231 }
2232 Ok(revision)
2233}
2234
2235async fn latest_certification(
2238 repository: &str,
2239 channel: FrameworkChannel,
2240) -> Result<Certification> {
2241 let slug = repository_slug(repository)?;
2242 let mut releases = Vec::new();
2243 let mut page = 1;
2244 loop {
2245 let bytes = fetch(&format!(
2246 "https://api.github.com/repos/{slug}/releases?per_page=100&page={page}"
2247 ))
2248 .await?;
2249 let batch: Vec<Release> = serde_json::from_slice(&bytes)?;
2250 let complete = batch.len() < 100;
2251 releases.extend(
2252 batch
2253 .into_iter()
2254 .filter(|release| release_matches(release, channel)),
2255 );
2256 if complete {
2257 break;
2258 }
2259 page += 1;
2260 }
2261 let release = newest_release(releases, channel)?;
2262 let asset = certification_asset(&release, channel)?;
2263 let certification = parse_certification(&fetch(&asset.browser_download_url).await?)?;
2264 verify_certification(&certification, Some(&release), repository)?;
2265 certifies_channel(&certification, channel)?;
2266 Ok(certification)
2267}
2268
2269fn certifies_channel(certification: &Certification, channel: FrameworkChannel) -> Result<()> {
2272 if certification.channel != channel {
2273 bail!(
2274 "{} certifies the {} channel, not {channel}",
2275 certification.tag,
2276 certification.channel
2277 );
2278 }
2279 Ok(())
2280}
2281
2282#[derive(Deserialize)]
2286struct CertificationSchema {
2287 schema_version: u32,
2288}
2289
2290const CERTIFICATION_SCHEMA_VERSION: u32 = 2;
2291
2292fn parse_certification(bytes: &[u8]) -> Result<Certification> {
2293 let schema: CertificationSchema = serde_json::from_slice(bytes)?;
2294 if schema.schema_version != CERTIFICATION_SCHEMA_VERSION {
2295 bail!(
2296 "framework manifest schema version {} is not supported; this CLI requires schema version {CERTIFICATION_SCHEMA_VERSION}",
2297 schema.schema_version
2298 );
2299 }
2300 Ok(serde_json::from_slice(bytes)?)
2301}
2302
2303fn release_matches(release: &Release, channel: FrameworkChannel) -> bool {
2305 match channel {
2306 FrameworkChannel::Nightly => {
2307 release.prerelease && !release.draft && release.tag_name.starts_with("nightly-")
2308 }
2309 FrameworkChannel::Stable => {
2310 !release.prerelease && !release.draft && is_stable_tag(&release.tag_name)
2311 }
2312 FrameworkChannel::Dev => false,
2313 }
2314}
2315
2316fn is_stable_tag(tag: &str) -> bool {
2319 tag.strip_prefix('v').is_some_and(|version| {
2320 version
2321 .parse::<cargo_toml::SemVer>()
2322 .is_ok_and(|version| version.pre.is_empty() && version.build.is_empty())
2323 })
2324}
2325
2326fn newest_release(releases: Vec<Release>, channel: FrameworkChannel) -> Result<Release> {
2330 let version = |release: &Release| -> Option<cargo_toml::SemVer> {
2331 match channel {
2332 FrameworkChannel::Stable => release.tag_name.strip_prefix('v')?.parse().ok(),
2333 FrameworkChannel::Nightly | FrameworkChannel::Dev => None,
2334 }
2335 };
2336 releases
2337 .into_iter()
2338 .max_by(|left, right| {
2339 version(left)
2340 .cmp(&version(right))
2341 .then_with(|| left.published_at.cmp(&right.published_at))
2342 })
2343 .ok_or_else(|| match channel {
2344 FrameworkChannel::Nightly => {
2345 eyre!("no certified nightly exists; select dev or stable explicitly")
2346 }
2347 FrameworkChannel::Stable => eyre!(
2348 "no stable framework release carries a manifest yet; \
2349 select dev or nightly explicitly"
2350 ),
2351 FrameworkChannel::Dev => unreachable!("dev releases are not certified"),
2352 })
2353}
2354
2355const FIRST_STABLE_MANIFEST_RELEASE: &str = "0.5.0";
2359
2360#[derive(Debug, thiserror::Error)]
2364#[error(
2365 "stable release {tag} carries no framework.json — it predates manifest publishing. \
2366 The first stable release carrying a manifest is {FIRST_STABLE_MANIFEST_RELEASE}; \
2367 until it is published, use `water create <name> --channel dev` or, once a certified \
2368 nightly exists, `--channel nightly`."
2369)]
2370struct StableReleaseWithoutManifest {
2371 tag: String,
2373}
2374
2375fn certification_asset(release: &Release, channel: FrameworkChannel) -> Result<&ReleaseAsset> {
2377 release
2378 .assets
2379 .iter()
2380 .find(|asset| asset.name == "framework.json")
2381 .ok_or_else(|| match channel {
2382 FrameworkChannel::Nightly => {
2383 eyre!("nightly {} has no certification manifest", release.tag_name)
2384 }
2385 FrameworkChannel::Stable => StableReleaseWithoutManifest {
2386 tag: release.tag_name.clone(),
2387 }
2388 .into(),
2389 FrameworkChannel::Dev => unreachable!("dev releases are not certified"),
2390 })
2391}
2392
2393async fn load_manifest(path: &Path, repository: &str) -> Result<Certification> {
2397 let contents = smol::fs::read(path)
2398 .await
2399 .wrap_err_with(|| format!("failed to read framework manifest {}", path.display()))?;
2400 let certification = parse_certification(&contents)
2401 .wrap_err_with(|| format!("invalid framework manifest {}", path.display()))?;
2402 verify_certification(&certification, None, repository)?;
2403 Ok(certification)
2404}
2405
2406fn verify_certification(
2411 certification: &Certification,
2412 release: Option<&Release>,
2413 repository: &str,
2414) -> Result<()> {
2415 let channel = certification.channel;
2416 if certification.schema_version != CERTIFICATION_SCHEMA_VERSION {
2417 bail!(
2418 "framework manifest schema version {} is not supported; this CLI requires schema version {CERTIFICATION_SCHEMA_VERSION}",
2419 certification.schema_version
2420 );
2421 }
2422 if channel == FrameworkChannel::Dev {
2423 bail!("framework manifest channel `dev` is not a certified distribution");
2424 }
2425 if certification.repository != repository_slug(repository)? {
2426 bail!(
2427 "{channel} manifest names a different repository ({})",
2428 certification.repository
2429 );
2430 }
2431 if let Some(release) = release
2432 && certification.tag != release.tag_name
2433 {
2434 bail!("{channel} certification does not match its release");
2435 }
2436 validate_revision(&certification.revision)?;
2437 if let Some(minimum) = &minimum_cli_version(&certification.metadata)? {
2438 let update = match channel {
2439 FrameworkChannel::Stable => registry_cli_update(minimum),
2440 FrameworkChannel::Dev | FrameworkChannel::Nightly => checkout_cli_update(),
2441 };
2442 validate_installed_cli(minimum, &update)?;
2443 }
2444 Ok(())
2445}
2446
2447struct SubmodulePin {
2451 repository: String,
2453 commit: String,
2455}
2456
2457fn rebase_patches_onto_source(
2473 mut patches: PatchSet,
2474 repository: &str,
2475 revision: &str,
2476 submodules: &BTreeMap<String, SubmodulePin>,
2477) -> PatchSet {
2478 patches.retain(|source, _| !same_git_source(source, repository));
2479 for dependencies in patches.values_mut() {
2480 for dependency in dependencies.values_mut() {
2481 let Dependency::Detailed(detail) = dependency else {
2482 continue;
2483 };
2484 let Some(path) = detail.path.take() else {
2485 continue;
2486 };
2487 let path = path.trim_start_matches("./");
2488 let pin = submodules.iter().find_map(|(root, pin)| {
2489 (path == root.as_str() || path.starts_with(&format!("{root}/"))).then_some(pin)
2490 });
2491 let (git, rev) = pin.map_or((repository, revision), |pin| {
2492 (pin.repository.as_str(), pin.commit.as_str())
2493 });
2494 detail.git = Some(git.to_owned());
2495 detail.rev = Some(rev.to_owned());
2496 }
2497 }
2498 patches
2499}
2500
2501fn canonical_git_url(url: &str) -> &str {
2504 let url = url.split(['?', '#']).next().unwrap_or_default();
2505 url.trim_end_matches('/')
2506 .trim_end_matches(".git")
2507 .trim_end_matches('/')
2508}
2509
2510fn same_git_source(source: &str, repository: &str) -> bool {
2514 canonical_git_url(source) == canonical_git_url(repository)
2515}
2516
2517#[cfg(test)]
2518mod tests {
2519 use test_fixtures::{
2520 dev_framework, nightly_framework, package, stable_framework, test_lock,
2521 write_apple_revision_checkout, write_local_checkout, write_pre_decoupling_checkout,
2522 };
2523
2524 use super::*;
2525
2526 fn snapshot(lock: &Lockfile) -> (ResolvedFramework, Vec<u8>) {
2527 let bytes = lock.to_string().into_bytes();
2528 let scaffold = lock
2529 .packages
2530 .iter()
2531 .map(|package| {
2532 (
2533 format!("{}-version", package.name),
2534 package.version.to_string(),
2535 )
2536 })
2537 .collect();
2538 let repository = framework_repository();
2539 let revision = "a".repeat(40);
2540 let framework = ResolvedFramework {
2541 source: Source::Nightly {
2542 repository: repository.to_owned(),
2543 revision: revision.clone(),
2544 tag: "nightly-test".into(),
2545 lock_sha256: hex::encode(Sha256::digest(&bytes)),
2546 },
2547 minimum_cli_version: None,
2548 rust_version: None,
2549 metadata: toml::toml! {
2550 android-min-api-level = 26
2551 },
2552 packages: resolve_packages(&scaffold, lock, repository, &revision).unwrap(),
2553 scaffold,
2554 experimental_packages: BTreeMap::new(),
2555 patches: PatchSet::default(),
2556 };
2557 (framework, bytes)
2558 }
2559
2560 #[test]
2561 fn cli_requirement_uses_semver_precedence() {
2562 for (minimum, current, compatible) in [
2563 ("0.1.4", "0.1.3", false),
2564 ("0.1.4", "0.1.4", true),
2565 ("0.1.9", "0.1.10", true),
2566 ("0.1.4", "0.1.4-rc.1", false),
2567 ("0.1.4-rc.1", "0.1.4-rc.2", true),
2568 ("0.1.4+z", "0.1.4+a", true),
2569 ("0.1.4", "1.0.0", true),
2570 ] {
2571 let minimum = minimum.parse().unwrap();
2572 let current = current.parse().unwrap();
2573 let update = registry_cli_update(&minimum);
2574 let result = validate_cli_version(&minimum, ¤t, &update);
2575 assert_eq!(result.is_ok(), compatible, "{current} against {minimum}");
2576 if let Err(error) = result {
2577 let message = error.to_string();
2578 assert!(message.contains(&minimum.to_string()));
2579 assert!(message.contains(¤t.to_string()));
2580 assert!(message.contains(&update));
2581 assert!(message.contains("water --version"));
2582 }
2583 }
2584 }
2585
2586 #[test]
2587 fn cli_requirement_metadata_rejects_invalid_versions() {
2588 let mut metadata = toml::toml! {
2589 minimum-cli-version = "0.1.4"
2590 };
2591 assert_eq!(
2592 minimum_cli_version(&metadata).unwrap(),
2593 Some("0.1.4".parse().unwrap())
2594 );
2595 metadata["minimum-cli-version"] = toml::Value::String(">=0.1.4".into());
2596 assert!(minimum_cli_version(&metadata).is_err());
2597 assert!(minimum_cli_version(&toml::Table::new()).unwrap().is_none());
2598 }
2599
2600 #[test]
2601 fn android_min_api_level_is_required_framework_metadata() {
2602 assert_eq!(stable_framework().android_min_api_level().unwrap(), 26);
2603
2604 let mut missing = stable_framework();
2605 missing.metadata.remove("android-min-api-level");
2606 let error = missing.android_min_api_level().unwrap_err().to_string();
2607 assert!(error.contains("android-min-api-level"), "{error}");
2608 assert!(error.contains("v0.4.1"), "{error}");
2609
2610 let mut invalid = stable_framework();
2611 invalid.metadata["android-min-api-level"] = toml::Value::String("26".to_owned());
2612 let error = invalid.android_min_api_level().unwrap_err().to_string();
2613 assert!(error.contains("android-min-api-level"), "{error}");
2614 }
2615
2616 #[test]
2617 fn persisted_cli_requirement_blocks_an_older_cli_with_update_guidance() {
2618 let mut minimum: cargo_toml::SemVer = env!("CARGO_PKG_VERSION").parse().unwrap();
2619 minimum.major += 1;
2620 let mut framework = stable_framework();
2621 framework.minimum_cli_version = Some(minimum.clone());
2622 let contents = toml::to_string(&framework).unwrap();
2623 let framework: ResolvedFramework = toml::from_str(&contents).unwrap();
2624 let error = framework.validate_cli().unwrap_err().to_string();
2625 assert!(error.contains(®istry_cli_update(&minimum)));
2626 assert_eq!(framework.minimum_cli_version, Some(minimum));
2627 }
2628
2629 #[test]
2630 fn snapshot_preserves_independent_package_sources() {
2631 let backend_revision = "b".repeat(40);
2632 let backend_source =
2633 format!("git+https://example.com/hydrolysis?rev={backend_revision}#{backend_revision}");
2634 let lock = Lockfile {
2635 packages: vec![
2636 package("waterui", "0.3.0", None),
2637 package("hydrolysis", "0.1.0", Some(&backend_source)),
2638 package(
2639 "hydrolysis-m3",
2640 "0.1.0",
2641 Some("registry+https://github.com/rust-lang/crates.io-index"),
2642 ),
2643 ],
2644 version: cargo_lock::ResolveVersion::V4,
2645 root: None,
2646 metadata: BTreeMap::default(),
2647 patch: cargo_lock::Patch::default(),
2648 };
2649 let (framework, _) = snapshot(&lock);
2650 let persisted = toml::to_string(&framework).unwrap();
2651 let framework: ResolvedFramework = toml::from_str(&persisted).unwrap();
2652 assert_eq!(framework.channel(), Some(FrameworkChannel::Nightly));
2653 assert_eq!(framework.dependency("waterui").rev, Some("a".repeat(40)));
2654 let backend = framework.dependency("hydrolysis");
2655 assert_eq!(
2656 backend.git.as_deref(),
2657 Some("https://example.com/hydrolysis")
2658 );
2659 assert_eq!(backend.rev, Some(backend_revision));
2660 let theme = framework.dependency("hydrolysis-m3");
2661 assert!(theme.git.is_none());
2662 assert_eq!(theme.version.unwrap().to_string(), "=0.1.0");
2663 }
2664
2665 #[test]
2666 fn extracted_crate_absent_from_the_lock_resolves_to_its_declared_requirement() {
2667 let lock = Lockfile {
2672 packages: vec![package("waterui", "0.3.0", None)],
2673 version: cargo_lock::ResolveVersion::V4,
2674 root: None,
2675 metadata: BTreeMap::default(),
2676 patch: cargo_lock::Patch::default(),
2677 };
2678 let gtk_revision = "b".repeat(40);
2679 let scaffold = BTreeMap::from([
2680 ("waterui-version".to_string(), "0.3.0".to_string()),
2681 ("waterui-dew-version".to_string(), "0.2.1".to_string()),
2682 ("waterui-gtk-version".to_string(), "0.2.0".to_string()),
2683 (
2684 "waterui-gtk-git".to_string(),
2685 "https://github.com/water-rs/gtk-backend".to_string(),
2686 ),
2687 ("waterui-gtk-rev".to_string(), gtk_revision.clone()),
2688 ]);
2689 let packages =
2690 resolve_packages(&scaffold, &lock, framework_repository(), &"a".repeat(40)).unwrap();
2691 assert!(packages["waterui"].git.is_some());
2692 let dew = &packages["waterui-dew"];
2693 assert!(dew.git.is_none());
2694 assert_eq!(dew.version.as_ref().unwrap().to_string(), "=0.2.1");
2695 let gtk = &packages["waterui-gtk"];
2696 assert_eq!(
2697 gtk.git.as_deref(),
2698 Some("https://github.com/water-rs/gtk-backend")
2699 );
2700 assert_eq!(gtk.rev.as_deref(), Some(gtk_revision.as_str()));
2701 assert_eq!(gtk.version.as_ref().unwrap().to_string(), "^0.2.0");
2702 }
2703
2704 #[test]
2705 fn declared_git_source_must_agree_with_the_lock() {
2706 let locked_revision = "b".repeat(40);
2709 let drifted_revision = "c".repeat(40);
2710 for (lock_revision, expected) in [
2711 (locked_revision.as_str(), true),
2712 (drifted_revision.as_str(), false),
2713 ] {
2714 let source = format!(
2715 "git+https://github.com/water-rs/gtk-backend?rev={lock_revision}#{lock_revision}"
2716 );
2717 let lock = Lockfile {
2718 packages: vec![package("waterui-gtk", "0.2.0", Some(&source))],
2719 version: cargo_lock::ResolveVersion::V4,
2720 root: None,
2721 metadata: BTreeMap::default(),
2722 patch: cargo_lock::Patch::default(),
2723 };
2724 let scaffold = BTreeMap::from([
2725 ("waterui-gtk-version".to_string(), "0.2.0".to_string()),
2726 (
2727 "waterui-gtk-git".to_string(),
2728 "https://github.com/water-rs/gtk-backend".to_string(),
2729 ),
2730 ("waterui-gtk-rev".to_string(), locked_revision.clone()),
2731 ]);
2732 let result =
2733 resolve_packages(&scaffold, &lock, framework_repository(), &"a".repeat(40));
2734 assert_eq!(result.is_ok(), expected, "lock revision {lock_revision}");
2735 if expected {
2736 assert_eq!(
2737 result.unwrap()["waterui-gtk"].rev.as_deref(),
2738 Some(locked_revision.as_str())
2739 );
2740 }
2741 }
2742 }
2743
2744 #[test]
2745 fn resolve_packages_matches_the_requirement_against_the_lock() {
2746 let m3_revision = "14a35e3ef69ced36557a3c7ab11d52e0afb53ad5";
2752 let m3_source = format!(
2753 "git+https://github.com/water-rs/hydrolysis-m3?rev={m3_revision}#{m3_revision}"
2754 );
2755 let lock = Lockfile {
2756 packages: vec![
2757 package("waterui", "0.3.0", None),
2758 package("hydrolysis-m3", "0.2.1", Some(&m3_source)),
2759 ],
2760 version: cargo_lock::ResolveVersion::V4,
2761 root: None,
2762 metadata: BTreeMap::default(),
2763 patch: cargo_lock::Patch::default(),
2764 };
2765 let scaffold = BTreeMap::from([
2766 ("waterui-version".to_string(), "0.3.0".to_string()),
2767 ("hydrolysis-m3-version".to_string(), "0.2.0".to_string()),
2768 ]);
2769 let packages =
2770 resolve_packages(&scaffold, &lock, framework_repository(), &"a".repeat(40)).unwrap();
2771 let m3 = &packages["hydrolysis-m3"];
2772 assert_eq!(
2773 m3.git.as_deref(),
2774 Some("https://github.com/water-rs/hydrolysis-m3")
2775 );
2776 assert_eq!(m3.rev.as_deref(), Some(m3_revision));
2777 }
2778
2779 #[test]
2780 fn stable_dependency_honors_a_declared_git_source() {
2781 let mut framework = stable_framework();
2785 let revision = "b".repeat(40);
2786 framework
2787 .scaffold
2788 .insert("waterui-gtk-version".to_owned(), "0.1.2".to_owned());
2789 framework.scaffold.insert(
2790 "waterui-gtk-git".to_owned(),
2791 "https://github.com/water-rs/gtk-backend".to_owned(),
2792 );
2793 framework
2794 .scaffold
2795 .insert("waterui-gtk-rev".to_owned(), revision.clone());
2796 framework
2797 .scaffold
2798 .insert("waterui-dew-version".to_owned(), "0.2.1".to_owned());
2799 let gtk = framework.dependency("waterui-gtk");
2800 assert_eq!(
2801 gtk.git.as_deref(),
2802 Some("https://github.com/water-rs/gtk-backend")
2803 );
2804 assert_eq!(gtk.rev.as_deref(), Some(revision.as_str()));
2805 assert_eq!(gtk.version.as_ref().unwrap().to_string(), "^0.1.2");
2806 let dew = framework.dependency("waterui-dew");
2809 assert!(dew.git.is_none());
2810 assert_eq!(dew.version.as_ref().unwrap().to_string(), "=0.2.1");
2811 }
2812
2813 #[test]
2814 fn stable_withholds_the_git_pinned_scaffold_packages() {
2815 let framework = stable_framework();
2820 for name in ["waterui-dew", "waterui-gtk", "waterui-winui"] {
2821 let package = &framework.experimental_packages[name];
2822 assert_eq!(package.rev.len(), 40);
2823 assert!(!framework.scaffold.contains_key(&format!("{name}-version")));
2824 assert!(!framework.scaffold.contains_key(&format!("{name}-git")));
2825 let error = framework
2826 .require_distributable(name)
2827 .unwrap_err()
2828 .to_string();
2829 assert!(error.contains(name), "{error}");
2830 assert!(error.contains("stable"), "{error}");
2831 assert!(error.contains(&package.git), "{error}");
2832 assert!(error.contains("--channel dev"), "{error}");
2833 assert!(error.contains("--channel nightly"), "{error}");
2834 }
2835 for name in ["waterui", "hydrolysis", "hydrolysis-m3"] {
2837 framework
2838 .require_distributable(name)
2839 .unwrap_or_else(|error| panic!("{name} must scaffold on stable: {error}"));
2840 }
2841 }
2842
2843 #[test]
2844 fn dev_and_nightly_distribute_the_experimental_packages() {
2845 for framework in [dev_framework(), nightly_framework(true)] {
2846 for name in ["waterui-dew", "waterui-gtk", "waterui-winui"] {
2847 framework
2848 .require_distributable(name)
2849 .unwrap_or_else(|error| panic!("{name} must scaffold off stable: {error}"));
2850 let dependency = framework.dependency(name);
2851 assert!(
2852 dependency.git.is_some(),
2853 "{name} must keep its declared git pin"
2854 );
2855 assert_eq!(dependency.rev.as_deref().map(str::len), Some(40));
2856 }
2857 }
2858 }
2859
2860 #[test]
2861 fn a_legacy_stable_selection_re_derives_the_withheld_set() {
2862 let mut framework = stable_framework();
2866 for (name, package) in framework.experimental_packages.clone() {
2867 framework
2868 .scaffold
2869 .insert(format!("{name}-version"), package.version);
2870 framework
2871 .scaffold
2872 .insert(format!("{name}-git"), package.git);
2873 framework
2874 .scaffold
2875 .insert(format!("{name}-rev"), package.rev);
2876 }
2877 framework.experimental_packages.clear();
2878
2879 let framework = framework.validated().expect("fixture validates");
2880 assert_eq!(framework.experimental_packages.len(), 3);
2881 assert!(
2882 framework.require_distributable("waterui-winui").is_err(),
2883 "a stale `waterui-winui-git` entry must not resurrect the package"
2884 );
2885 }
2886
2887 #[test]
2888 fn a_stable_certification_must_agree_on_the_withheld_set() {
2889 let repository = framework_repository();
2890 let revision = "a".repeat(40);
2891 let lock_sha256 = "f".repeat(64);
2892 let metadata = toml::toml! { android-min-api-level = 26 };
2893 let mut scaffold = BTreeMap::from([
2894 ("waterui-version".to_owned(), "0.4.1".to_owned()),
2895 ("waterui-winui-version".to_owned(), "0.1.0".to_owned()),
2896 (
2897 "waterui-winui-git".to_owned(),
2898 "https://github.com/water-rs/waterui-winui".to_owned(),
2899 ),
2900 ("waterui-winui-rev".to_owned(), "e".repeat(40)),
2901 ]);
2902 let experimental_packages = split_experimental_packages(&mut scaffold);
2903
2904 let stable_certification = |experimental: BTreeMap<_, _>, scaffold| Certification {
2905 schema_version: 2,
2906 channel: FrameworkChannel::Stable,
2907 repository: "water-rs/waterui".to_owned(),
2908 revision: revision.clone(),
2909 tag: "v0.4.1".to_owned(),
2910 lockfiles: BTreeMap::from([("Cargo.lock".to_owned(), lock_sha256.clone())]),
2911 submodules: BTreeMap::new(),
2912 scaffold,
2913 experimental_packages: experimental,
2914 metadata: metadata.clone(),
2915 };
2916 let source = certified_source(
2917 &stable_certification(experimental_packages.clone(), scaffold.clone()),
2918 repository,
2919 &revision,
2920 &metadata,
2921 &scaffold,
2922 &experimental_packages,
2923 &lock_sha256,
2924 )
2925 .expect("a manifest carrying the withheld set verifies");
2926 assert!(matches!(source, Source::Stable { .. }));
2927
2928 let error = certified_source(
2931 &stable_certification(BTreeMap::new(), scaffold.clone()),
2932 repository,
2933 &revision,
2934 &metadata,
2935 &scaffold,
2936 &experimental_packages,
2937 &lock_sha256,
2938 )
2939 .unwrap_err()
2940 .to_string();
2941 assert!(error.contains("experimental packages"), "{error}");
2942
2943 let mut doubled = scaffold.clone();
2945 doubled.insert("waterui-winui-version".to_owned(), "0.1.0".to_owned());
2946 let error = certified_source(
2947 &stable_certification(experimental_packages.clone(), doubled),
2948 repository,
2949 &revision,
2950 &metadata,
2951 &scaffold,
2952 &experimental_packages,
2953 &lock_sha256,
2954 )
2955 .unwrap_err()
2956 .to_string();
2957 assert!(error.contains("waterui-winui-version"), "{error}");
2958 }
2959
2960 #[test]
2961 fn extracted_crate_validation_accepts_only_the_sanctioned_source() {
2962 let gtk_revision = "b".repeat(40);
2966 let mut framework = stable_framework();
2967 framework.packages.insert(
2968 "waterui-gtk".to_owned(),
2969 DependencyDetail {
2970 version: Some("0.2.0".parse().unwrap()),
2971 git: Some("https://github.com/water-rs/gtk-backend".to_owned()),
2972 rev: Some(gtk_revision.clone()),
2973 ..Default::default()
2974 },
2975 );
2976 framework.packages.insert(
2977 "waterui-dew".to_owned(),
2978 DependencyDetail {
2979 version: Some("=0.2.1".parse().unwrap()),
2980 ..Default::default()
2981 },
2982 );
2983 let identity = |name: &str, version: &str, source: String| LockedPackage {
2984 name: name.to_owned(),
2985 version: version.to_owned(),
2986 source: Some(source),
2987 };
2988 let gtk_source = |revision: &str| {
2989 format!("git+https://github.com/water-rs/gtk-backend?rev={revision}#{revision}")
2990 };
2991 assert!(framework.sanctioned_source(&identity(
2992 "waterui-gtk",
2993 "0.2.0",
2994 gtk_source(>k_revision)
2995 )));
2996 assert!(framework.sanctioned_source(&identity(
2998 "waterui-gtk",
2999 "0.2.1",
3000 gtk_source(>k_revision)
3001 )));
3002 let drifted = "c".repeat(40);
3004 assert!(!framework.sanctioned_source(&identity(
3005 "waterui-gtk",
3006 "0.2.0",
3007 gtk_source(&drifted)
3008 )));
3009 let registry = || "registry+https://github.com/rust-lang/crates.io-index".to_owned();
3011 assert!(framework.sanctioned_source(&identity("waterui-dew", "0.2.1", registry())));
3012 assert!(!framework.sanctioned_source(&identity("waterui-dew", "0.2.2", registry())));
3013 }
3014
3015 fn scaffolded(field: &str) -> String {
3021 let version = &stable_framework().scaffold[field];
3022 format!("={version}")
3023 }
3024
3025 #[test]
3026 fn channel_update_preserves_aliases_features_and_unrelated_dependencies() {
3027 let manifest = toml::toml! {
3028 [dependencies.ui]
3029 package = "waterui"
3030 path = "../waterui"
3031 default-features = false
3032 features = ["gpu"]
3033 [dependencies.serde]
3034 version = "1"
3035 features = ["derive"]
3036 [target."cfg(unix)".build-dependencies]
3037 waterui-core = "0.2"
3038 };
3039 let mut document = toml_edit::ser::to_document(&manifest).unwrap();
3040 stable_framework()
3041 .update_manifest(&mut document, &PatchSet::default())
3042 .unwrap();
3043 assert_eq!(
3044 document["dependencies"]["ui"]["package"].as_str(),
3045 Some("waterui")
3046 );
3047 assert!(document["dependencies"]["ui"].get("path").is_none());
3048 assert_eq!(
3049 document["dependencies"]["ui"]["version"].as_str(),
3050 Some(scaffolded("waterui-version").as_str())
3051 );
3052 assert_eq!(
3053 document["dependencies"]["ui"]["default-features"].as_bool(),
3054 Some(false)
3055 );
3056 assert_eq!(
3057 document["dependencies"]["ui"]["features"][0].as_str(),
3058 Some("gpu")
3059 );
3060 let updated: toml::Value = toml::from_str(&document.to_string()).unwrap();
3061 assert_eq!(
3062 updated["dependencies"]["serde"],
3063 manifest["dependencies"]["serde"]
3064 );
3065 assert_eq!(
3066 updated["target"]["cfg(unix)"]["build-dependencies"]["waterui-core"]["version"]
3067 .as_str(),
3068 Some(scaffolded("waterui-core-version").as_str())
3069 );
3070 }
3071
3072 #[test]
3073 fn channel_update_writes_patches_as_tables_and_clears_stale_ones() {
3074 let mut document: toml_edit::DocumentMut =
3075 "[package]\nname = \"app\"\n\n[dependencies]\nwaterui = \"0.3.0\"\n"
3076 .parse()
3077 .unwrap();
3078 let (dev, _) = snapshot(&Lockfile {
3079 packages: vec![package("waterui", "0.3.0", None)],
3080 version: cargo_lock::ResolveVersion::V4,
3081 root: None,
3082 metadata: BTreeMap::default(),
3083 patch: cargo_lock::Patch::default(),
3084 });
3085 let mut dev = dev;
3086 let vello: Dependency = toml::from_str::<toml::Value>(
3087 r#"git = "https://github.com/lexoliu/vello"
3088rev = "d68d9e9825bcd1ffee762323881c13a2e7a3f639""#,
3089 )
3090 .unwrap()
3091 .try_into()
3092 .unwrap();
3093 dev.patches
3094 .entry("crates-io".into())
3095 .or_default()
3096 .insert("vello".into(), vello);
3097 dev.update_manifest(&mut document, &PatchSet::default())
3098 .unwrap();
3099 let rendered = document.to_string();
3100 assert!(rendered.starts_with("[package]"), "{rendered}");
3101 assert!(rendered.contains("[patch.crates-io]\n"), "{rendered}");
3102 assert!(!rendered.contains("\n[patch]\n"), "{rendered}");
3103 assert_eq!(
3104 document["patch"]["crates-io"]["vello"]["rev"].as_str(),
3105 Some("d68d9e9825bcd1ffee762323881c13a2e7a3f639")
3106 );
3107 assert_eq!(
3108 document["dependencies"]["waterui"]["rev"].as_str(),
3109 Some("a".repeat(40).as_str())
3110 );
3111
3112 stable_framework()
3113 .update_manifest(&mut document, &dev.patches())
3114 .unwrap();
3115 let rendered = document.to_string();
3116 assert!(!rendered.contains("patch"), "{rendered}");
3117 assert_eq!(
3118 document["dependencies"]["waterui"]["version"].as_str(),
3119 Some(scaffolded("waterui-version").as_str())
3120 );
3121 }
3122
3123 #[test]
3124 fn snapshot_lock_preserves_external_sources_and_rewrites_local_edges() {
3125 let core = package("waterui-core", "0.3.0", None);
3126 let mut facade = package("waterui", "0.3.0", None);
3127 facade.dependencies.push(LockedDependency::from(&core));
3128 let theme = package(
3129 "hydrolysis-m3",
3130 "0.1.0",
3131 Some("registry+https://github.com/rust-lang/crates.io-index"),
3132 );
3133 let lock = Lockfile {
3134 packages: vec![facade, core, theme.clone()],
3135 version: cargo_lock::ResolveVersion::V4,
3136 root: None,
3137 metadata: BTreeMap::default(),
3138 patch: cargo_lock::Patch::default(),
3139 };
3140 let (framework, bytes) = snapshot(&lock);
3141 let resolved = framework.cargo_lock(&bytes).unwrap();
3142 let facade = resolved
3143 .packages
3144 .iter()
3145 .find(|package| package.name.as_str() == "waterui")
3146 .unwrap();
3147 let core = resolved
3148 .packages
3149 .iter()
3150 .find(|package| package.name.as_str() == "waterui-core")
3151 .unwrap();
3152 assert_eq!(facade.dependencies, vec![LockedDependency::from(core)]);
3153 assert!(core.source.as_ref().unwrap().is_git());
3154 assert!(resolved.packages.contains(&theme));
3155 let mut changed = bytes;
3156 changed.push(b'\n');
3157 assert!(
3158 framework
3159 .cargo_lock(&changed)
3160 .unwrap_err()
3161 .to_string()
3162 .contains("does not match")
3163 );
3164 }
3165
3166 #[test]
3167 fn rebase_patches_onto_source_redirects_git_source_dependencies() {
3168 let mut patches = PatchSet::default();
3169 let mut crates_io = std::collections::BTreeMap::new();
3170 crates_io.insert(
3171 "waterui-core".to_string(),
3172 Dependency::Detailed(Box::new(DependencyDetail {
3173 path: Some("core".to_string()),
3174 ..DependencyDetail::default()
3175 })),
3176 );
3177 crates_io.insert(
3178 "waterkit-audio".to_string(),
3179 Dependency::Detailed(Box::new(DependencyDetail {
3180 path: Some("kit/multimedia/audio".to_string()),
3181 ..DependencyDetail::default()
3182 })),
3183 );
3184 crates_io.insert(
3185 "vello".to_string(),
3186 Dependency::Detailed(Box::new(DependencyDetail {
3187 git: Some("https://github.com/lexoliu/vello".to_string()),
3188 rev: Some("5e5f538556be16527f67379b105af82f408b747d".to_string()),
3189 ..DependencyDetail::default()
3190 })),
3191 );
3192 patches.insert("crates-io".to_string(), crates_io);
3193 patches.insert(
3197 "https://github.com/water-rs/waterui".to_string(),
3198 std::collections::BTreeMap::new(),
3199 );
3200 patches.insert(
3201 "https://github.com/water-rs/waterui.git?branch=dev".to_string(),
3202 std::collections::BTreeMap::new(),
3203 );
3204
3205 let submodules = BTreeMap::from([(
3206 "kit".to_string(),
3207 SubmodulePin {
3208 repository: "https://github.com/water-rs/waterkit".to_string(),
3209 commit: "98c89ee702c5629094030023fb8d55464592d35d".to_string(),
3210 },
3211 )]);
3212 let rebased = rebase_patches_onto_source(
3213 patches,
3214 "https://github.com/water-rs/waterui",
3215 "475b4bb884a5f4e2b1156f1af74c40feaf71fdc1",
3216 &submodules,
3217 );
3218
3219 let Dependency::Detailed(core) = &rebased["crates-io"]["waterui-core"] else {
3221 panic!("a path patch stays a detailed dependency");
3222 };
3223 assert!(core.path.is_none());
3224 assert_eq!(
3225 core.git.as_deref(),
3226 Some("https://github.com/water-rs/waterui")
3227 );
3228 assert_eq!(
3229 core.rev.as_deref(),
3230 Some("475b4bb884a5f4e2b1156f1af74c40feaf71fdc1")
3231 );
3232
3233 let Dependency::Detailed(audio) = &rebased["crates-io"]["waterkit-audio"] else {
3236 panic!("a submodule path patch stays a detailed dependency");
3237 };
3238 assert_eq!(
3239 audio.git.as_deref(),
3240 Some("https://github.com/water-rs/waterkit")
3241 );
3242 assert_eq!(
3243 audio.rev.as_deref(),
3244 Some("98c89ee702c5629094030023fb8d55464592d35d")
3245 );
3246
3247 let Dependency::Detailed(vello) = &rebased["crates-io"]["vello"] else {
3251 panic!("a git patch stays a detailed dependency");
3252 };
3253 assert_eq!(
3254 vello.git.as_deref(),
3255 Some("https://github.com/lexoliu/vello")
3256 );
3257 assert!(!rebased.contains_key("https://github.com/water-rs/waterui"));
3258 assert!(!rebased.contains_key("https://github.com/water-rs/waterui.git?branch=dev"));
3259 }
3260
3261 #[test]
3262 fn coherence_admits_a_submodule_crate_at_either_source() {
3263 let revision = "a".repeat(40);
3264 let pin = "98c89ee702c5629094030023fb8d55464592d35d";
3265 let framework_source = format!("git+{}?rev={revision}#{revision}", framework_repository());
3266 let pin_source = format!("git+https://github.com/water-rs/waterkit?rev={pin}#{pin}");
3267 let (mut framework, _) = snapshot(&test_lock());
3268 framework
3269 .patches
3270 .entry("crates-io".into())
3271 .or_default()
3272 .insert(
3273 "waterkit-codec".into(),
3274 Dependency::Detailed(Box::new(DependencyDetail {
3275 git: Some("https://github.com/water-rs/waterkit".into()),
3276 rev: Some(pin.into()),
3277 ..DependencyDetail::default()
3278 })),
3279 );
3280 framework
3281 .patches
3282 .entry("crates-io".into())
3283 .or_default()
3284 .insert(
3285 "waterkit-fs".into(),
3286 Dependency::Detailed(Box::new(DependencyDetail {
3287 git: Some("https://github.com/water-rs/waterkit".into()),
3288 rev: Some(pin.into()),
3289 ..DependencyDetail::default()
3290 })),
3291 );
3292 let packages = vec![
3293 package("waterkit-codec", "0.1.1", Some(&framework_source)),
3296 package("waterkit-fs", "0.1.1", Some(&pin_source)),
3298 package(
3299 "serde",
3300 "1.0.0",
3301 Some("registry+https://github.com/rust-lang/crates.io-index"),
3302 ),
3303 ];
3304 let allowed = framework.allowed_packages(&packages);
3305 let identity = |name: &str, source: &str| LockedPackage {
3306 name: name.to_owned(),
3307 version: "0.1.1".to_owned(),
3308 source: Some(source.to_owned()),
3309 };
3310 assert!(allowed.contains(&identity("waterkit-codec", &framework_source)));
3311 assert!(allowed.contains(&identity("waterkit-codec", &pin_source)));
3312 assert!(allowed.contains(&identity("waterkit-fs", &pin_source)));
3313 assert!(allowed.contains(&identity("waterkit-fs", &framework_source)));
3314 assert_eq!(
3316 allowed
3317 .iter()
3318 .filter(|package| package.name == "serde")
3319 .count(),
3320 1
3321 );
3322 }
3323
3324 #[test]
3325 fn parse_gitmodules_reads_submodule_paths_and_urls() {
3326 let submodules = parse_gitmodules(
3327 "[submodule \"backends/android\"]\n\
3328 \tpath = backends/android\n\
3329 \turl = https://github.com/water-rs/android-backend.git\n\
3330 \tbranch = dev\n\
3331 [submodule \"kit\"]\n\
3332 \tpath = kit\n\
3333 \turl = \"https://github.com/water-rs/waterkit.git\"\n",
3334 );
3335 assert_eq!(
3336 submodules,
3337 BTreeMap::from([
3338 (
3339 "backends/android".to_string(),
3340 "https://github.com/water-rs/android-backend.git".to_string(),
3341 ),
3342 (
3343 "kit".to_string(),
3344 "https://github.com/water-rs/waterkit.git".to_string(),
3345 ),
3346 ])
3347 );
3348 }
3349
3350 fn release(tag: &str, draft: bool, prerelease: bool, published_at: &str) -> Release {
3351 Release {
3352 tag_name: tag.to_owned(),
3353 draft,
3354 prerelease,
3355 published_at: Some(published_at.to_owned()),
3356 assets: vec![ReleaseAsset {
3357 name: "framework.json".to_owned(),
3358 browser_download_url: format!(
3359 "https://github.com/water-rs/waterui/releases/download/{tag}/framework.json"
3360 ),
3361 }],
3362 }
3363 }
3364
3365 #[test]
3366 fn stable_release_selection_takes_the_newest_stable_tag() {
3367 let releases = vec![
3368 release("v0.4.0", false, false, "2025-10-01T00:00:00Z"),
3369 release("v0.5.0-rc.1", true, false, "2025-12-01T00:00:00Z"),
3372 release("nightly-2025-12-01", false, true, "2025-12-02T00:00:00Z"),
3373 release("v0.4.1", false, false, "2025-11-01T00:00:00Z"),
3374 release("v0.9.9", true, false, "2025-12-03T00:00:00Z"),
3375 release("v0.6.0+build.5", false, false, "2025-12-04T00:00:00Z"),
3377 release("v0.3.9", false, false, "2025-12-05T00:00:00Z"),
3380 ];
3381 let eligible: Vec<_> = releases
3382 .into_iter()
3383 .filter(|release| release_matches(release, FrameworkChannel::Stable))
3384 .collect();
3385 let release = newest_release(eligible, FrameworkChannel::Stable).unwrap();
3386 assert_eq!(release.tag_name, "v0.4.1");
3387 assert_eq!(
3388 certification_asset(&release, FrameworkChannel::Stable)
3389 .unwrap()
3390 .name,
3391 "framework.json"
3392 );
3393 }
3394
3395 #[test]
3396 fn stable_release_without_a_manifest_reports_it_predates_publishing() {
3397 let mut latest = release("v0.4.1", false, false, "2025-11-01T00:00:00Z");
3398 latest.assets.clear();
3399 let releases = vec![
3400 release("v0.4.0", false, false, "2025-10-01T00:00:00Z"),
3401 latest,
3402 ];
3403 let eligible: Vec<_> = releases
3404 .into_iter()
3405 .filter(|release| release_matches(release, FrameworkChannel::Stable))
3406 .collect();
3407 let release = newest_release(eligible, FrameworkChannel::Stable).unwrap();
3408 let error = certification_asset(&release, FrameworkChannel::Stable)
3409 .unwrap_err()
3410 .to_string();
3411 assert!(error.contains("v0.4.1"), "{error}");
3412 assert!(error.contains("predates manifest publishing"), "{error}");
3413 assert!(
3414 error.contains(FIRST_STABLE_MANIFEST_RELEASE),
3415 "{error} must name the first manifest-carrying stable release"
3416 );
3417 assert!(
3418 error.contains("--channel dev") && error.contains("--channel nightly"),
3419 "{error} must name the channels that resolve today"
3420 );
3421 }
3422
3423 fn certification(channel: FrameworkChannel, tag: &str) -> Certification {
3424 Certification {
3425 schema_version: 2,
3426 channel,
3427 repository: "water-rs/waterui".to_owned(),
3428 revision: "a".repeat(40),
3429 tag: tag.to_owned(),
3430 lockfiles: BTreeMap::from([("Cargo.lock".to_owned(), "f".repeat(64))]),
3431 submodules: BTreeMap::new(),
3432 scaffold: BTreeMap::new(),
3433 experimental_packages: BTreeMap::new(),
3434 metadata: toml::toml! {
3435 android-min-api-level = 26
3436 },
3437 }
3438 }
3439
3440 #[test]
3441 fn certification_verification_rejects_uncertified_or_mismatched_manifests() {
3442 let repository = framework_repository();
3443 let release = release("v0.4.1", false, false, "2025-11-01T00:00:00Z");
3444
3445 let dev = certification(FrameworkChannel::Dev, "dev");
3446 assert!(
3447 verify_certification(&dev, None, repository)
3448 .unwrap_err()
3449 .to_string()
3450 .contains("dev")
3451 );
3452
3453 let mut wrong_schema = certification(FrameworkChannel::Stable, "v0.4.1");
3454 wrong_schema.schema_version = 1;
3455 assert!(
3456 verify_certification(&wrong_schema, None, repository)
3457 .unwrap_err()
3458 .to_string()
3459 .contains("schema")
3460 );
3461 let nightly_on_a_stable_tag = certification(FrameworkChannel::Nightly, "v0.4.1");
3462 let error = certifies_channel(&nightly_on_a_stable_tag, FrameworkChannel::Stable)
3463 .unwrap_err()
3464 .to_string();
3465 assert!(error.contains("certifies the nightly channel"), "{error}");
3466 certifies_channel(&nightly_on_a_stable_tag, FrameworkChannel::Nightly).unwrap();
3467
3468 let error = parse_certification(
3471 br#"{"schema_version": 1, "channel": "nightly", "repository": "water-rs/waterui"}"#,
3472 )
3473 .err()
3474 .expect("a schema-1 manifest is rejected")
3475 .to_string();
3476 assert!(
3477 error.contains("schema version 1 is not supported"),
3478 "{error}"
3479 );
3480
3481 let mut wrong_repository = certification(FrameworkChannel::Stable, "v0.4.1");
3482 wrong_repository.repository = "water-rs/android-backend".to_owned();
3483 assert!(
3484 verify_certification(&wrong_repository, None, repository)
3485 .unwrap_err()
3486 .to_string()
3487 .contains("water-rs/android-backend")
3488 );
3489
3490 let wrong_tag = certification(FrameworkChannel::Stable, "v0.4.0");
3491 assert!(
3492 verify_certification(&wrong_tag, Some(&release), repository)
3493 .unwrap_err()
3494 .to_string()
3495 .contains("does not match its release")
3496 );
3497
3498 verify_certification(&wrong_tag, None, repository).unwrap();
3500
3501 let stable = certification(FrameworkChannel::Stable, "v0.4.1");
3502 verify_certification(&stable, Some(&release), repository).unwrap();
3503 }
3504
3505 #[test]
3506 fn manifest_loading_verifies_a_certification_from_disk() {
3507 let directory = tempfile::tempdir().unwrap();
3508 let path = directory.path().join("framework.json");
3509 let manifest = serde_json::json!({
3510 "schema_version": 2,
3511 "channel": "stable",
3512 "repository": "water-rs/waterui",
3513 "revision": "a".repeat(40),
3514 "tag": "v0.4.1",
3515 "lockfiles": {"Cargo.lock": "f".repeat(64)},
3516 "submodules": {
3517 "backends/apple": "b".repeat(40),
3518 "backends/android": "c".repeat(40),
3519 },
3520 "scaffold": {
3521 "hydrolysis-version": "0.2.1",
3522 "hydrolysis-m3-version": "0.2.0",
3523 "waterui-dew-version": "0.2.1",
3524 "waterui-gtk-version": "0.1.2",
3525 "apple-backend-url": "https://github.com/water-rs/apple-backend.git",
3526 "android-backend-url": "https://github.com/water-rs/android-backend.git",
3527 },
3528 "metadata": {
3529 "minimum-cli-version": "0.1.0",
3530 "android-min-api-level": 26,
3531 },
3532 });
3533 std::fs::write(&path, serde_json::to_vec(&manifest).unwrap()).unwrap();
3534 let repository = framework_repository();
3535 let certification = smol::block_on(load_manifest(&path, repository)).unwrap();
3536 assert_eq!(certification.channel, FrameworkChannel::Stable);
3537 assert_eq!(certification.tag, "v0.4.1");
3538
3539 std::fs::write(&path, b"not json").unwrap();
3540 assert!(smol::block_on(load_manifest(&path, repository)).is_err());
3541 }
3542
3543 #[test]
3549 fn framework_scaffold_derives_from_the_framework_manifest() {
3550 let root: toml::Value = toml::from_str(include_str!(
3551 "../../tests/fixtures/framework_checkout_manifest.toml"
3552 ))
3553 .unwrap();
3554 let scaffold = framework_scaffold(&root).unwrap();
3555 let workspace = |name: &str| {
3556 let dependency = &root["workspace"]["dependencies"][name];
3557 dependency
3558 .as_str()
3559 .or_else(|| dependency.get("version").and_then(toml::Value::as_str))
3560 .unwrap()
3561 .to_owned()
3562 };
3563 assert_eq!(
3564 scaffold,
3565 BTreeMap::from([
3566 ("hydrolysis-version".to_owned(), workspace("hydrolysis")),
3567 (
3568 "hydrolysis-m3-version".to_owned(),
3569 workspace("hydrolysis-m3")
3570 ),
3571 ("waterui-dew-version".to_owned(), workspace("waterui-dew")),
3572 (
3573 "waterui-dew-git".to_owned(),
3574 "https://github.com/water-rs/dew".to_owned()
3575 ),
3576 (
3577 "waterui-dew-rev".to_owned(),
3578 "b64f6759a3ebe7ac621bad84be00fe431f978119".to_owned()
3579 ),
3580 ("waterui-gtk-version".to_owned(), workspace("waterui-gtk")),
3581 (
3582 "waterui-gtk-git".to_owned(),
3583 "https://github.com/water-rs/gtk-backend".to_owned()
3584 ),
3585 (
3586 "waterui-gtk-rev".to_owned(),
3587 "3162043e618e759bea6d6e52ec75c6ee1273c080".to_owned()
3588 ),
3589 (
3590 "waterui-winui-version".to_owned(),
3591 workspace("waterui-winui")
3592 ),
3593 (
3594 "waterui-winui-git".to_owned(),
3595 "https://github.com/water-rs/waterui-winui".to_owned()
3596 ),
3597 (
3598 "waterui-winui-rev".to_owned(),
3599 "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee".to_owned()
3600 ),
3601 (
3602 "apple-backend-url".to_owned(),
3603 "https://github.com/water-rs/apple-backend.git".to_owned()
3604 ),
3605 ("apple-backend-version".to_owned(), "0.3.0-dev.2".to_owned()),
3606 (
3607 "android-backend-url".to_owned(),
3608 "https://github.com/water-rs/android-backend.git".to_owned()
3609 ),
3610 ("android-backend-revision".to_owned(), "c".repeat(40)),
3611 ])
3612 );
3613 }
3614
3615 #[test]
3616 fn framework_scaffold_rejects_a_git_package_without_a_revision() {
3617 let mut root: toml::Value = toml::from_str(include_str!(
3618 "../../tests/fixtures/framework_checkout_manifest.toml"
3619 ))
3620 .unwrap();
3621 let gtk = &mut root["workspace"]["dependencies"]["waterui-gtk"];
3622 gtk.as_table_mut()
3623 .unwrap()
3624 .insert("branch".to_owned(), toml::Value::String("dev".to_owned()));
3625 gtk.as_table_mut().unwrap().remove("rev");
3626 let error = framework_scaffold(&root).unwrap_err();
3627 assert!(error.to_string().contains("waterui-gtk"), "{error:?}");
3628 }
3629
3630 #[test]
3631 fn framework_scaffold_rejects_a_backend_revision_that_is_not_a_commit() {
3632 let mut root: toml::Value = toml::from_str(include_str!(
3633 "../../tests/fixtures/framework_checkout_manifest.toml"
3634 ))
3635 .unwrap();
3636 root["package"]["metadata"]["waterui"]["android-backend-revision"] =
3637 toml::Value::String("dev".to_owned());
3638 let error = framework_scaffold(&root).unwrap_err();
3639 assert!(
3640 error.to_string().contains("android-backend-revision"),
3641 "{error:?}"
3642 );
3643 }
3644
3645 #[test]
3646 fn declared_apple_revision_resolves_without_a_gitlink() {
3647 let directory = tempfile::tempdir().unwrap();
3648 let root = directory.path().join("waterui");
3649 let revision = "d".repeat(40);
3650 write_apple_revision_checkout(&root, &revision);
3651
3652 let framework = smol::block_on(ResolvedFramework::for_local_checkout(&root)).unwrap();
3653 assert_eq!(framework.apple_backend_revision(), Some(revision.as_str()));
3654 assert!(framework.apple_backend_version().is_none());
3655 assert!(framework.git_source().is_none());
3656 }
3657
3658 #[test]
3659 fn local_checkout_resolves_from_its_own_manifest() {
3660 let directory = tempfile::tempdir().unwrap();
3661 let root = directory.path().join("waterui");
3662 write_local_checkout(&root);
3663 let framework = smol::block_on(ResolvedFramework::for_local_checkout(&root)).unwrap();
3664 assert_eq!(framework.channel(), None);
3665 assert_eq!(framework.scaffold_value("hydrolysis-version"), "0.2.1");
3666 assert_eq!(
3667 framework.scaffold_value("apple-backend-version"),
3668 "0.3.0-dev.2"
3669 );
3670 assert_eq!(
3671 framework.scaffold_value("android-backend-revision"),
3672 "c".repeat(40)
3673 );
3674 assert_eq!(framework.scaffold_value("waterui-version"), "0.4.1");
3675 assert!(framework.git_source().is_none());
3676 }
3677
3678 #[test]
3682 fn local_checkout_predating_the_gitlink_removals_uses_its_pins() {
3683 let directory = tempfile::tempdir().unwrap();
3684 let root = directory.path().join("waterui");
3685 write_pre_decoupling_checkout(&root);
3686
3687 let framework = smol::block_on(ResolvedFramework::for_local_checkout(&root)).unwrap();
3688 assert_eq!(
3689 framework.scaffold_value("apple-backend-revision"),
3690 "b".repeat(40)
3691 );
3692 assert_eq!(
3693 framework.scaffold_value("android-backend-revision"),
3694 "c".repeat(40)
3695 );
3696 }
3697}