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
139impl fmt::Display for ResolvedFramework {
143 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
144 let short = |revision: &str| revision.get(..8).unwrap_or(revision).to_owned();
145 match &self.source {
146 Source::Stable {
147 release: Some(release),
148 } => write!(
149 formatter,
150 "stable {} ({})",
151 release.tag,
152 short(&release.revision)
153 ),
154 Source::Stable { release: None } => formatter.write_str("stable"),
155 Source::Nightly { tag, revision, .. } => {
156 write!(formatter, "nightly {tag} ({})", short(revision))
157 }
158 Source::Dev { revision, .. } => write!(formatter, "dev {}", short(revision)),
159 Source::Local { root } => write!(formatter, "local checkout {}", root.display()),
160 }
161 }
162}
163
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170pub struct ExperimentalPackage {
171 pub git: String,
173 pub rev: String,
175 pub version: String,
177}
178
179#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)]
180struct LockedPackage {
181 name: String,
182 version: String,
183 source: Option<String>,
184}
185
186fn replaces_locked_package(
197 allowed: &BTreeSet<LockedPackage>,
198 name: &str,
199 version: &semver::Version,
200 source: Option<&str>,
201) -> bool {
202 let identity = LockedPackage {
203 name: name.to_owned(),
204 version: version.to_string(),
205 source: source.map(str::to_owned),
206 };
207 if allowed.contains(&identity) {
208 return false;
209 }
210 allowed
211 .iter()
212 .filter(|locked| locked.name == identity.name && locked.source == identity.source)
213 .any(|locked| {
214 semver::VersionReq::parse(&format!("^{}", locked.version))
215 .expect("a lockfile version is a valid caret requirement")
216 .matches(version)
217 })
218}
219
220impl From<&cargo_lock::Package> for LockedPackage {
221 fn from(package: &cargo_lock::Package) -> Self {
222 Self {
223 name: package.name.to_string(),
224 version: package.version.to_string(),
225 source: package.source.as_ref().map(ToString::to_string),
226 }
227 }
228}
229
230#[derive(Debug, Deserialize)]
231struct Release {
232 tag_name: String,
233 draft: bool,
234 prerelease: bool,
235 published_at: Option<String>,
236 assets: Vec<ReleaseAsset>,
237}
238
239#[derive(Debug, Deserialize)]
240struct ReleaseAsset {
241 name: String,
242 browser_download_url: String,
243}
244
245#[derive(Deserialize)]
246struct Certification {
247 schema_version: u32,
248 channel: FrameworkChannel,
249 repository: String,
250 revision: String,
251 tag: String,
252 lockfiles: BTreeMap<String, String>,
253 #[serde(default)]
255 submodules: BTreeMap<String, String>,
256 scaffold: BTreeMap<String, String>,
257 #[serde(default, rename = "experimental-packages")]
262 experimental_packages: BTreeMap<String, ExperimentalPackage>,
263 metadata: toml::Table,
266}
267
268pub(crate) fn rewrite_patch_tables(
273 document: &mut toml_edit::DocumentMut,
274 previous: &PatchSet,
275 patches: &PatchSet,
276) -> Result<()> {
277 for (source, dependencies) in previous {
278 if let Some(table) = document
279 .get_mut("patch")
280 .and_then(|patch| patch.get_mut(source))
281 .and_then(toml_edit::Item::as_table_like_mut)
282 {
283 for name in dependencies.keys() {
284 table.remove(name);
285 }
286 }
287 }
288 let patches = toml_edit::ser::to_document(patches)?;
289 for (source, dependencies) in patches.iter() {
290 let patch = document
294 .entry("patch")
295 .or_insert_with(toml_edit::table)
296 .as_table_mut()
297 .ok_or_else(|| eyre!("[patch] is not a table"))?;
298 patch.set_implicit(true);
299 let table = patch
300 .entry(source)
301 .or_insert_with(toml_edit::table)
302 .as_table_like_mut()
303 .ok_or_else(|| eyre!("[patch.{source}] is not a table"))?;
304 for (name, dependency) in dependencies
305 .as_table_like()
306 .expect("serialized patch dependencies are tables")
307 .iter()
308 {
309 table.insert(name, dependency.clone());
310 }
311 }
312 if let Some(patch) = document
313 .get_mut("patch")
314 .and_then(toml_edit::Item::as_table_mut)
315 {
316 let empty: Vec<String> = patch
317 .iter()
318 .filter(|(_, sources)| {
319 sources
320 .as_table_like()
321 .is_some_and(toml_edit::TableLike::is_empty)
322 })
323 .map(|(source, _)| source.to_owned())
324 .collect();
325 for source in empty {
326 patch.remove(&source);
327 }
328 if patch.is_empty() {
329 document.remove("patch");
330 }
331 }
332 Ok(())
333}
334
335impl ResolvedFramework {
336 #[must_use]
339 pub const fn channel(&self) -> Option<FrameworkChannel> {
340 match self.source {
341 Source::Stable { .. } => Some(FrameworkChannel::Stable),
342 Source::Dev { .. } => Some(FrameworkChannel::Dev),
343 Source::Nightly { .. } => Some(FrameworkChannel::Nightly),
344 Source::Local { .. } => None,
345 }
346 }
347
348 pub(crate) async fn for_manifest(
357 manifest: &crate::project::Manifest,
358 project_root: &Path,
359 ) -> Result<Self> {
360 if let Some(framework) = &manifest.framework {
361 return framework.clone().validated().wrap_err(
362 "the recorded framework selection predates a metadata key this CLI \
363 requires; re-run `water channel` to resolve it again",
364 );
365 }
366 let Some(waterui_path) = &manifest.waterui_path else {
367 bail!(
368 "the project records no framework selection; run `water channel` \
369 to select one or point `waterui_path` at a checkout"
370 );
371 };
372 Self::for_local_checkout(&project_root.join(waterui_path)).await
373 }
374
375 pub(crate) async fn for_local_checkout(root: &Path) -> Result<Self> {
380 let manifest: toml::Value = toml::from_str(
381 &smol::fs::read_to_string(root.join("Cargo.toml"))
382 .await
383 .wrap_err_with(|| {
384 format!(
385 "the WaterUI checkout at {} has no Cargo.toml",
386 root.display()
387 )
388 })?,
389 )?;
390 let metadata = framework_metadata(&manifest)?;
391 let minimum_cli_version = minimum_cli_version(&metadata)?;
392 let rust_version = manifest_rust_version(&manifest)?;
393 if let Some(minimum) = &minimum_cli_version {
394 validate_installed_cli(minimum, &checkout_cli_update())?;
395 }
396 let mut scaffold = framework_scaffold(&manifest)?;
397 let lock: Lockfile = smol::fs::read_to_string(root.join("Cargo.lock"))
398 .await
399 .wrap_err_with(|| {
400 format!(
401 "the WaterUI checkout at {} has no Cargo.lock",
402 root.display()
403 )
404 })?
405 .parse()?;
406 let mut submodules = BTreeMap::new();
407 for path in BACKEND_SUBMODULES {
411 if declares_backend_revision(&scaffold, path) {
412 continue;
413 }
414 submodules.insert(
415 (*path).to_owned(),
416 local_submodule_revision(root, path).await?,
417 );
418 }
419 if !declares_apple_backend_pin(&scaffold) {
423 submodules.insert(
424 "backends/apple".to_owned(),
425 local_submodule_revision(root, "backends/apple").await?,
426 );
427 }
428 complete_scaffold(&mut scaffold, &submodules, &lock)?;
429 Self {
430 source: Source::Local {
431 root: root.to_path_buf(),
432 },
433 minimum_cli_version,
434 rust_version,
435 metadata,
436 scaffold,
437 experimental_packages: BTreeMap::new(),
440 packages: BTreeMap::new(),
441 patches: PatchSet::default(),
442 }
443 .validated()
444 }
445
446 fn validated(mut self) -> Result<Self> {
451 self.android_min_api_level()?;
452 if matches!(self.source, Source::Stable { .. }) {
458 let withheld = split_experimental_packages(&mut self.scaffold);
459 self.experimental_packages.extend(withheld);
460 }
461 Ok(self)
462 }
463
464 pub(crate) async fn resolve_manifest(path: &Path) -> Result<(Self, Option<Vec<u8>>)> {
473 let repository = framework_repository();
474 let slug = repository_slug(repository)?;
475 let certification = load_manifest(path, repository).await?;
476 let revision = certification.revision.clone();
477 Self::construct(repository, slug, &revision, Some(certification)).await
478 }
479
480 pub(crate) fn validate_cli(&self) -> Result<()> {
481 if let Some(minimum) = &self.minimum_cli_version {
482 let update = match &self.source {
483 Source::Stable { .. } => registry_cli_update(minimum),
484 Source::Dev { .. } | Source::Nightly { .. } | Source::Local { .. } => {
485 checkout_cli_update()
486 }
487 };
488 validate_installed_cli(minimum, &update)?;
489 }
490 Ok(())
491 }
492
493 pub(crate) fn scaffold_value(&self, key: &str) -> &str {
494 self.scaffold
495 .get(key)
496 .unwrap_or_else(|| panic!("resolved framework carries no `{key}` scaffold metadata"))
497 }
498
499 pub(crate) fn require_distributable(&self, name: &str) -> Result<()> {
508 let Some(package) = self.experimental_packages.get(name) else {
509 return Ok(());
510 };
511 let channel = self
512 .channel()
513 .map_or_else(|| "local".to_owned(), |channel| channel.to_string());
514 bail!(
515 "`{name}` is an experimental package the {channel} framework channel does not \
516 distribute — it is pinned to {} at {} with no registry release. \
517 Scaffold it on `--channel dev` or `--channel nightly`.",
518 package.git,
519 package.rev,
520 );
521 }
522
523 #[must_use]
528 pub const fn rust_version(&self) -> Option<&cargo_toml::SemVer> {
529 self.rust_version.as_ref()
530 }
531
532 pub(crate) fn apple_backend_version(&self) -> Option<&str> {
536 self.scaffold
537 .get("apple-backend-version")
538 .map(String::as_str)
539 }
540
541 pub(crate) fn apple_backend_revision(&self) -> Option<&str> {
546 self.scaffold
547 .get("apple-backend-revision")
548 .map(String::as_str)
549 }
550
551 pub(crate) fn android_min_api_level(&self) -> Result<u32> {
561 const KEY: &str = "package.metadata.waterui.android-min-api-level";
562 let origin = match &self.source {
563 Source::Stable { release } => release.as_ref().map_or_else(
564 || "the stable framework manifest".to_owned(),
565 |release| format!("the framework manifest certified by {}", release.tag),
566 ),
567 Source::Dev {
568 repository,
569 revision,
570 ..
571 }
572 | Source::Nightly {
573 repository,
574 revision,
575 ..
576 } => format!("the framework manifest at {repository}@{revision}"),
577 Source::Local { root } => format!("{}", root.join("Cargo.toml").display()),
578 };
579 let value = self
580 .metadata
581 .get("android-min-api-level")
582 .ok_or_else(|| eyre!("{origin} does not declare {KEY}"))?;
583 value
584 .as_integer()
585 .and_then(|level| u32::try_from(level).ok())
586 .ok_or_else(|| eyre!("{origin} declares an invalid {KEY}: {value}"))
587 }
588
589 pub(crate) fn patches(&self) -> PatchSet {
590 self.patches.clone()
591 }
592
593 pub(crate) fn update_manifest(
598 &self,
599 document: &mut toml_edit::DocumentMut,
600 previous_patches: &PatchSet,
601 ) -> Result<()> {
602 for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
603 if let Some(dependencies) = document
604 .get_mut(section)
605 .and_then(toml_edit::Item::as_table_like_mut)
606 {
607 self.update_dependencies(dependencies)?;
608 }
609 }
610 if let Some(targets) = document
611 .get_mut("target")
612 .and_then(toml_edit::Item::as_table_like_mut)
613 {
614 for (_, target) in targets.iter_mut() {
615 for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
616 if let Some(dependencies) = target
617 .get_mut(section)
618 .and_then(toml_edit::Item::as_table_like_mut)
619 {
620 self.update_dependencies(dependencies)?;
621 }
622 }
623 }
624 }
625 rewrite_patch_tables(document, previous_patches, &self.patches)
626 }
627
628 fn update_dependencies(&self, dependencies: &mut dyn toml_edit::TableLike) -> Result<()> {
629 for (name, dependency) in dependencies.iter_mut() {
630 let package = dependency
631 .get("package")
632 .and_then(toml_edit::Item::as_str)
633 .unwrap_or(&name)
634 .to_owned();
635 if !self.scaffold.contains_key(&format!("{package}-version")) {
636 continue;
637 }
638 if dependency.is_str() {
639 let decor = dependency
640 .as_value()
641 .expect("string dependency")
642 .decor()
643 .clone();
644 let mut value = toml_edit::Value::InlineTable(toml_edit::InlineTable::new());
645 *value.decor_mut() = decor;
646 *dependency = toml_edit::Item::Value(value);
647 }
648 let table = dependency
649 .as_table_like_mut()
650 .ok_or_else(|| eyre!("invalid dependency {name}"))?;
651 if table.contains_key("workspace") {
652 bail!(
653 "{name} inherits its source; select the framework at its Cargo workspace root"
654 );
655 }
656 for key in [
657 "version",
658 "git",
659 "rev",
660 "branch",
661 "tag",
662 "path",
663 "registry",
664 "registry-index",
665 ] {
666 table.remove(key);
667 }
668 let source = toml_edit::ser::to_document(&self.dependency(&package))?;
669 for key in ["version", "git", "rev"] {
670 if let Some(value) = source.get(key) {
671 table.insert(key, value.clone());
672 }
673 }
674 }
675 Ok(())
676 }
677
678 pub(crate) fn validate_dependencies(
679 &self,
680 metadata: &cargo_metadata::Metadata,
681 contents: &[u8],
682 ) -> Result<()> {
683 let source = match &self.source {
684 Source::Stable { .. } | Source::Local { .. } => return Ok(()),
685 Source::Dev {
686 repository,
687 revision,
688 ..
689 }
690 | Source::Nightly {
691 repository,
692 revision,
693 ..
694 } => format!("git+{repository}?rev={revision}#{revision}"),
695 };
696 let locked = self.cargo_lock(contents)?;
697 let allowed = self.allowed_packages(&locked.packages);
698 let ecosystem: BTreeSet<&str> = locked
703 .packages
704 .iter()
705 .map(|package| package.name.as_str())
706 .chain(self.packages.keys().map(String::as_str))
707 .collect();
708 let packages: BTreeMap<_, _> = metadata
709 .packages
710 .iter()
711 .map(|package| (package.id.clone(), package))
712 .collect();
713 let resolve = metadata
714 .resolve
715 .as_ref()
716 .ok_or_else(|| eyre!("framework verification requires a resolved Cargo graph"))?;
717 let nodes: BTreeMap<_, _> = resolve
718 .nodes
719 .iter()
720 .map(|node| (node.id.clone(), node))
721 .collect();
722 let is_framework_source = |package: &cargo_metadata::Package| {
723 package
724 .source
725 .as_ref()
726 .is_some_and(|candidate| candidate.repr == source)
727 };
728 if !metadata.packages.iter().any(is_framework_source) {
729 bail!("the project does not resolve its selected framework revision");
730 }
731 let mut pending: Vec<_> = metadata
732 .packages
733 .iter()
734 .filter(|package| {
735 is_framework_source(package) || self.packages.contains_key(package.name.as_str())
736 })
737 .map(|package| package.id.clone())
738 .collect();
739 let mut visited = BTreeSet::new();
740 while let Some(id) = pending.pop() {
741 if !visited.insert(id.clone()) {
742 continue;
743 }
744 let package = packages[&id];
745 let identity = LockedPackage {
746 name: package.name.to_string(),
747 version: package.version.to_string(),
748 source: package.source.as_ref().map(|source| source.repr.clone()),
749 };
750 if !allowed.contains(&identity)
751 && ecosystem.contains(identity.name.as_str())
752 && !self.sanctioned_source(&identity)
753 {
754 bail!(
755 "framework dependency {} {} differs from Water.lock; select a compatible channel explicitly",
756 identity.name,
757 identity.version
758 );
759 }
760 pending.extend(nodes[&id].dependencies.iter().cloned());
761 }
762 Ok(())
763 }
764
765 pub(crate) async fn prepare_build(
766 &self,
767 project: &Project,
768 directory: &std::path::Path,
769 features: &[String],
770 ) -> Result<()> {
771 self.validate_cli()?;
772 let project_lock: Lockfile = smol::fs::read_to_string(project.lockfile_path().await?)
773 .await?
774 .parse()?;
775 let lock_path = directory.join("Cargo.lock");
776 let previous: Option<Lockfile> = match smol::fs::read_to_string(&lock_path).await {
777 Ok(contents) => Some(contents.parse()?),
778 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
779 Err(error) => return Err(error.into()),
780 };
781 let allow_new = previous.as_ref().is_none_or(|previous| {
782 let previous: BTreeSet<_> = previous
783 .packages
784 .iter()
785 .map(LockedDependency::from)
786 .collect();
787 project_lock
788 .packages
789 .iter()
790 .filter(|package| package.name.as_str() == "waterui")
791 .any(|package| !previous.contains(&LockedDependency::from(package)))
792 });
793 let canonical = if self.channel() == Some(FrameworkChannel::Stable) {
794 None
795 } else {
796 Some(smol::fs::read(project.root().join("Water.lock")).await?)
797 };
798 let mut packages = BTreeMap::new();
799 if let Some(previous) = &previous {
800 packages.extend(
801 previous
802 .packages
803 .iter()
804 .map(|package| (LockedDependency::from(package), package.clone())),
805 );
806 }
807 if let Some(canonical) = &canonical {
808 packages.extend(
809 self.cargo_lock(canonical)?
810 .packages
811 .into_iter()
812 .map(|package| (LockedDependency::from(&package), package)),
813 );
814 }
815 packages.extend(
816 project_lock
817 .packages
818 .iter()
819 .map(|package| (LockedDependency::from(package), package.clone())),
820 );
821 let allowed = self.allowed_packages(packages.values());
822 let mut seed = project_lock;
823 seed.packages = packages.into_values().collect();
824 smol::fs::write(&lock_path, seed.to_string()).await?;
825 let root = directory.to_path_buf();
826 let features = features.to_vec();
827 let result = async {
828 let metadata = smol::unblock(move || {
829 cargo_metadata::MetadataCommand::new().current_dir(root)
830 .features(cargo_metadata::CargoOpt::SomeFeatures(features)).exec()
831 }).await?;
832 validate_resolved_cli(&metadata)?;
833 if !allow_new {
834 for package in &metadata.packages {
835 if package.source.is_some()
836 && replaces_locked_package(
837 &allowed,
838 &package.name,
839 &package.version,
840 package.source.as_ref().map(|source| source.repr.as_str()),
841 )
842 {
843 bail!("generated build would change locked dependency {}; update the framework channel explicitly", package.name);
844 }
845 }
846 }
847 if let Some(canonical) = canonical {
848 self.validate_dependencies(&metadata, &canonical)?;
849 }
850 Ok(())
851 }.await;
852 if let Err(error) = &result {
853 let restore = if let Some(previous) = previous {
854 smol::fs::write(&lock_path, previous.to_string()).await
855 } else {
856 smol::fs::remove_file(&lock_path).await
857 };
858 restore.wrap_err_with(|| {
859 format!("failed to preserve the previous lock after resolution failed: {error}")
860 })?;
861 }
862 result
863 }
864
865 pub(crate) fn cargo_lock(&self, contents: &[u8]) -> Result<Lockfile> {
866 let (repository, revision, expected) = match &self.source {
867 Source::Stable { .. } => bail!("stable uses the application's Cargo.lock"),
868 Source::Local { .. } => {
869 bail!("a local framework checkout has no canonical lock")
870 }
871 Source::Dev {
872 repository,
873 revision,
874 lock_sha256,
875 }
876 | Source::Nightly {
877 repository,
878 revision,
879 lock_sha256,
880 ..
881 } => (repository, revision, lock_sha256),
882 };
883 if hex::encode(Sha256::digest(contents)) != *expected {
884 bail!("Water.lock does not match the selected framework revision");
885 }
886 let mut lock: Lockfile = std::str::from_utf8(contents)?.parse()?;
887 let source = format!("git+{repository}?rev={revision}#{revision}")
888 .parse::<cargo_lock::SourceId>()?;
889 let mut local = BTreeMap::new();
890 for package in &mut lock.packages {
891 if package.source.is_none() {
892 package.source = Some(source.clone());
893 local.insert(
894 (package.name.clone(), package.version.clone()),
895 LockedDependency::from(&*package),
896 );
897 }
898 }
899 for package in &mut lock.packages {
900 for dependency in &mut package.dependencies {
901 if dependency.source.is_none()
902 && let Some(replacement) =
903 local.get(&(dependency.name.clone(), dependency.version.clone()))
904 {
905 *dependency = replacement.clone();
906 }
907 }
908 }
909 Ok(lock)
910 }
911
912 pub(crate) const fn git_source(&self) -> Option<(&str, &str)> {
919 match &self.source {
920 Source::Stable { .. } | Source::Local { .. } => None,
921 Source::Dev {
922 repository,
923 revision,
924 ..
925 }
926 | Source::Nightly {
927 repository,
928 revision,
929 ..
930 } => Some((repository.as_str(), revision.as_str())),
931 }
932 }
933
934 fn allowed_packages<'p>(
942 &self,
943 packages: impl IntoIterator<Item = &'p cargo_lock::Package>,
944 ) -> BTreeSet<LockedPackage> {
945 let mut allowed = BTreeSet::new();
946 let Some((repository, revision)) = self.git_source() else {
947 return packages.into_iter().map(LockedPackage::from).collect();
948 };
949 let framework_source = format!("git+{repository}?rev={revision}#{revision}");
950 let pinned: BTreeMap<&str, String> = self
952 .patches
953 .values()
954 .flatten()
955 .filter_map(|(name, dependency)| {
956 let Dependency::Detailed(detail) = dependency else {
957 return None;
958 };
959 let (git, rev) = detail.git.as_deref().zip(detail.rev.as_deref())?;
960 Some((name.as_str(), format!("git+{git}?rev={rev}#{rev}")))
961 })
962 .collect();
963 for package in packages {
964 let identity = LockedPackage::from(package);
965 if let Some(source) = &identity.source
966 && let Some(pin) = pinned.get(identity.name.as_str())
967 {
968 if source == &framework_source {
969 allowed.insert(LockedPackage {
970 source: Some(pin.clone()),
971 ..identity.clone()
972 });
973 } else if source == pin {
974 allowed.insert(LockedPackage {
975 source: Some(framework_source.clone()),
976 ..identity.clone()
977 });
978 }
979 }
980 allowed.insert(identity);
981 }
982 allowed
983 }
984
985 fn sanctioned_source(&self, identity: &LockedPackage) -> bool {
991 let Some(detail) = self.packages.get(identity.name.as_str()) else {
992 return false;
993 };
994 let Some(source) = &identity.source else {
995 return false;
996 };
997 if let (Some(git), Some(rev)) = (&detail.git, &detail.rev) {
998 let Ok(source) = source.parse::<cargo_lock::SourceId>() else {
999 return false;
1000 };
1001 let declared = cargo_lock::package::GitReference::Rev(rev.clone());
1002 return source.is_git()
1003 && source.git_reference() == Some(&declared)
1004 && canonical_git_url(source.url().as_str()) == canonical_git_url(git);
1005 }
1006 source.as_str() == "registry+https://github.com/rust-lang/crates.io-index"
1007 && detail.version.as_ref().is_some_and(|requirement| {
1008 identity
1009 .version
1010 .parse::<cargo_toml::SemVer>()
1011 .is_ok_and(|version| requirement.matches(&version))
1012 })
1013 }
1014
1015 pub(crate) fn dependency(&self, name: &str) -> DependencyDetail {
1016 match &self.source {
1017 Source::Stable { .. } => {
1018 let requirement = self.scaffold_value(&format!("{name}-version"));
1019 let git = self.scaffold.get(&format!("{name}-git"));
1022 DependencyDetail {
1023 version: Some(
1024 git.map_or_else(|| format!("={requirement}"), |_| requirement.to_owned())
1025 .parse()
1026 .expect("resolved package version is valid"),
1027 ),
1028 git: git.cloned(),
1029 rev: git.map(|_| self.scaffold_value(&format!("{name}-rev")).to_owned()),
1030 ..Default::default()
1031 }
1032 }
1033 Source::Dev { .. } | Source::Nightly { .. } => self.packages[name].clone(),
1034 Source::Local { .. } => {
1035 unreachable!("a local checkout resolves framework crates by path")
1036 }
1037 }
1038 }
1039
1040 pub(crate) async fn resolve(channel: FrameworkChannel) -> Result<(Self, Option<Vec<u8>>)> {
1052 let repository = framework_repository();
1053 let slug = repository_slug(repository)?;
1054 match channel {
1055 FrameworkChannel::Stable | FrameworkChannel::Nightly => {
1056 let certification = latest_certification(repository, channel).await?;
1057 let revision = certification.revision.clone();
1058 Self::construct(repository, slug, &revision, Some(certification)).await
1059 }
1060 FrameworkChannel::Dev => {
1061 let revision = resolve_dev(repository, slug).await?;
1062 Self::construct(repository, slug, &revision, None).await
1063 }
1064 }
1065 }
1066
1067 async fn construct(
1078 repository: &str,
1079 slug: &str,
1080 revision: &str,
1081 certification: Option<Certification>,
1082 ) -> Result<(Self, Option<Vec<u8>>)> {
1083 validate_revision(revision)?;
1084 let base = format!("https://raw.githubusercontent.com/{slug}/{revision}");
1085 let manifest_bytes = fetch(&format!("{base}/Cargo.toml")).await?;
1086 let root: toml::Value = toml::from_str(std::str::from_utf8(&manifest_bytes)?)?;
1087 let metadata = framework_metadata(&root)?;
1088 let minimum_cli_version = minimum_cli_version(&metadata)?;
1089 let rust_version = manifest_rust_version(&root)?;
1090 let mut scaffold = framework_scaffold(&root)?;
1091 let lock_bytes = fetch(&format!("{base}/Cargo.lock")).await?;
1092 let lock_sha256 = hex::encode(Sha256::digest(&lock_bytes));
1093 let lock: Lockfile = std::str::from_utf8(&lock_bytes)?.parse()?;
1094
1095 let channel = certification
1096 .as_ref()
1097 .map_or(FrameworkChannel::Dev, |certification| certification.channel);
1098 let experimental_packages = if channel == FrameworkChannel::Stable {
1104 split_experimental_packages(&mut scaffold)
1105 } else {
1106 BTreeMap::new()
1107 };
1108 if let Some(minimum) = &minimum_cli_version {
1109 let update = match channel {
1110 FrameworkChannel::Stable => registry_cli_update(minimum),
1111 FrameworkChannel::Dev | FrameworkChannel::Nightly => checkout_cli_update(),
1112 };
1113 validate_installed_cli(minimum, &update)?;
1114 }
1115
1116 let submodule_repositories = match channel {
1121 FrameworkChannel::Stable => BTreeMap::new(),
1122 FrameworkChannel::Dev | FrameworkChannel::Nightly => {
1123 match fetch_optional(&format!("{base}/.gitmodules")).await? {
1124 Some(bytes) => parse_gitmodules(std::str::from_utf8(&bytes)?),
1125 None => BTreeMap::new(),
1127 }
1128 }
1129 };
1130
1131 let (source, submodules) = if let Some(certification) = &certification {
1132 (
1133 certified_source(
1134 certification,
1135 repository,
1136 revision,
1137 &metadata,
1138 &scaffold,
1139 &experimental_packages,
1140 &lock_sha256,
1141 )?,
1142 certification.submodules.clone(),
1143 )
1144 } else {
1145 (
1146 Source::Dev {
1147 repository: repository.to_owned(),
1148 revision: revision.to_owned(),
1149 lock_sha256,
1150 },
1151 dev_submodules(slug, revision, &scaffold, &submodule_repositories).await?,
1152 )
1153 };
1154 complete_scaffold(&mut scaffold, &submodules, &lock)?;
1155 channel_apple_backend_pin(channel, &mut scaffold, certification.as_ref()).await?;
1156
1157 let (packages, patches, lockfile) = match channel {
1158 FrameworkChannel::Stable => (BTreeMap::new(), PatchSet::default(), None),
1162 FrameworkChannel::Dev | FrameworkChannel::Nightly => {
1163 let patches: PatchSet = root
1164 .get("patch")
1165 .cloned()
1166 .map(toml::Value::try_into)
1167 .transpose()?
1168 .unwrap_or_default();
1169 let pins = submodule_pins(submodule_repositories, &submodules);
1173 let patches = rebase_patches_onto_source(patches, repository, revision, &pins);
1174 let packages = resolve_packages(&scaffold, &lock, repository, revision)?;
1175 (packages, patches, Some(lock_bytes))
1176 }
1177 };
1178 Ok((
1179 Self {
1180 source,
1181 minimum_cli_version,
1182 rust_version,
1183 metadata,
1184 scaffold,
1185 experimental_packages,
1186 packages,
1187 patches,
1188 }
1189 .validated()?,
1190 lockfile,
1191 ))
1192 }
1193}
1194
1195async fn channel_apple_backend_pin(
1204 channel: FrameworkChannel,
1205 scaffold: &mut BTreeMap<String, String>,
1206 certification: Option<&Certification>,
1207) -> Result<()> {
1208 match channel {
1209 FrameworkChannel::Dev if scaffold.contains_key("apple-backend-version") => {
1210 let url = scaffold.get("apple-backend-url").ok_or_else(|| {
1211 eyre!("framework manifest declares apple-backend-version without apple-backend-url")
1212 })?;
1213 let revision = backend_dev_revision(url).await?;
1214 scaffold.insert("apple-backend-revision".to_owned(), revision);
1215 }
1216 FrameworkChannel::Nightly => {
1217 if let Some(revision) = certification
1218 .and_then(|certification| certification.scaffold.get("apple-backend-revision"))
1219 {
1220 validate_revision(revision)
1221 .wrap_err("nightly certification scaffold `apple-backend-revision`")?;
1222 scaffold.insert("apple-backend-revision".to_owned(), revision.clone());
1223 }
1224 }
1225 FrameworkChannel::Stable | FrameworkChannel::Dev => {}
1226 }
1227 Ok(())
1228}
1229
1230async fn dev_submodules(
1235 slug: &str,
1236 revision: &str,
1237 scaffold: &BTreeMap<String, String>,
1238 submodule_repositories: &BTreeMap<String, String>,
1239) -> Result<BTreeMap<String, String>> {
1240 let mut submodules = BTreeMap::new();
1241 for path in BACKEND_SUBMODULES {
1242 if declares_backend_revision(scaffold, path) {
1243 continue;
1244 }
1245 submodules.insert(
1246 (*path).to_owned(),
1247 submodule_revision(slug, revision, path).await?,
1248 );
1249 }
1250 if !declares_apple_backend_pin(scaffold) {
1253 submodules.insert(
1254 "backends/apple".to_owned(),
1255 submodule_revision(slug, revision, "backends/apple").await?,
1256 );
1257 }
1258 for path in submodule_repositories.keys() {
1263 if !submodules.contains_key(path)
1264 && let Some(commit) = submodule_pin(slug, revision, path).await?
1265 {
1266 submodules.insert(path.clone(), commit);
1267 }
1268 }
1269 Ok(submodules)
1270}
1271
1272fn submodule_pins(
1275 repositories: BTreeMap<String, String>,
1276 submodules: &BTreeMap<String, String>,
1277) -> BTreeMap<String, SubmodulePin> {
1278 repositories
1279 .into_iter()
1280 .filter_map(|(path, url)| {
1281 submodules.get(&path).map(|commit| {
1282 (
1283 path,
1284 SubmodulePin {
1285 repository: canonical_git_url(&url).to_owned(),
1286 commit: commit.clone(),
1287 },
1288 )
1289 })
1290 })
1291 .collect()
1292}
1293
1294fn certified_source(
1299 certification: &Certification,
1300 repository: &str,
1301 revision: &str,
1302 metadata: &toml::Table,
1303 scaffold: &BTreeMap<String, String>,
1304 experimental_packages: &BTreeMap<String, ExperimentalPackage>,
1305 lock_sha256: &str,
1306) -> Result<Source> {
1307 let channel = certification.channel;
1308 if certification.metadata != *metadata {
1309 bail!("{channel} framework metadata does not match its certification");
1310 }
1311 for (key, value) in scaffold {
1312 if certification.scaffold.get(key) != Some(value) {
1313 bail!("{channel} certification scaffold `{key}` does not match the framework manifest");
1314 }
1315 }
1316 if certification.experimental_packages != *experimental_packages {
1320 bail!("{channel} certification experimental packages do not match the framework manifest");
1321 }
1322 for name in experimental_packages.keys() {
1323 if certification
1324 .scaffold
1325 .contains_key(&format!("{name}-version"))
1326 {
1327 bail!("{channel} certification scaffold `{name}-version` names a withheld package");
1328 }
1329 }
1330 let expected = certification
1331 .lockfiles
1332 .get("Cargo.lock")
1333 .ok_or_else(|| eyre!("{channel} certification has no dependency lock"))?;
1334 if lock_sha256 != *expected {
1335 bail!("{channel} dependency lock does not match its certification");
1336 }
1337 let release = FrameworkRelease {
1338 repository: repository.to_owned(),
1339 revision: revision.to_owned(),
1340 tag: certification.tag.clone(),
1341 };
1342 Ok(match certification.channel {
1343 FrameworkChannel::Stable => Source::Stable {
1344 release: Some(release),
1345 },
1346 FrameworkChannel::Nightly => Source::Nightly {
1347 repository: repository.to_owned(),
1348 revision: revision.to_owned(),
1349 tag: certification.tag.clone(),
1350 lock_sha256: lock_sha256.to_owned(),
1351 },
1352 FrameworkChannel::Dev => unreachable!("verify_certification rejects a dev manifest"),
1353 })
1354}
1355
1356const BACKEND_SUBMODULES: &[&str] = &["backends/android"];
1363
1364fn declares_apple_backend_pin(scaffold: &BTreeMap<String, String>) -> bool {
1365 scaffold.contains_key("apple-backend-version")
1366 || declares_backend_revision(scaffold, "backends/apple")
1367}
1368
1369fn declares_backend_revision(scaffold: &BTreeMap<String, String>, submodule_path: &str) -> bool {
1372 scaffold.contains_key(&format!(
1373 "{}-backend-revision",
1374 backend_name(submodule_path)
1375 ))
1376}
1377
1378const FRAMEWORK_PACKAGES: &[&str] = &[
1382 "waterui",
1383 "waterui-core",
1384 "waterui-testing",
1385 "waterui-ffi",
1386 "waterui-locale",
1387 "waterui-browser-cef",
1388 "waterui-preview",
1389 "waterui-preview-protocol",
1390 "waterui-mcp",
1391];
1392
1393fn backend_name(submodule_path: &str) -> &str {
1394 submodule_path
1395 .rsplit('/')
1396 .next()
1397 .expect("a submodule path has a basename")
1398}
1399
1400fn framework_repository() -> &'static str {
1405 env!("WATERUI_FRAMEWORK_REPOSITORY").trim_end_matches(".git")
1406}
1407
1408fn repository_slug(repository: &str) -> Result<&str> {
1410 repository
1411 .strip_prefix("https://github.com/")
1412 .ok_or_else(|| eyre!("{repository} must identify its GitHub source"))
1413}
1414
1415fn framework_metadata(manifest: &toml::Value) -> Result<toml::Table> {
1419 manifest
1420 .get("package")
1421 .and_then(|package| package.get("metadata"))
1422 .and_then(|metadata| metadata.get("waterui"))
1423 .map_or_else(
1424 || Ok(toml::Table::new()),
1425 |metadata| {
1426 metadata
1427 .clone()
1428 .try_into()
1429 .wrap_err("invalid package.metadata.waterui metadata")
1430 },
1431 )
1432}
1433
1434fn framework_scaffold(manifest: &toml::Value) -> Result<BTreeMap<String, String>> {
1445 let metadata = framework_metadata(manifest)?;
1446 let workspace = manifest
1447 .get("workspace")
1448 .and_then(|workspace| workspace.get("dependencies"))
1449 .and_then(toml::Value::as_table)
1450 .ok_or_else(|| eyre!("framework manifest has no [workspace.dependencies]"))?;
1451 let packages = metadata
1452 .get("scaffold-packages")
1453 .and_then(toml::Value::as_array)
1454 .ok_or_else(|| {
1455 eyre!("framework manifest has no package.metadata.waterui.scaffold-packages")
1456 })?;
1457 let mut scaffold = BTreeMap::new();
1458 for package in packages {
1459 let name = package.as_str().ok_or_else(|| {
1460 eyre!("package.metadata.waterui.scaffold-packages entries must be crate names")
1461 })?;
1462 let dependency = workspace.get(name).ok_or_else(|| {
1463 eyre!("scaffold package {name} has no [workspace.dependencies] requirement")
1464 })?;
1465 let requirement = dependency
1466 .as_str()
1467 .or_else(|| dependency.get("version").and_then(toml::Value::as_str))
1468 .ok_or_else(|| eyre!("workspace.dependencies.{name} declares no version"))?;
1469 scaffold.insert(format!("{name}-version"), requirement.to_owned());
1470 if let Some(git) = dependency.get("git").and_then(toml::Value::as_str) {
1474 let revision = dependency
1475 .get("rev")
1476 .and_then(toml::Value::as_str)
1477 .ok_or_else(|| {
1478 eyre!("workspace.dependencies.{name} must pin an immutable Git revision")
1479 })?;
1480 validate_revision(revision)
1481 .wrap_err_with(|| format!("workspace.dependencies.{name}.rev"))?;
1482 scaffold.insert(format!("{name}-git"), git.to_owned());
1483 scaffold.insert(format!("{name}-rev"), revision.to_owned());
1484 }
1485 }
1486 for (key, value) in &metadata {
1487 if !(key.ends_with("-backend-url")
1488 || key.ends_with("-backend-version")
1489 || key.ends_with("-backend-revision"))
1490 {
1491 continue;
1492 }
1493 let value = value
1494 .as_str()
1495 .ok_or_else(|| eyre!("package.metadata.waterui.{key} must be a string"))?;
1496 if key.ends_with("-backend-revision") {
1497 validate_revision(value).wrap_err_with(|| format!("package.metadata.waterui.{key}"))?;
1498 }
1499 scaffold.insert(key.clone(), value.to_owned());
1500 }
1501 Ok(scaffold)
1502}
1503
1504fn split_experimental_packages(
1510 scaffold: &mut BTreeMap<String, String>,
1511) -> BTreeMap<String, ExperimentalPackage> {
1512 let mut experimental = BTreeMap::new();
1513 for (key, value) in std::mem::take(scaffold) {
1517 if let Some(name) = key.strip_suffix("-git") {
1518 experimental.insert(
1519 name.to_owned(),
1520 ExperimentalPackage {
1521 version: String::new(),
1522 git: value,
1523 rev: String::new(),
1524 },
1525 );
1526 } else {
1527 scaffold.insert(key, value);
1528 }
1529 }
1530 for (name, package) in &mut experimental {
1531 package.rev = scaffold
1532 .remove(&format!("{name}-rev"))
1533 .expect("a `-git` scaffold entry carries `-rev`");
1534 package.version = scaffold
1535 .remove(&format!("{name}-version"))
1536 .expect("a `-git` scaffold entry carries `-version`");
1537 }
1538 experimental
1539}
1540
1541pub(crate) fn manifest_rust_version(manifest: &toml::Value) -> Result<Option<cargo_toml::SemVer>> {
1546 let declared = manifest
1547 .get("workspace")
1548 .and_then(|workspace| workspace.get("package"))
1549 .and_then(|package| package.get("rust-version"))
1550 .or_else(|| {
1551 manifest
1552 .get("package")
1553 .and_then(|package| package.get("rust-version"))
1554 });
1555 declared
1556 .map(|value| {
1557 let text = value
1558 .as_str()
1559 .ok_or_else(|| eyre!("rust-version must be a string"))?;
1560 crate::utils::parse_semver_version(text).wrap_err("invalid rust-version")
1561 })
1562 .transpose()
1563}
1564
1565fn minimum_cli_version(metadata: &toml::Table) -> Result<Option<cargo_toml::SemVer>> {
1569 metadata
1570 .get("minimum-cli-version")
1571 .cloned()
1572 .map(toml::Value::try_into)
1573 .transpose()
1574 .wrap_err("invalid package.metadata.waterui.minimum-cli-version")
1575}
1576
1577fn checkout_cli_update() -> String {
1581 format!(
1582 "cargo install {} --git {} --locked",
1583 env!("CARGO_PKG_NAME"),
1584 env!("CARGO_PKG_REPOSITORY")
1585 )
1586}
1587
1588fn registry_cli_update(minimum: &cargo_toml::SemVer) -> String {
1589 format!(
1590 "cargo install {} --version '>={minimum}' --locked",
1591 env!("CARGO_PKG_NAME")
1592 )
1593}
1594
1595fn validate_installed_cli(minimum: &cargo_toml::SemVer, update: &str) -> Result<()> {
1596 let current = env!("CARGO_PKG_VERSION")
1597 .parse()
1598 .expect("CLI package version is valid");
1599 validate_cli_version(minimum, ¤t, update)
1600}
1601
1602fn validate_cli_version(
1603 minimum: &cargo_toml::SemVer,
1604 current: &cargo_toml::SemVer,
1605 update: &str,
1606) -> Result<()> {
1607 if current.cmp_precedence(minimum).is_lt() {
1608 let update = crate::self_update::cli_update_command(update);
1612 bail!(
1613 "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`."
1614 );
1615 }
1616 Ok(())
1617}
1618
1619pub(crate) async fn validate_local_cli(root: &Path) -> Result<()> {
1620 let contents = smol::fs::read_to_string(root.join("Cargo.toml")).await?;
1621 let manifest = toml::from_str(&contents)?;
1622 if let Some(minimum) = minimum_cli_version(&framework_metadata(&manifest)?)? {
1623 validate_installed_cli(&minimum, &checkout_cli_update())?;
1624 }
1625 Ok(())
1626}
1627
1628pub(crate) fn validate_resolved_cli(metadata: &cargo_metadata::Metadata) -> Result<()> {
1629 for package in metadata
1630 .packages
1631 .iter()
1632 .filter(|package| package.name.as_str() == "waterui")
1633 {
1634 let Some(value) = package
1635 .metadata
1636 .get("waterui")
1637 .and_then(|metadata| metadata.get("minimum-cli-version"))
1638 else {
1639 continue;
1640 };
1641 let minimum: cargo_toml::SemVer = serde_json::from_value(value.clone())
1642 .wrap_err("invalid package.metadata.waterui.minimum-cli-version")?;
1643 let source = package
1644 .source
1645 .as_ref()
1646 .map(|source| source.repr.parse::<cargo_lock::SourceId>())
1647 .transpose()?;
1648 let update = match source {
1649 Some(source) if !source.is_git() => registry_cli_update(&minimum),
1650 Some(_) | None => checkout_cli_update(),
1651 };
1652 validate_installed_cli(&minimum, &update)?;
1653 }
1654 Ok(())
1655}
1656
1657fn resolve_packages(
1658 scaffold: &BTreeMap<String, String>,
1659 lock: &Lockfile,
1660 repository: &str,
1661 revision: &str,
1662) -> Result<BTreeMap<String, DependencyDetail>> {
1663 let mut packages = BTreeMap::new();
1664 for (key, version) in scaffold {
1665 let Some(name) = key.strip_suffix("-version") else {
1666 continue;
1667 };
1668 if name.ends_with("-backend") {
1671 continue;
1672 }
1673 let requirement: cargo_toml::VersionReq = version.parse().wrap_err_with(|| {
1680 eyre!(
1681 "framework scaffold requirement {name} = {version:?} is not a version requirement"
1682 )
1683 })?;
1684 let candidates: Vec<_> = lock
1685 .packages
1686 .iter()
1687 .filter(|package| {
1688 package.name.as_str() == name && requirement.matches(&package.version)
1689 })
1690 .collect();
1691 if let Some(git) = scaffold.get(&format!("{name}-git")) {
1696 let pinned = scaffold.get(&format!("{name}-rev")).ok_or_else(|| {
1697 eyre!("framework scaffold declares {name}-git without {name}-rev")
1698 })?;
1699 match candidates.as_slice() {
1700 [] => {}
1701 [package] => assert_declared_git_source(name, package, git, pinned)?,
1702 _ => bail!("framework lock has multiple sources for {name} {version}"),
1703 }
1704 packages.insert(
1705 name.to_owned(),
1706 DependencyDetail {
1707 version: Some(version.parse()?),
1708 git: Some(git.clone()),
1709 rev: Some(pinned.clone()),
1710 ..Default::default()
1711 },
1712 );
1713 continue;
1714 }
1715 let package = match candidates.as_slice() {
1716 [package] => *package,
1717 [] => {
1723 packages.insert(
1724 name.to_owned(),
1725 DependencyDetail {
1726 version: Some(format!("={version}").parse()?),
1727 ..Default::default()
1728 },
1729 );
1730 continue;
1731 }
1732 _ => bail!("framework lock has multiple sources for {name} {version}"),
1733 };
1734 let mut dependency = DependencyDetail::default();
1735 match &package.source {
1736 None => {
1737 dependency.git = Some(repository.to_owned());
1738 dependency.rev = Some(revision.to_owned());
1739 }
1740 Some(source) if source.is_default_registry() => {
1741 dependency.version = Some(format!("={version}").parse()?);
1742 }
1743 Some(source) if source.is_git() => {
1744 let Some(cargo_lock::package::GitReference::Rev(revision)) = source.git_reference()
1745 else {
1746 bail!(
1747 "framework package {name} must use an immutable Git revision in the framework manifest"
1748 );
1749 };
1750 validate_revision(revision)?;
1751 if source.precise() != Some(revision.as_str()) {
1752 bail!("framework package {name} does not resolve its declared revision");
1753 }
1754 dependency.git = Some(source.url().to_string());
1755 dependency.rev = Some(revision.clone());
1756 }
1757 Some(source) => bail!("unsupported framework package source for {name}: {source}"),
1758 }
1759 packages.insert(name.to_owned(), dependency);
1760 }
1761 Ok(packages)
1762}
1763
1764fn assert_declared_git_source(
1768 name: &str,
1769 package: &cargo_lock::Package,
1770 git: &str,
1771 revision: &str,
1772) -> Result<()> {
1773 let Some(source) = &package.source else {
1774 bail!("framework package {name} is a workspace member, not the declared {git}");
1775 };
1776 let declared = cargo_lock::package::GitReference::Rev(revision.to_owned());
1777 if !(source.is_git()
1778 && source.git_reference() == Some(&declared)
1779 && source.precise() == Some(revision)
1780 && canonical_git_url(source.url().as_str()) == canonical_git_url(git))
1781 {
1782 bail!("framework package {name} resolves {source}, not the declared {git}@{revision}");
1783 }
1784 Ok(())
1785}
1786
1787#[derive(Deserialize)]
1788struct SubmoduleEntry {
1789 sha: String,
1790 #[serde(rename = "type")]
1791 kind: String,
1792}
1793
1794fn complete_scaffold(
1799 scaffold: &mut BTreeMap<String, String>,
1800 submodules: &BTreeMap<String, String>,
1801 lock: &Lockfile,
1802) -> Result<()> {
1803 for &submodule in BACKEND_SUBMODULES {
1807 if declares_backend_revision(scaffold, submodule) {
1808 continue;
1809 }
1810 let commit = submodules
1811 .get(submodule)
1812 .ok_or_else(|| eyre!("framework records no {submodule} submodule pin"))?;
1813 validate_revision(commit)?;
1814 scaffold.insert(
1815 format!("{}-backend-revision", backend_name(submodule)),
1816 commit.clone(),
1817 );
1818 }
1819 if !declares_apple_backend_pin(scaffold) {
1823 let commit = submodules
1824 .get("backends/apple")
1825 .ok_or_else(|| eyre!("framework records no Apple backend pin"))?;
1826 validate_revision(commit)?;
1827 scaffold.insert("apple-backend-revision".to_owned(), commit.clone());
1828 }
1829 for &name in FRAMEWORK_PACKAGES {
1830 let candidates: Vec<_> = lock
1831 .packages
1832 .iter()
1833 .filter(|package| package.name.as_str() == name)
1834 .collect();
1835 let version = match candidates.as_slice() {
1836 [package] => package.version.to_string(),
1837 [] => bail!("framework lock has no package named {name}"),
1838 _ => bail!("framework lock has multiple packages named {name}"),
1839 };
1840 scaffold.insert(format!("{name}-version"), version);
1841 }
1842 Ok(())
1843}
1844
1845async fn local_submodule_revision(root: &Path, path: &str) -> Result<String> {
1849 let treeish = format!("HEAD:{path}");
1850 let output = Command::new("git")
1851 .arg("-C")
1852 .arg(root)
1853 .args(["rev-parse", treeish.as_str()])
1854 .output()
1855 .await?;
1856 if !output.status.success() {
1857 bail!(
1858 "the WaterUI checkout at {} records no {path} submodule pin: {}",
1859 root.display(),
1860 String::from_utf8_lossy(&output.stderr).trim()
1861 );
1862 }
1863 let revision = std::str::from_utf8(&output.stdout)?.trim().to_owned();
1864 validate_revision(&revision)?;
1865 Ok(revision)
1866}
1867
1868async fn submodule_revision(slug: &str, revision: &str, path: &str) -> Result<String> {
1872 let bytes = fetch(&format!(
1873 "https://api.github.com/repos/{slug}/contents/{path}?ref={revision}"
1874 ))
1875 .await?;
1876 let entry: SubmoduleEntry = serde_json::from_slice(&bytes)?;
1877 if entry.kind != "submodule" {
1878 bail!(
1879 "{path} at {slug}@{revision} is a {}, not a submodule",
1880 entry.kind
1881 );
1882 }
1883 Ok(entry.sha)
1884}
1885
1886async fn submodule_pin(slug: &str, revision: &str, path: &str) -> Result<Option<String>> {
1890 let Some(bytes) = fetch_optional(&format!(
1891 "https://api.github.com/repos/{slug}/contents/{path}?ref={revision}"
1892 ))
1893 .await?
1894 else {
1895 return Ok(None);
1896 };
1897 let Ok(entry) = serde_json::from_slice::<SubmoduleEntry>(&bytes) else {
1900 return Ok(None);
1901 };
1902 Ok((entry.kind == "submodule").then_some(entry.sha))
1903}
1904
1905fn parse_gitmodules(contents: &str) -> BTreeMap<String, String> {
1909 let mut submodules = BTreeMap::new();
1910 let mut submodule = false;
1911 let mut path = None::<String>;
1912 let mut url = None::<String>;
1913 for line in contents.lines().map(str::trim) {
1914 if line.starts_with('[') {
1915 if submodule && let (Some(path), Some(url)) = (path.take(), url.take()) {
1916 submodules.insert(path, url);
1917 }
1918 submodule = line.starts_with("[submodule");
1919 continue;
1920 }
1921 if !submodule {
1922 continue;
1923 }
1924 if let Some((key, value)) = line.split_once('=') {
1925 match key.trim() {
1926 "path" => path = Some(value.trim().trim_matches('"').to_owned()),
1927 "url" => url = Some(value.trim().trim_matches('"').to_owned()),
1928 _ => {}
1929 }
1930 }
1931 }
1932 if submodule && let (Some(path), Some(url)) = (path, url) {
1933 submodules.insert(path, url);
1934 }
1935 submodules
1936}
1937
1938fn validate_revision(revision: &str) -> Result<()> {
1939 if revision.len() != 40 || !revision.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1940 bail!("framework revision must be a full Git commit hash");
1941 }
1942 Ok(())
1943}
1944
1945async fn fetch(url: &str) -> Result<Vec<u8>> {
1946 fetch_optional(url)
1947 .await?
1948 .ok_or_else(|| eyre!("framework resolution returned HTTP 404 from {url}"))
1949}
1950
1951async fn fetch_optional(url: &str) -> Result<Option<Vec<u8>>> {
1956 let mut client = zenwave::client();
1957 let mut request = client
1958 .method(Method::GET, url)?
1959 .header("User-Agent", env!("CARGO_PKG_NAME"))?;
1960 if let Some(token) = github_api_token(url) {
1961 request = request.header("Authorization", &format!("Bearer {token}"))?;
1962 }
1963 let response = match request.await {
1964 Ok(response) => response,
1965 Err(zenwave::Error::Http { status, .. }) if status == StatusCode::NOT_FOUND => {
1966 return Ok(None);
1967 }
1968 Err(error) => return Err(error.into()),
1969 };
1970 Ok(Some(response.into_body().into_bytes().await?.to_vec()))
1971}
1972
1973fn github_api_token(url: &str) -> Option<String> {
1980 if !url.starts_with("https://api.github.com/") {
1981 return None;
1982 }
1983 ["WATERUI_GITHUB_TOKEN", "GITHUB_TOKEN"]
1984 .into_iter()
1985 .filter_map(|name| std::env::var(name).ok())
1986 .find(|token| !token.trim().is_empty())
1987}
1988
1989#[cfg(test)]
1990pub(crate) mod test_fixtures {
1991 use std::process::Command as StdCommand;
1992
1993 use super::*;
1994
1995 pub fn stable_framework() -> ResolvedFramework {
2001 let revision = |seed: char| seed.to_string().repeat(40);
2002 let scaffold = FRAMEWORK_PACKAGES
2003 .iter()
2004 .map(|name| (format!("{name}-version"), "0.4.1".to_owned()))
2005 .chain([
2006 ("hydrolysis-version".to_owned(), "0.2.1".to_owned()),
2007 ("hydrolysis-m3-version".to_owned(), "0.2.0".to_owned()),
2008 (
2009 "apple-backend-url".to_owned(),
2010 "https://github.com/water-rs/apple-backend.git".to_owned(),
2011 ),
2012 ("apple-backend-version".to_owned(), "0.3.0-dev.2".to_owned()),
2013 (
2014 "android-backend-url".to_owned(),
2015 "https://github.com/water-rs/android-backend.git".to_owned(),
2016 ),
2017 ("android-backend-revision".to_owned(), revision('c')),
2018 ])
2019 .collect();
2020 ResolvedFramework {
2021 source: Source::Stable {
2022 release: Some(FrameworkRelease {
2023 repository: framework_repository().to_owned(),
2024 revision: revision('a'),
2025 tag: "v0.4.1".to_owned(),
2026 }),
2027 },
2028 minimum_cli_version: None,
2029 rust_version: None,
2030 metadata: toml::toml! {
2031 android-min-api-level = 26
2032 },
2033 scaffold,
2034 experimental_packages: experimental_scaffold_packages(),
2035 packages: BTreeMap::new(),
2036 patches: PatchSet::default(),
2037 }
2038 }
2039
2040 fn experimental_scaffold_packages() -> BTreeMap<String, ExperimentalPackage> {
2046 let experimental = |version: &str, git: &str, seed: char| ExperimentalPackage {
2047 version: version.to_owned(),
2048 git: git.to_owned(),
2049 rev: seed.to_string().repeat(40),
2050 };
2051 BTreeMap::from([
2052 (
2053 "waterui-dew".to_owned(),
2054 experimental("0.2.1", "https://github.com/water-rs/dew", 'b'),
2055 ),
2056 (
2057 "waterui-gtk".to_owned(),
2058 experimental("0.2.0", "https://github.com/water-rs/gtk-backend", 'g'),
2059 ),
2060 (
2061 "waterui-winui".to_owned(),
2062 experimental("0.1.0", "https://github.com/water-rs/waterui-winui", 'e'),
2063 ),
2064 ])
2065 }
2066
2067 pub fn dev_framework() -> ResolvedFramework {
2074 let mut framework = stable_framework();
2075 let revision = 'a'.to_string().repeat(40);
2076 framework.source = Source::Dev {
2077 repository: framework_repository().to_owned(),
2078 revision: revision.clone(),
2079 lock_sha256: 'f'.to_string().repeat(64),
2080 };
2081 framework.scaffold.insert(
2082 "apple-backend-revision".to_owned(),
2083 'd'.to_string().repeat(40),
2084 );
2085 for (name, package) in std::mem::take(&mut framework.experimental_packages) {
2086 framework
2087 .scaffold
2088 .insert(format!("{name}-version"), package.version);
2089 framework
2090 .scaffold
2091 .insert(format!("{name}-git"), package.git);
2092 framework
2093 .scaffold
2094 .insert(format!("{name}-rev"), package.rev);
2095 }
2096 framework.packages = resolve_packages(
2097 &framework.scaffold,
2098 &test_lock(),
2099 framework_repository(),
2100 &revision,
2101 )
2102 .expect("the fixture lock resolves every scaffold requirement");
2103 framework
2104 }
2105
2106 pub fn nightly_framework(backend_revision: bool) -> ResolvedFramework {
2111 let mut framework = dev_framework();
2112 if !backend_revision {
2113 framework.scaffold.remove("apple-backend-revision");
2114 }
2115 framework.source = Source::Nightly {
2116 repository: framework_repository().to_owned(),
2117 revision: 'a'.to_string().repeat(40),
2118 tag: "nightly-2026.09.15".to_owned(),
2119 lock_sha256: 'f'.to_string().repeat(64),
2120 };
2121 framework
2122 }
2123
2124 pub fn write_local_checkout(root: &Path) {
2129 std::fs::create_dir_all(root).expect("checkout dir");
2130 std::fs::write(root.join("Cargo.toml"), local_checkout_manifest()).expect("manifest");
2131 let lock = test_lock();
2132 std::fs::write(root.join("Cargo.lock"), lock.to_string()).expect("lockfile");
2133 let git = |args: &[String]| {
2134 let status = StdCommand::new("git")
2135 .arg("-C")
2136 .arg(root)
2137 .args(args)
2138 .status()
2139 .expect("git must run");
2140 assert!(status.success(), "git {args:?} failed");
2141 };
2142 git(&["init".to_owned(), "-q".to_owned()]);
2143 git(&[
2144 "add".to_owned(),
2145 "Cargo.toml".to_owned(),
2146 "Cargo.lock".to_owned(),
2147 ]);
2148 git(&[
2149 "-c".to_owned(),
2150 "user.name=waterui-test".to_owned(),
2151 "-c".to_owned(),
2152 "user.email=waterui-test@waterui.dev".to_owned(),
2153 "commit".to_owned(),
2154 "-qm".to_owned(),
2155 "init".to_owned(),
2156 ]);
2157 }
2158
2159 pub fn write_apple_revision_checkout(root: &Path, revision: &str) {
2160 write_local_checkout(root);
2161 let manifest_path = root.join("Cargo.toml");
2162 let mut manifest: toml::Value =
2163 toml::from_str(&std::fs::read_to_string(&manifest_path).unwrap()).unwrap();
2164 let metadata = manifest["package"]["metadata"]["waterui"]
2165 .as_table_mut()
2166 .unwrap();
2167 metadata.remove("apple-backend-version");
2168 metadata.insert(
2169 "apple-backend-revision".to_owned(),
2170 toml::Value::String(revision.to_owned()),
2171 );
2172 std::fs::write(manifest_path, toml::to_string(&manifest).unwrap()).unwrap();
2173 }
2174
2175 pub fn write_pre_decoupling_checkout(root: &Path) {
2179 write_local_checkout(root);
2180 write_submodule_pin(root, "backends/apple", 'b');
2181 write_submodule_pin(root, "backends/android", 'c');
2182 let manifest = local_checkout_manifest()
2183 .lines()
2184 .filter(|line| {
2185 let line = line.trim_start();
2186 !(line.starts_with("apple-backend-version")
2187 || line.starts_with("android-backend-revision"))
2188 })
2189 .collect::<Vec<_>>()
2190 .join("\n");
2191 assert!(
2192 !manifest.contains("apple-backend-version")
2193 && !manifest.contains("android-backend-revision"),
2194 "the fixture manifest moved; the pre-decoupling rewrite must be revisited"
2195 );
2196 std::fs::write(root.join("Cargo.toml"), manifest).expect("manifest");
2197 let git = |args: &[&str]| {
2198 let status = StdCommand::new("git")
2199 .arg("-C")
2200 .arg(root)
2201 .args(args)
2202 .status()
2203 .expect("git must run");
2204 assert!(status.success(), "git {args:?} failed");
2205 };
2206 git(&["add", "Cargo.toml"]);
2207 git(&[
2208 "-c",
2209 "user.name=waterui-test",
2210 "-c",
2211 "user.email=waterui-test@waterui.dev",
2212 "commit",
2213 "-qm",
2214 "pre-decoupling manifest",
2215 ]);
2216 }
2217
2218 fn write_submodule_pin(root: &Path, path: &str, seed: char) {
2221 let status = StdCommand::new("git")
2222 .arg("-C")
2223 .arg(root)
2224 .args([
2225 "update-index",
2226 "--add",
2227 "--cacheinfo",
2228 &format!("160000,{},{}", seed.to_string().repeat(40), path),
2229 ])
2230 .status()
2231 .expect("git must run");
2232 assert!(status.success(), "git update-index failed");
2233 }
2234
2235 fn local_checkout_manifest() -> &'static str {
2238 include_str!("../../tests/fixtures/framework_checkout_manifest.toml")
2239 }
2240
2241 pub fn test_lock() -> Lockfile {
2243 Lockfile {
2244 packages: FRAMEWORK_PACKAGES
2245 .iter()
2246 .map(|name| package(name, "0.4.1", None))
2247 .collect(),
2248 version: cargo_lock::ResolveVersion::V4,
2249 root: None,
2250 metadata: BTreeMap::default(),
2251 patch: cargo_lock::Patch::default(),
2252 }
2253 }
2254
2255 pub fn package(name: &str, version: &str, source: Option<&str>) -> cargo_lock::Package {
2256 cargo_lock::Package {
2257 name: name.parse().unwrap(),
2258 version: version.parse().unwrap(),
2259 source: source.map(|source| source.parse().unwrap()),
2260 checksum: None,
2261 dependencies: Vec::new(),
2262 replace: None,
2263 }
2264 }
2265}
2266
2267async fn resolve_dev(repository: &str, slug: &str) -> Result<String> {
2268 gated_dev_head(repository, slug, "dev.yml", "framework").await
2269}
2270
2271async fn backend_dev_revision(url: &str) -> Result<String> {
2277 gated_dev_head(
2278 url,
2279 repository_slug(url.trim_end_matches(".git"))?,
2280 "ci.yml",
2281 "Apple backend",
2282 )
2283 .await
2284}
2285
2286async fn gated_dev_head(repository: &str, slug: &str, gate: &str, what: &str) -> Result<String> {
2290 let output = Command::new("git")
2291 .args(["ls-remote", repository, "refs/heads/dev"])
2292 .output()
2293 .await?;
2294 if !output.status.success() {
2295 bail!(
2296 "could not resolve {what} dev: {}",
2297 String::from_utf8_lossy(&output.stderr)
2298 );
2299 }
2300 let revision = std::str::from_utf8(&output.stdout)?
2301 .split_whitespace()
2302 .next()
2303 .ok_or_else(|| eyre!("{what} repository has no dev branch"))?
2304 .to_owned();
2305 validate_revision(&revision)?;
2306 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?;
2307 let runs: serde_json::Value = serde_json::from_slice(&response)?;
2308 let checked = runs["workflow_runs"].as_array().is_some_and(|runs| {
2309 runs.iter().any(|run| {
2310 run["head_sha"].as_str() == Some(&revision) && run["conclusion"] == "success"
2311 })
2312 });
2313 if !checked {
2314 bail!("{what} dev revision {revision} has not passed its compilation gate");
2315 }
2316 Ok(revision)
2317}
2318
2319async fn latest_certification(
2330 repository: &str,
2331 channel: FrameworkChannel,
2332) -> Result<Certification> {
2333 let slug = repository_slug(repository)?;
2334 let (tag, manifest_url, release) = match channel {
2335 FrameworkChannel::Stable => {
2336 let version = newest_registry_version(&fetch(&sparse_index_url("waterui")).await?)?;
2337 let tag = format!("v{version}");
2338 let manifest_url =
2339 format!("https://github.com/{slug}/releases/download/{tag}/framework.json");
2340 (tag, manifest_url, None)
2341 }
2342 FrameworkChannel::Nightly => {
2343 let release = newest_nightly_release(slug).await?;
2344 let asset = certification_asset(&release)?;
2345 (
2346 release.tag_name.clone(),
2347 asset.browser_download_url.clone(),
2348 Some(release),
2349 )
2350 }
2351 FrameworkChannel::Dev => unreachable!("dev is not a certified channel"),
2352 };
2353 let Some(bytes) = fetch_optional(&manifest_url).await? else {
2354 return Err(match channel {
2355 FrameworkChannel::Stable => StableReleaseWithoutManifest { tag }.into(),
2356 FrameworkChannel::Nightly => eyre!("nightly {tag} has no certification manifest"),
2357 FrameworkChannel::Dev => unreachable!("dev is not a certified channel"),
2358 });
2359 };
2360 let certification = parse_certification(&bytes)?;
2361 if certification.tag != tag {
2362 bail!("{channel} certification does not match its release");
2363 }
2364 verify_certification(&certification, release.as_ref(), repository)?;
2365 certifies_channel(&certification, channel)?;
2366 Ok(certification)
2367}
2368
2369fn sparse_index_url(name: &str) -> String {
2372 let prefix = match name.len() {
2373 1 => "1".to_owned(),
2374 2 => "2".to_owned(),
2375 3 => format!("3/{}", &name[..1]),
2376 _ => format!("{}/{}", &name[..2], &name[2..4]),
2377 };
2378 format!("https://index.crates.io/{prefix}/{name}")
2379}
2380
2381#[derive(Deserialize)]
2383struct IndexVersion {
2384 vers: cargo_toml::SemVer,
2385 yanked: bool,
2386}
2387
2388fn newest_registry_version(index: &[u8]) -> Result<cargo_toml::SemVer> {
2392 std::str::from_utf8(index)?
2393 .lines()
2394 .filter(|line| !line.trim().is_empty())
2395 .map(|line| serde_json::from_str::<IndexVersion>(line).map_err(eyre::Report::from))
2396 .filter_map(|entry| match entry {
2397 Ok(entry) if entry.yanked || !entry.vers.pre.is_empty() => None,
2398 Ok(entry) => Some(Ok(entry.vers)),
2399 Err(error) => Some(Err(error)),
2400 })
2401 .try_fold(None, |newest: Option<cargo_toml::SemVer>, version| {
2402 let version = version?;
2403 Ok::<_, eyre::Report>(Some(match newest {
2404 Some(newest) if newest.cmp_precedence(&version).is_ge() => newest,
2405 _ => version,
2406 }))
2407 })?
2408 .ok_or_else(|| {
2409 eyre!(
2410 "no stable framework release carries a manifest yet; \
2411 select dev or nightly explicitly"
2412 )
2413 })
2414}
2415
2416async fn newest_nightly_release(slug: &str) -> Result<Release> {
2418 let mut releases = Vec::new();
2419 let mut page = 1;
2420 loop {
2421 let bytes = fetch(&format!(
2422 "https://api.github.com/repos/{slug}/releases?per_page=100&page={page}"
2423 ))
2424 .await?;
2425 let batch: Vec<Release> = serde_json::from_slice(&bytes)?;
2426 let complete = batch.len() < 100;
2427 releases.extend(batch.into_iter().filter(is_nightly_release));
2428 if complete {
2429 break;
2430 }
2431 page += 1;
2432 }
2433 releases
2434 .into_iter()
2435 .max_by(|left, right| left.published_at.cmp(&right.published_at))
2436 .ok_or_else(|| eyre!("no certified nightly exists; select dev or stable explicitly"))
2437}
2438
2439fn certifies_channel(certification: &Certification, channel: FrameworkChannel) -> Result<()> {
2442 if certification.channel != channel {
2443 bail!(
2444 "{} certifies the {} channel, not {channel}",
2445 certification.tag,
2446 certification.channel
2447 );
2448 }
2449 Ok(())
2450}
2451
2452#[derive(Deserialize)]
2456struct CertificationSchema {
2457 schema_version: u32,
2458}
2459
2460const CERTIFICATION_SCHEMA_VERSION: u32 = 2;
2461
2462fn parse_certification(bytes: &[u8]) -> Result<Certification> {
2463 let schema: CertificationSchema = serde_json::from_slice(bytes)?;
2464 if schema.schema_version != CERTIFICATION_SCHEMA_VERSION {
2465 bail!(
2466 "framework manifest schema version {} is not supported; this CLI requires schema version {CERTIFICATION_SCHEMA_VERSION}",
2467 schema.schema_version
2468 );
2469 }
2470 Ok(serde_json::from_slice(bytes)?)
2471}
2472
2473fn is_nightly_release(release: &Release) -> bool {
2476 release.prerelease && !release.draft && release.tag_name.starts_with("nightly-")
2477}
2478
2479const FIRST_STABLE_MANIFEST_RELEASE: &str = "0.5.0";
2483
2484#[derive(Debug, thiserror::Error)]
2488#[error(
2489 "stable release {tag} carries no framework.json — it predates manifest publishing. \
2490 The first stable release carrying a manifest is {FIRST_STABLE_MANIFEST_RELEASE}; \
2491 until it is published, use `water create <name> --channel dev` or, once a certified \
2492 nightly exists, `--channel nightly`."
2493)]
2494struct StableReleaseWithoutManifest {
2495 tag: String,
2497}
2498
2499fn certification_asset(release: &Release) -> Result<&ReleaseAsset> {
2501 release
2502 .assets
2503 .iter()
2504 .find(|asset| asset.name == "framework.json")
2505 .ok_or_else(|| eyre!("nightly {} has no certification manifest", release.tag_name))
2506}
2507
2508async fn load_manifest(path: &Path, repository: &str) -> Result<Certification> {
2512 let contents = smol::fs::read(path)
2513 .await
2514 .wrap_err_with(|| format!("failed to read framework manifest {}", path.display()))?;
2515 let certification = parse_certification(&contents)
2516 .wrap_err_with(|| format!("invalid framework manifest {}", path.display()))?;
2517 verify_certification(&certification, None, repository)?;
2518 Ok(certification)
2519}
2520
2521fn verify_certification(
2526 certification: &Certification,
2527 release: Option<&Release>,
2528 repository: &str,
2529) -> Result<()> {
2530 let channel = certification.channel;
2531 if certification.schema_version != CERTIFICATION_SCHEMA_VERSION {
2532 bail!(
2533 "framework manifest schema version {} is not supported; this CLI requires schema version {CERTIFICATION_SCHEMA_VERSION}",
2534 certification.schema_version
2535 );
2536 }
2537 if channel == FrameworkChannel::Dev {
2538 bail!("framework manifest channel `dev` is not a certified distribution");
2539 }
2540 if certification.repository != repository_slug(repository)? {
2541 bail!(
2542 "{channel} manifest names a different repository ({})",
2543 certification.repository
2544 );
2545 }
2546 if let Some(release) = release
2547 && certification.tag != release.tag_name
2548 {
2549 bail!("{channel} certification does not match its release");
2550 }
2551 validate_revision(&certification.revision)?;
2552 if let Some(minimum) = &minimum_cli_version(&certification.metadata)? {
2553 let update = match channel {
2554 FrameworkChannel::Stable => registry_cli_update(minimum),
2555 FrameworkChannel::Dev | FrameworkChannel::Nightly => checkout_cli_update(),
2556 };
2557 validate_installed_cli(minimum, &update)?;
2558 }
2559 Ok(())
2560}
2561
2562struct SubmodulePin {
2566 repository: String,
2568 commit: String,
2570}
2571
2572fn rebase_patches_onto_source(
2588 mut patches: PatchSet,
2589 repository: &str,
2590 revision: &str,
2591 submodules: &BTreeMap<String, SubmodulePin>,
2592) -> PatchSet {
2593 patches.retain(|source, _| !same_git_source(source, repository));
2594 for dependencies in patches.values_mut() {
2595 for dependency in dependencies.values_mut() {
2596 let Dependency::Detailed(detail) = dependency else {
2597 continue;
2598 };
2599 let Some(path) = detail.path.take() else {
2600 continue;
2601 };
2602 let path = path.trim_start_matches("./");
2603 let pin = submodules.iter().find_map(|(root, pin)| {
2604 (path == root.as_str() || path.starts_with(&format!("{root}/"))).then_some(pin)
2605 });
2606 let (git, rev) = pin.map_or((repository, revision), |pin| {
2607 (pin.repository.as_str(), pin.commit.as_str())
2608 });
2609 detail.git = Some(git.to_owned());
2610 detail.rev = Some(rev.to_owned());
2611 }
2612 }
2613 patches
2614}
2615
2616fn canonical_git_url(url: &str) -> &str {
2619 let url = url.split(['?', '#']).next().unwrap_or_default();
2620 url.trim_end_matches('/')
2621 .trim_end_matches(".git")
2622 .trim_end_matches('/')
2623}
2624
2625fn same_git_source(source: &str, repository: &str) -> bool {
2629 canonical_git_url(source) == canonical_git_url(repository)
2630}
2631
2632#[cfg(test)]
2633mod tests {
2634 use test_fixtures::{
2635 dev_framework, nightly_framework, package, stable_framework, test_lock,
2636 write_apple_revision_checkout, write_local_checkout, write_pre_decoupling_checkout,
2637 };
2638
2639 use super::*;
2640
2641 #[test]
2642 fn a_second_major_beside_the_locked_one_is_an_addition_not_a_change() {
2643 let registry = "registry+https://github.com/rust-lang/crates.io-index";
2644 let allowed: BTreeSet<_> = [("annotate-snippets", "0.12.16"), ("toml", "0.9.5")]
2645 .into_iter()
2646 .map(|(name, version)| LockedPackage {
2647 name: name.to_owned(),
2648 version: version.to_owned(),
2649 source: Some(registry.to_owned()),
2650 })
2651 .collect();
2652 let resolved = |name: &str, version: &str| {
2653 replaces_locked_package(
2654 &allowed,
2655 name,
2656 &semver::Version::parse(version).unwrap(),
2657 Some(registry),
2658 )
2659 };
2660 assert!(
2661 !resolved("annotate-snippets", "0.12.16"),
2662 "the locked entry itself"
2663 );
2664 assert!(
2665 !resolved("annotate-snippets", "0.11.5"),
2666 "bindgen's 0.11 line coexists with the locked 0.12"
2667 );
2668 assert!(
2669 !resolved("bincode", "2.0.1"),
2670 "a name the project never locked"
2671 );
2672 assert!(
2673 resolved("toml", "0.9.8"),
2674 "the locked 0.9.5 moved within its caret range"
2675 );
2676 assert!(
2677 resolved("annotate-snippets", "0.12.20"),
2678 "the locked 0.12.16 moved"
2679 );
2680 }
2681
2682 fn snapshot(lock: &Lockfile) -> (ResolvedFramework, Vec<u8>) {
2683 let bytes = lock.to_string().into_bytes();
2684 let scaffold = lock
2685 .packages
2686 .iter()
2687 .map(|package| {
2688 (
2689 format!("{}-version", package.name),
2690 package.version.to_string(),
2691 )
2692 })
2693 .collect();
2694 let repository = framework_repository();
2695 let revision = "a".repeat(40);
2696 let framework = ResolvedFramework {
2697 source: Source::Nightly {
2698 repository: repository.to_owned(),
2699 revision: revision.clone(),
2700 tag: "nightly-test".into(),
2701 lock_sha256: hex::encode(Sha256::digest(&bytes)),
2702 },
2703 minimum_cli_version: None,
2704 rust_version: None,
2705 metadata: toml::toml! {
2706 android-min-api-level = 26
2707 },
2708 packages: resolve_packages(&scaffold, lock, repository, &revision).unwrap(),
2709 scaffold,
2710 experimental_packages: BTreeMap::new(),
2711 patches: PatchSet::default(),
2712 };
2713 (framework, bytes)
2714 }
2715
2716 #[test]
2717 fn cli_requirement_uses_semver_precedence() {
2718 for (minimum, current, compatible) in [
2719 ("0.1.4", "0.1.3", false),
2720 ("0.1.4", "0.1.4", true),
2721 ("0.1.9", "0.1.10", true),
2722 ("0.1.4", "0.1.4-rc.1", false),
2723 ("0.1.4-rc.1", "0.1.4-rc.2", true),
2724 ("0.1.4+z", "0.1.4+a", true),
2725 ("0.1.4", "1.0.0", true),
2726 ] {
2727 let minimum = minimum.parse().unwrap();
2728 let current = current.parse().unwrap();
2729 let update = registry_cli_update(&minimum);
2730 let result = validate_cli_version(&minimum, ¤t, &update);
2731 assert_eq!(result.is_ok(), compatible, "{current} against {minimum}");
2732 if let Err(error) = result {
2733 let message = error.to_string();
2734 assert!(message.contains(&minimum.to_string()));
2735 assert!(message.contains(¤t.to_string()));
2736 assert!(message.contains(&update));
2737 assert!(message.contains("water --version"));
2738 }
2739 }
2740 }
2741
2742 #[test]
2743 fn cli_requirement_metadata_rejects_invalid_versions() {
2744 let mut metadata = toml::toml! {
2745 minimum-cli-version = "0.1.4"
2746 };
2747 assert_eq!(
2748 minimum_cli_version(&metadata).unwrap(),
2749 Some("0.1.4".parse().unwrap())
2750 );
2751 metadata["minimum-cli-version"] = toml::Value::String(">=0.1.4".into());
2752 assert!(minimum_cli_version(&metadata).is_err());
2753 assert!(minimum_cli_version(&toml::Table::new()).unwrap().is_none());
2754 }
2755
2756 #[test]
2757 fn android_min_api_level_is_required_framework_metadata() {
2758 assert_eq!(stable_framework().android_min_api_level().unwrap(), 26);
2759
2760 let mut missing = stable_framework();
2761 missing.metadata.remove("android-min-api-level");
2762 let error = missing.android_min_api_level().unwrap_err().to_string();
2763 assert!(error.contains("android-min-api-level"), "{error}");
2764 assert!(error.contains("v0.4.1"), "{error}");
2765
2766 let mut invalid = stable_framework();
2767 invalid.metadata["android-min-api-level"] = toml::Value::String("26".to_owned());
2768 let error = invalid.android_min_api_level().unwrap_err().to_string();
2769 assert!(error.contains("android-min-api-level"), "{error}");
2770 }
2771
2772 #[test]
2773 fn persisted_cli_requirement_blocks_an_older_cli_with_update_guidance() {
2774 let mut minimum: cargo_toml::SemVer = env!("CARGO_PKG_VERSION").parse().unwrap();
2775 minimum.major += 1;
2776 let mut framework = stable_framework();
2777 framework.minimum_cli_version = Some(minimum.clone());
2778 let contents = toml::to_string(&framework).unwrap();
2779 let framework: ResolvedFramework = toml::from_str(&contents).unwrap();
2780 let error = framework.validate_cli().unwrap_err().to_string();
2781 assert!(error.contains(®istry_cli_update(&minimum)));
2782 assert_eq!(framework.minimum_cli_version, Some(minimum));
2783 }
2784
2785 #[test]
2786 fn snapshot_preserves_independent_package_sources() {
2787 let backend_revision = "b".repeat(40);
2788 let backend_source =
2789 format!("git+https://example.com/hydrolysis?rev={backend_revision}#{backend_revision}");
2790 let lock = Lockfile {
2791 packages: vec![
2792 package("waterui", "0.3.0", None),
2793 package("hydrolysis", "0.1.0", Some(&backend_source)),
2794 package(
2795 "hydrolysis-m3",
2796 "0.1.0",
2797 Some("registry+https://github.com/rust-lang/crates.io-index"),
2798 ),
2799 ],
2800 version: cargo_lock::ResolveVersion::V4,
2801 root: None,
2802 metadata: BTreeMap::default(),
2803 patch: cargo_lock::Patch::default(),
2804 };
2805 let (framework, _) = snapshot(&lock);
2806 let persisted = toml::to_string(&framework).unwrap();
2807 let framework: ResolvedFramework = toml::from_str(&persisted).unwrap();
2808 assert_eq!(framework.channel(), Some(FrameworkChannel::Nightly));
2809 assert_eq!(framework.dependency("waterui").rev, Some("a".repeat(40)));
2810 let backend = framework.dependency("hydrolysis");
2811 assert_eq!(
2812 backend.git.as_deref(),
2813 Some("https://example.com/hydrolysis")
2814 );
2815 assert_eq!(backend.rev, Some(backend_revision));
2816 let theme = framework.dependency("hydrolysis-m3");
2817 assert!(theme.git.is_none());
2818 assert_eq!(theme.version.unwrap().to_string(), "=0.1.0");
2819 }
2820
2821 #[test]
2822 fn extracted_crate_absent_from_the_lock_resolves_to_its_declared_requirement() {
2823 let lock = Lockfile {
2828 packages: vec![package("waterui", "0.3.0", None)],
2829 version: cargo_lock::ResolveVersion::V4,
2830 root: None,
2831 metadata: BTreeMap::default(),
2832 patch: cargo_lock::Patch::default(),
2833 };
2834 let gtk_revision = "b".repeat(40);
2835 let scaffold = BTreeMap::from([
2836 ("waterui-version".to_string(), "0.3.0".to_string()),
2837 ("waterui-dew-version".to_string(), "0.2.1".to_string()),
2838 ("waterui-gtk-version".to_string(), "0.2.0".to_string()),
2839 (
2840 "waterui-gtk-git".to_string(),
2841 "https://github.com/water-rs/gtk-backend".to_string(),
2842 ),
2843 ("waterui-gtk-rev".to_string(), gtk_revision.clone()),
2844 ]);
2845 let packages =
2846 resolve_packages(&scaffold, &lock, framework_repository(), &"a".repeat(40)).unwrap();
2847 assert!(packages["waterui"].git.is_some());
2848 let dew = &packages["waterui-dew"];
2849 assert!(dew.git.is_none());
2850 assert_eq!(dew.version.as_ref().unwrap().to_string(), "=0.2.1");
2851 let gtk = &packages["waterui-gtk"];
2852 assert_eq!(
2853 gtk.git.as_deref(),
2854 Some("https://github.com/water-rs/gtk-backend")
2855 );
2856 assert_eq!(gtk.rev.as_deref(), Some(gtk_revision.as_str()));
2857 assert_eq!(gtk.version.as_ref().unwrap().to_string(), "^0.2.0");
2858 }
2859
2860 #[test]
2861 fn declared_git_source_must_agree_with_the_lock() {
2862 let locked_revision = "b".repeat(40);
2865 let drifted_revision = "c".repeat(40);
2866 for (lock_revision, expected) in [
2867 (locked_revision.as_str(), true),
2868 (drifted_revision.as_str(), false),
2869 ] {
2870 let source = format!(
2871 "git+https://github.com/water-rs/gtk-backend?rev={lock_revision}#{lock_revision}"
2872 );
2873 let lock = Lockfile {
2874 packages: vec![package("waterui-gtk", "0.2.0", Some(&source))],
2875 version: cargo_lock::ResolveVersion::V4,
2876 root: None,
2877 metadata: BTreeMap::default(),
2878 patch: cargo_lock::Patch::default(),
2879 };
2880 let scaffold = BTreeMap::from([
2881 ("waterui-gtk-version".to_string(), "0.2.0".to_string()),
2882 (
2883 "waterui-gtk-git".to_string(),
2884 "https://github.com/water-rs/gtk-backend".to_string(),
2885 ),
2886 ("waterui-gtk-rev".to_string(), locked_revision.clone()),
2887 ]);
2888 let result =
2889 resolve_packages(&scaffold, &lock, framework_repository(), &"a".repeat(40));
2890 assert_eq!(result.is_ok(), expected, "lock revision {lock_revision}");
2891 if expected {
2892 assert_eq!(
2893 result.unwrap()["waterui-gtk"].rev.as_deref(),
2894 Some(locked_revision.as_str())
2895 );
2896 }
2897 }
2898 }
2899
2900 #[test]
2901 fn resolve_packages_matches_the_requirement_against_the_lock() {
2902 let m3_revision = "14a35e3ef69ced36557a3c7ab11d52e0afb53ad5";
2908 let m3_source = format!(
2909 "git+https://github.com/water-rs/hydrolysis-m3?rev={m3_revision}#{m3_revision}"
2910 );
2911 let lock = Lockfile {
2912 packages: vec![
2913 package("waterui", "0.3.0", None),
2914 package("hydrolysis-m3", "0.2.1", Some(&m3_source)),
2915 ],
2916 version: cargo_lock::ResolveVersion::V4,
2917 root: None,
2918 metadata: BTreeMap::default(),
2919 patch: cargo_lock::Patch::default(),
2920 };
2921 let scaffold = BTreeMap::from([
2922 ("waterui-version".to_string(), "0.3.0".to_string()),
2923 ("hydrolysis-m3-version".to_string(), "0.2.0".to_string()),
2924 ]);
2925 let packages =
2926 resolve_packages(&scaffold, &lock, framework_repository(), &"a".repeat(40)).unwrap();
2927 let m3 = &packages["hydrolysis-m3"];
2928 assert_eq!(
2929 m3.git.as_deref(),
2930 Some("https://github.com/water-rs/hydrolysis-m3")
2931 );
2932 assert_eq!(m3.rev.as_deref(), Some(m3_revision));
2933 }
2934
2935 #[test]
2936 fn stable_dependency_honors_a_declared_git_source() {
2937 let mut framework = stable_framework();
2941 let revision = "b".repeat(40);
2942 framework
2943 .scaffold
2944 .insert("waterui-gtk-version".to_owned(), "0.1.2".to_owned());
2945 framework.scaffold.insert(
2946 "waterui-gtk-git".to_owned(),
2947 "https://github.com/water-rs/gtk-backend".to_owned(),
2948 );
2949 framework
2950 .scaffold
2951 .insert("waterui-gtk-rev".to_owned(), revision.clone());
2952 framework
2953 .scaffold
2954 .insert("waterui-dew-version".to_owned(), "0.2.1".to_owned());
2955 let gtk = framework.dependency("waterui-gtk");
2956 assert_eq!(
2957 gtk.git.as_deref(),
2958 Some("https://github.com/water-rs/gtk-backend")
2959 );
2960 assert_eq!(gtk.rev.as_deref(), Some(revision.as_str()));
2961 assert_eq!(gtk.version.as_ref().unwrap().to_string(), "^0.1.2");
2962 let dew = framework.dependency("waterui-dew");
2965 assert!(dew.git.is_none());
2966 assert_eq!(dew.version.as_ref().unwrap().to_string(), "=0.2.1");
2967 }
2968
2969 #[test]
2970 fn stable_withholds_the_git_pinned_scaffold_packages() {
2971 let framework = stable_framework();
2976 for name in ["waterui-dew", "waterui-gtk", "waterui-winui"] {
2977 let package = &framework.experimental_packages[name];
2978 assert_eq!(package.rev.len(), 40);
2979 assert!(!framework.scaffold.contains_key(&format!("{name}-version")));
2980 assert!(!framework.scaffold.contains_key(&format!("{name}-git")));
2981 let error = framework
2982 .require_distributable(name)
2983 .unwrap_err()
2984 .to_string();
2985 assert!(error.contains(name), "{error}");
2986 assert!(error.contains("stable"), "{error}");
2987 assert!(error.contains(&package.git), "{error}");
2988 assert!(error.contains("--channel dev"), "{error}");
2989 assert!(error.contains("--channel nightly"), "{error}");
2990 }
2991 for name in ["waterui", "hydrolysis", "hydrolysis-m3"] {
2993 framework
2994 .require_distributable(name)
2995 .unwrap_or_else(|error| panic!("{name} must scaffold on stable: {error}"));
2996 }
2997 }
2998
2999 #[test]
3000 fn dev_and_nightly_distribute_the_experimental_packages() {
3001 for framework in [dev_framework(), nightly_framework(true)] {
3002 for name in ["waterui-dew", "waterui-gtk", "waterui-winui"] {
3003 framework
3004 .require_distributable(name)
3005 .unwrap_or_else(|error| panic!("{name} must scaffold off stable: {error}"));
3006 let dependency = framework.dependency(name);
3007 assert!(
3008 dependency.git.is_some(),
3009 "{name} must keep its declared git pin"
3010 );
3011 assert_eq!(dependency.rev.as_deref().map(str::len), Some(40));
3012 }
3013 }
3014 }
3015
3016 #[test]
3017 fn a_legacy_stable_selection_re_derives_the_withheld_set() {
3018 let mut framework = stable_framework();
3022 for (name, package) in framework.experimental_packages.clone() {
3023 framework
3024 .scaffold
3025 .insert(format!("{name}-version"), package.version);
3026 framework
3027 .scaffold
3028 .insert(format!("{name}-git"), package.git);
3029 framework
3030 .scaffold
3031 .insert(format!("{name}-rev"), package.rev);
3032 }
3033 framework.experimental_packages.clear();
3034
3035 let framework = framework.validated().expect("fixture validates");
3036 assert_eq!(framework.experimental_packages.len(), 3);
3037 assert!(
3038 framework.require_distributable("waterui-winui").is_err(),
3039 "a stale `waterui-winui-git` entry must not resurrect the package"
3040 );
3041 }
3042
3043 #[test]
3044 fn a_stable_certification_must_agree_on_the_withheld_set() {
3045 let repository = framework_repository();
3046 let revision = "a".repeat(40);
3047 let lock_sha256 = "f".repeat(64);
3048 let metadata = toml::toml! { android-min-api-level = 26 };
3049 let mut scaffold = BTreeMap::from([
3050 ("waterui-version".to_owned(), "0.4.1".to_owned()),
3051 ("waterui-winui-version".to_owned(), "0.1.0".to_owned()),
3052 (
3053 "waterui-winui-git".to_owned(),
3054 "https://github.com/water-rs/waterui-winui".to_owned(),
3055 ),
3056 ("waterui-winui-rev".to_owned(), "e".repeat(40)),
3057 ]);
3058 let experimental_packages = split_experimental_packages(&mut scaffold);
3059
3060 let stable_certification = |experimental: BTreeMap<_, _>, scaffold| Certification {
3061 schema_version: 2,
3062 channel: FrameworkChannel::Stable,
3063 repository: "water-rs/waterui".to_owned(),
3064 revision: revision.clone(),
3065 tag: "v0.4.1".to_owned(),
3066 lockfiles: BTreeMap::from([("Cargo.lock".to_owned(), lock_sha256.clone())]),
3067 submodules: BTreeMap::new(),
3068 scaffold,
3069 experimental_packages: experimental,
3070 metadata: metadata.clone(),
3071 };
3072 let source = certified_source(
3073 &stable_certification(experimental_packages.clone(), scaffold.clone()),
3074 repository,
3075 &revision,
3076 &metadata,
3077 &scaffold,
3078 &experimental_packages,
3079 &lock_sha256,
3080 )
3081 .expect("a manifest carrying the withheld set verifies");
3082 assert!(matches!(source, Source::Stable { .. }));
3083
3084 let error = certified_source(
3087 &stable_certification(BTreeMap::new(), scaffold.clone()),
3088 repository,
3089 &revision,
3090 &metadata,
3091 &scaffold,
3092 &experimental_packages,
3093 &lock_sha256,
3094 )
3095 .unwrap_err()
3096 .to_string();
3097 assert!(error.contains("experimental packages"), "{error}");
3098
3099 let mut doubled = scaffold.clone();
3101 doubled.insert("waterui-winui-version".to_owned(), "0.1.0".to_owned());
3102 let error = certified_source(
3103 &stable_certification(experimental_packages.clone(), doubled),
3104 repository,
3105 &revision,
3106 &metadata,
3107 &scaffold,
3108 &experimental_packages,
3109 &lock_sha256,
3110 )
3111 .unwrap_err()
3112 .to_string();
3113 assert!(error.contains("waterui-winui-version"), "{error}");
3114 }
3115
3116 #[test]
3117 fn extracted_crate_validation_accepts_only_the_sanctioned_source() {
3118 let gtk_revision = "b".repeat(40);
3122 let mut framework = stable_framework();
3123 framework.packages.insert(
3124 "waterui-gtk".to_owned(),
3125 DependencyDetail {
3126 version: Some("0.2.0".parse().unwrap()),
3127 git: Some("https://github.com/water-rs/gtk-backend".to_owned()),
3128 rev: Some(gtk_revision.clone()),
3129 ..Default::default()
3130 },
3131 );
3132 framework.packages.insert(
3133 "waterui-dew".to_owned(),
3134 DependencyDetail {
3135 version: Some("=0.2.1".parse().unwrap()),
3136 ..Default::default()
3137 },
3138 );
3139 let identity = |name: &str, version: &str, source: String| LockedPackage {
3140 name: name.to_owned(),
3141 version: version.to_owned(),
3142 source: Some(source),
3143 };
3144 let gtk_source = |revision: &str| {
3145 format!("git+https://github.com/water-rs/gtk-backend?rev={revision}#{revision}")
3146 };
3147 assert!(framework.sanctioned_source(&identity(
3148 "waterui-gtk",
3149 "0.2.0",
3150 gtk_source(>k_revision)
3151 )));
3152 assert!(framework.sanctioned_source(&identity(
3154 "waterui-gtk",
3155 "0.2.1",
3156 gtk_source(>k_revision)
3157 )));
3158 let drifted = "c".repeat(40);
3160 assert!(!framework.sanctioned_source(&identity(
3161 "waterui-gtk",
3162 "0.2.0",
3163 gtk_source(&drifted)
3164 )));
3165 let registry = || "registry+https://github.com/rust-lang/crates.io-index".to_owned();
3167 assert!(framework.sanctioned_source(&identity("waterui-dew", "0.2.1", registry())));
3168 assert!(!framework.sanctioned_source(&identity("waterui-dew", "0.2.2", registry())));
3169 }
3170
3171 fn scaffolded(field: &str) -> String {
3177 let version = &stable_framework().scaffold[field];
3178 format!("={version}")
3179 }
3180
3181 #[test]
3182 fn channel_update_preserves_aliases_features_and_unrelated_dependencies() {
3183 let manifest = toml::toml! {
3184 [dependencies.ui]
3185 package = "waterui"
3186 path = "../waterui"
3187 default-features = false
3188 features = ["gpu"]
3189 [dependencies.serde]
3190 version = "1"
3191 features = ["derive"]
3192 [target."cfg(unix)".build-dependencies]
3193 waterui-core = "0.2"
3194 };
3195 let mut document = toml_edit::ser::to_document(&manifest).unwrap();
3196 stable_framework()
3197 .update_manifest(&mut document, &PatchSet::default())
3198 .unwrap();
3199 assert_eq!(
3200 document["dependencies"]["ui"]["package"].as_str(),
3201 Some("waterui")
3202 );
3203 assert!(document["dependencies"]["ui"].get("path").is_none());
3204 assert_eq!(
3205 document["dependencies"]["ui"]["version"].as_str(),
3206 Some(scaffolded("waterui-version").as_str())
3207 );
3208 assert_eq!(
3209 document["dependencies"]["ui"]["default-features"].as_bool(),
3210 Some(false)
3211 );
3212 assert_eq!(
3213 document["dependencies"]["ui"]["features"][0].as_str(),
3214 Some("gpu")
3215 );
3216 let updated: toml::Value = toml::from_str(&document.to_string()).unwrap();
3217 assert_eq!(
3218 updated["dependencies"]["serde"],
3219 manifest["dependencies"]["serde"]
3220 );
3221 assert_eq!(
3222 updated["target"]["cfg(unix)"]["build-dependencies"]["waterui-core"]["version"]
3223 .as_str(),
3224 Some(scaffolded("waterui-core-version").as_str())
3225 );
3226 }
3227
3228 #[test]
3229 fn channel_update_writes_patches_as_tables_and_clears_stale_ones() {
3230 let mut document: toml_edit::DocumentMut =
3231 "[package]\nname = \"app\"\n\n[dependencies]\nwaterui = \"0.3.0\"\n"
3232 .parse()
3233 .unwrap();
3234 let (dev, _) = snapshot(&Lockfile {
3235 packages: vec![package("waterui", "0.3.0", None)],
3236 version: cargo_lock::ResolveVersion::V4,
3237 root: None,
3238 metadata: BTreeMap::default(),
3239 patch: cargo_lock::Patch::default(),
3240 });
3241 let mut dev = dev;
3242 let vello: Dependency = toml::from_str::<toml::Value>(
3243 r#"git = "https://github.com/lexoliu/vello"
3244rev = "d68d9e9825bcd1ffee762323881c13a2e7a3f639""#,
3245 )
3246 .unwrap()
3247 .try_into()
3248 .unwrap();
3249 dev.patches
3250 .entry("crates-io".into())
3251 .or_default()
3252 .insert("vello".into(), vello);
3253 dev.update_manifest(&mut document, &PatchSet::default())
3254 .unwrap();
3255 let rendered = document.to_string();
3256 assert!(rendered.starts_with("[package]"), "{rendered}");
3257 assert!(rendered.contains("[patch.crates-io]\n"), "{rendered}");
3258 assert!(!rendered.contains("\n[patch]\n"), "{rendered}");
3259 assert_eq!(
3260 document["patch"]["crates-io"]["vello"]["rev"].as_str(),
3261 Some("d68d9e9825bcd1ffee762323881c13a2e7a3f639")
3262 );
3263 assert_eq!(
3264 document["dependencies"]["waterui"]["rev"].as_str(),
3265 Some("a".repeat(40).as_str())
3266 );
3267
3268 stable_framework()
3269 .update_manifest(&mut document, &dev.patches())
3270 .unwrap();
3271 let rendered = document.to_string();
3272 assert!(!rendered.contains("patch"), "{rendered}");
3273 assert_eq!(
3274 document["dependencies"]["waterui"]["version"].as_str(),
3275 Some(scaffolded("waterui-version").as_str())
3276 );
3277 }
3278
3279 #[test]
3280 fn snapshot_lock_preserves_external_sources_and_rewrites_local_edges() {
3281 let core = package("waterui-core", "0.3.0", None);
3282 let mut facade = package("waterui", "0.3.0", None);
3283 facade.dependencies.push(LockedDependency::from(&core));
3284 let theme = package(
3285 "hydrolysis-m3",
3286 "0.1.0",
3287 Some("registry+https://github.com/rust-lang/crates.io-index"),
3288 );
3289 let lock = Lockfile {
3290 packages: vec![facade, core, theme.clone()],
3291 version: cargo_lock::ResolveVersion::V4,
3292 root: None,
3293 metadata: BTreeMap::default(),
3294 patch: cargo_lock::Patch::default(),
3295 };
3296 let (framework, bytes) = snapshot(&lock);
3297 let resolved = framework.cargo_lock(&bytes).unwrap();
3298 let facade = resolved
3299 .packages
3300 .iter()
3301 .find(|package| package.name.as_str() == "waterui")
3302 .unwrap();
3303 let core = resolved
3304 .packages
3305 .iter()
3306 .find(|package| package.name.as_str() == "waterui-core")
3307 .unwrap();
3308 assert_eq!(facade.dependencies, vec![LockedDependency::from(core)]);
3309 assert!(core.source.as_ref().unwrap().is_git());
3310 assert!(resolved.packages.contains(&theme));
3311 let mut changed = bytes;
3312 changed.push(b'\n');
3313 assert!(
3314 framework
3315 .cargo_lock(&changed)
3316 .unwrap_err()
3317 .to_string()
3318 .contains("does not match")
3319 );
3320 }
3321
3322 #[test]
3323 fn rebase_patches_onto_source_redirects_git_source_dependencies() {
3324 let mut patches = PatchSet::default();
3325 let mut crates_io = std::collections::BTreeMap::new();
3326 crates_io.insert(
3327 "waterui-core".to_string(),
3328 Dependency::Detailed(Box::new(DependencyDetail {
3329 path: Some("core".to_string()),
3330 ..DependencyDetail::default()
3331 })),
3332 );
3333 crates_io.insert(
3334 "waterkit-audio".to_string(),
3335 Dependency::Detailed(Box::new(DependencyDetail {
3336 path: Some("kit/multimedia/audio".to_string()),
3337 ..DependencyDetail::default()
3338 })),
3339 );
3340 crates_io.insert(
3341 "vello".to_string(),
3342 Dependency::Detailed(Box::new(DependencyDetail {
3343 git: Some("https://github.com/lexoliu/vello".to_string()),
3344 rev: Some("5e5f538556be16527f67379b105af82f408b747d".to_string()),
3345 ..DependencyDetail::default()
3346 })),
3347 );
3348 patches.insert("crates-io".to_string(), crates_io);
3349 patches.insert(
3353 "https://github.com/water-rs/waterui".to_string(),
3354 std::collections::BTreeMap::new(),
3355 );
3356 patches.insert(
3357 "https://github.com/water-rs/waterui.git?branch=dev".to_string(),
3358 std::collections::BTreeMap::new(),
3359 );
3360
3361 let submodules = BTreeMap::from([(
3362 "kit".to_string(),
3363 SubmodulePin {
3364 repository: "https://github.com/water-rs/waterkit".to_string(),
3365 commit: "98c89ee702c5629094030023fb8d55464592d35d".to_string(),
3366 },
3367 )]);
3368 let rebased = rebase_patches_onto_source(
3369 patches,
3370 "https://github.com/water-rs/waterui",
3371 "475b4bb884a5f4e2b1156f1af74c40feaf71fdc1",
3372 &submodules,
3373 );
3374
3375 let Dependency::Detailed(core) = &rebased["crates-io"]["waterui-core"] else {
3377 panic!("a path patch stays a detailed dependency");
3378 };
3379 assert!(core.path.is_none());
3380 assert_eq!(
3381 core.git.as_deref(),
3382 Some("https://github.com/water-rs/waterui")
3383 );
3384 assert_eq!(
3385 core.rev.as_deref(),
3386 Some("475b4bb884a5f4e2b1156f1af74c40feaf71fdc1")
3387 );
3388
3389 let Dependency::Detailed(audio) = &rebased["crates-io"]["waterkit-audio"] else {
3392 panic!("a submodule path patch stays a detailed dependency");
3393 };
3394 assert_eq!(
3395 audio.git.as_deref(),
3396 Some("https://github.com/water-rs/waterkit")
3397 );
3398 assert_eq!(
3399 audio.rev.as_deref(),
3400 Some("98c89ee702c5629094030023fb8d55464592d35d")
3401 );
3402
3403 let Dependency::Detailed(vello) = &rebased["crates-io"]["vello"] else {
3407 panic!("a git patch stays a detailed dependency");
3408 };
3409 assert_eq!(
3410 vello.git.as_deref(),
3411 Some("https://github.com/lexoliu/vello")
3412 );
3413 assert!(!rebased.contains_key("https://github.com/water-rs/waterui"));
3414 assert!(!rebased.contains_key("https://github.com/water-rs/waterui.git?branch=dev"));
3415 }
3416
3417 #[test]
3418 fn coherence_admits_a_submodule_crate_at_either_source() {
3419 let revision = "a".repeat(40);
3420 let pin = "98c89ee702c5629094030023fb8d55464592d35d";
3421 let framework_source = format!("git+{}?rev={revision}#{revision}", framework_repository());
3422 let pin_source = format!("git+https://github.com/water-rs/waterkit?rev={pin}#{pin}");
3423 let (mut framework, _) = snapshot(&test_lock());
3424 framework
3425 .patches
3426 .entry("crates-io".into())
3427 .or_default()
3428 .insert(
3429 "waterkit-codec".into(),
3430 Dependency::Detailed(Box::new(DependencyDetail {
3431 git: Some("https://github.com/water-rs/waterkit".into()),
3432 rev: Some(pin.into()),
3433 ..DependencyDetail::default()
3434 })),
3435 );
3436 framework
3437 .patches
3438 .entry("crates-io".into())
3439 .or_default()
3440 .insert(
3441 "waterkit-fs".into(),
3442 Dependency::Detailed(Box::new(DependencyDetail {
3443 git: Some("https://github.com/water-rs/waterkit".into()),
3444 rev: Some(pin.into()),
3445 ..DependencyDetail::default()
3446 })),
3447 );
3448 let packages = vec![
3449 package("waterkit-codec", "0.1.1", Some(&framework_source)),
3452 package("waterkit-fs", "0.1.1", Some(&pin_source)),
3454 package(
3455 "serde",
3456 "1.0.0",
3457 Some("registry+https://github.com/rust-lang/crates.io-index"),
3458 ),
3459 ];
3460 let allowed = framework.allowed_packages(&packages);
3461 let identity = |name: &str, source: &str| LockedPackage {
3462 name: name.to_owned(),
3463 version: "0.1.1".to_owned(),
3464 source: Some(source.to_owned()),
3465 };
3466 assert!(allowed.contains(&identity("waterkit-codec", &framework_source)));
3467 assert!(allowed.contains(&identity("waterkit-codec", &pin_source)));
3468 assert!(allowed.contains(&identity("waterkit-fs", &pin_source)));
3469 assert!(allowed.contains(&identity("waterkit-fs", &framework_source)));
3470 assert_eq!(
3472 allowed
3473 .iter()
3474 .filter(|package| package.name == "serde")
3475 .count(),
3476 1
3477 );
3478 }
3479
3480 #[test]
3481 fn parse_gitmodules_reads_submodule_paths_and_urls() {
3482 let submodules = parse_gitmodules(
3483 "[submodule \"backends/android\"]\n\
3484 \tpath = backends/android\n\
3485 \turl = https://github.com/water-rs/android-backend.git\n\
3486 \tbranch = dev\n\
3487 [submodule \"kit\"]\n\
3488 \tpath = kit\n\
3489 \turl = \"https://github.com/water-rs/waterkit.git\"\n",
3490 );
3491 assert_eq!(
3492 submodules,
3493 BTreeMap::from([
3494 (
3495 "backends/android".to_string(),
3496 "https://github.com/water-rs/android-backend.git".to_string(),
3497 ),
3498 (
3499 "kit".to_string(),
3500 "https://github.com/water-rs/waterkit.git".to_string(),
3501 ),
3502 ])
3503 );
3504 }
3505
3506 fn release(tag: &str, draft: bool, prerelease: bool, published_at: &str) -> Release {
3507 Release {
3508 tag_name: tag.to_owned(),
3509 draft,
3510 prerelease,
3511 published_at: Some(published_at.to_owned()),
3512 assets: vec![ReleaseAsset {
3513 name: "framework.json".to_owned(),
3514 browser_download_url: format!(
3515 "https://github.com/water-rs/waterui/releases/download/{tag}/framework.json"
3516 ),
3517 }],
3518 }
3519 }
3520
3521 #[test]
3522 fn stable_version_is_the_highest_published_unyanked_bare_semver() {
3523 let index = concat!(
3524 r#"{"name":"waterui","vers":"0.4.0","yanked":false}"#,
3525 "\n",
3526 r#"{"name":"waterui","vers":"0.5.0-rc.1","yanked":false}"#,
3528 "\n",
3529 r#"{"name":"waterui","vers":"0.4.1","yanked":false}"#,
3530 "\n",
3531 r#"{"name":"waterui","vers":"0.9.9","yanked":true}"#,
3533 "\n",
3534 r#"{"name":"waterui","vers":"0.3.9","yanked":false}"#,
3537 "\n",
3538 );
3539 let version = newest_registry_version(index.as_bytes()).unwrap();
3540 assert_eq!(version.to_string(), "0.4.1");
3541 }
3542
3543 #[test]
3544 fn stable_index_with_nothing_publishable_names_the_other_channels() {
3545 let index = r#"{"name":"waterui","vers":"0.1.0","yanked":true}"#;
3546 let error = newest_registry_version(index.as_bytes())
3547 .unwrap_err()
3548 .to_string();
3549 assert!(error.contains("dev or nightly"), "{error}");
3550 }
3551
3552 #[test]
3553 fn sparse_index_paths_follow_the_registry_layout() {
3554 assert_eq!(
3555 sparse_index_url("waterui"),
3556 "https://index.crates.io/wa/te/waterui"
3557 );
3558 assert_eq!(sparse_index_url("ab"), "https://index.crates.io/2/ab");
3559 assert_eq!(sparse_index_url("abc"), "https://index.crates.io/3/a/abc");
3560 }
3561
3562 #[test]
3563 fn stable_release_without_a_manifest_reports_it_predates_publishing() {
3564 let error = StableReleaseWithoutManifest {
3565 tag: "v0.4.1".to_owned(),
3566 }
3567 .to_string();
3568 assert!(error.contains("v0.4.1"), "{error}");
3569 assert!(error.contains("predates manifest publishing"), "{error}");
3570 assert!(
3571 error.contains(FIRST_STABLE_MANIFEST_RELEASE),
3572 "{error} must name the first manifest-carrying stable release"
3573 );
3574 assert!(
3575 error.contains("--channel dev") && error.contains("--channel nightly"),
3576 "{error} must name the channels that resolve today"
3577 );
3578 }
3579
3580 #[test]
3581 fn nightly_selection_takes_the_newest_published_prerelease() {
3582 let releases = vec![
3583 release("nightly-2025-12-01", false, true, "2025-12-02T00:00:00Z"),
3584 release("v0.4.1", false, false, "2025-12-03T00:00:00Z"),
3586 release("nightly-2025-12-04", true, true, "2025-12-05T00:00:00Z"),
3587 release("nightly-2025-12-03", false, true, "2025-12-04T00:00:00Z"),
3588 ];
3589 let newest = releases
3590 .into_iter()
3591 .filter(is_nightly_release)
3592 .max_by(|left, right| left.published_at.cmp(&right.published_at))
3593 .unwrap();
3594 assert_eq!(newest.tag_name, "nightly-2025-12-03");
3595 assert_eq!(certification_asset(&newest).unwrap().name, "framework.json");
3596 }
3597
3598 #[test]
3599 fn only_github_api_requests_carry_the_token() {
3600 assert!(
3601 github_api_token(
3602 "https://github.com/water-rs/waterui/releases/download/v0.5.0/framework.json"
3603 )
3604 .is_none()
3605 );
3606 assert!(github_api_token("https://index.crates.io/wa/te/waterui").is_none());
3607 assert!(
3608 github_api_token("https://raw.githubusercontent.com/water-rs/waterui/abc/Cargo.toml")
3609 .is_none()
3610 );
3611 }
3612
3613 fn certification(channel: FrameworkChannel, tag: &str) -> Certification {
3614 Certification {
3615 schema_version: 2,
3616 channel,
3617 repository: "water-rs/waterui".to_owned(),
3618 revision: "a".repeat(40),
3619 tag: tag.to_owned(),
3620 lockfiles: BTreeMap::from([("Cargo.lock".to_owned(), "f".repeat(64))]),
3621 submodules: BTreeMap::new(),
3622 scaffold: BTreeMap::new(),
3623 experimental_packages: BTreeMap::new(),
3624 metadata: toml::toml! {
3625 android-min-api-level = 26
3626 },
3627 }
3628 }
3629
3630 #[test]
3631 fn certification_verification_rejects_uncertified_or_mismatched_manifests() {
3632 let repository = framework_repository();
3633 let release = release("v0.4.1", false, false, "2025-11-01T00:00:00Z");
3634
3635 let dev = certification(FrameworkChannel::Dev, "dev");
3636 assert!(
3637 verify_certification(&dev, None, repository)
3638 .unwrap_err()
3639 .to_string()
3640 .contains("dev")
3641 );
3642
3643 let mut wrong_schema = certification(FrameworkChannel::Stable, "v0.4.1");
3644 wrong_schema.schema_version = 1;
3645 assert!(
3646 verify_certification(&wrong_schema, None, repository)
3647 .unwrap_err()
3648 .to_string()
3649 .contains("schema")
3650 );
3651 let nightly_on_a_stable_tag = certification(FrameworkChannel::Nightly, "v0.4.1");
3652 let error = certifies_channel(&nightly_on_a_stable_tag, FrameworkChannel::Stable)
3653 .unwrap_err()
3654 .to_string();
3655 assert!(error.contains("certifies the nightly channel"), "{error}");
3656 certifies_channel(&nightly_on_a_stable_tag, FrameworkChannel::Nightly).unwrap();
3657
3658 let error = parse_certification(
3661 br#"{"schema_version": 1, "channel": "nightly", "repository": "water-rs/waterui"}"#,
3662 )
3663 .err()
3664 .expect("a schema-1 manifest is rejected")
3665 .to_string();
3666 assert!(
3667 error.contains("schema version 1 is not supported"),
3668 "{error}"
3669 );
3670
3671 let mut wrong_repository = certification(FrameworkChannel::Stable, "v0.4.1");
3672 wrong_repository.repository = "water-rs/android-backend".to_owned();
3673 assert!(
3674 verify_certification(&wrong_repository, None, repository)
3675 .unwrap_err()
3676 .to_string()
3677 .contains("water-rs/android-backend")
3678 );
3679
3680 let wrong_tag = certification(FrameworkChannel::Stable, "v0.4.0");
3681 assert!(
3682 verify_certification(&wrong_tag, Some(&release), repository)
3683 .unwrap_err()
3684 .to_string()
3685 .contains("does not match its release")
3686 );
3687
3688 verify_certification(&wrong_tag, None, repository).unwrap();
3690
3691 let stable = certification(FrameworkChannel::Stable, "v0.4.1");
3692 verify_certification(&stable, Some(&release), repository).unwrap();
3693 }
3694
3695 #[test]
3696 fn manifest_loading_verifies_a_certification_from_disk() {
3697 let directory = tempfile::tempdir().unwrap();
3698 let path = directory.path().join("framework.json");
3699 let manifest = serde_json::json!({
3700 "schema_version": 2,
3701 "channel": "stable",
3702 "repository": "water-rs/waterui",
3703 "revision": "a".repeat(40),
3704 "tag": "v0.4.1",
3705 "lockfiles": {"Cargo.lock": "f".repeat(64)},
3706 "submodules": {
3707 "backends/apple": "b".repeat(40),
3708 "backends/android": "c".repeat(40),
3709 },
3710 "scaffold": {
3711 "hydrolysis-version": "0.2.1",
3712 "hydrolysis-m3-version": "0.2.0",
3713 "waterui-dew-version": "0.2.1",
3714 "waterui-gtk-version": "0.1.2",
3715 "apple-backend-url": "https://github.com/water-rs/apple-backend.git",
3716 "android-backend-url": "https://github.com/water-rs/android-backend.git",
3717 },
3718 "metadata": {
3719 "minimum-cli-version": "0.1.0",
3720 "android-min-api-level": 26,
3721 },
3722 });
3723 std::fs::write(&path, serde_json::to_vec(&manifest).unwrap()).unwrap();
3724 let repository = framework_repository();
3725 let certification = smol::block_on(load_manifest(&path, repository)).unwrap();
3726 assert_eq!(certification.channel, FrameworkChannel::Stable);
3727 assert_eq!(certification.tag, "v0.4.1");
3728
3729 std::fs::write(&path, b"not json").unwrap();
3730 assert!(smol::block_on(load_manifest(&path, repository)).is_err());
3731 }
3732
3733 #[test]
3739 fn framework_scaffold_derives_from_the_framework_manifest() {
3740 let root: toml::Value = toml::from_str(include_str!(
3741 "../../tests/fixtures/framework_checkout_manifest.toml"
3742 ))
3743 .unwrap();
3744 let scaffold = framework_scaffold(&root).unwrap();
3745 let workspace = |name: &str| {
3746 let dependency = &root["workspace"]["dependencies"][name];
3747 dependency
3748 .as_str()
3749 .or_else(|| dependency.get("version").and_then(toml::Value::as_str))
3750 .unwrap()
3751 .to_owned()
3752 };
3753 assert_eq!(
3754 scaffold,
3755 BTreeMap::from([
3756 ("hydrolysis-version".to_owned(), workspace("hydrolysis")),
3757 (
3758 "hydrolysis-m3-version".to_owned(),
3759 workspace("hydrolysis-m3")
3760 ),
3761 ("waterui-dew-version".to_owned(), workspace("waterui-dew")),
3762 (
3763 "waterui-dew-git".to_owned(),
3764 "https://github.com/water-rs/dew".to_owned()
3765 ),
3766 (
3767 "waterui-dew-rev".to_owned(),
3768 "b64f6759a3ebe7ac621bad84be00fe431f978119".to_owned()
3769 ),
3770 ("waterui-gtk-version".to_owned(), workspace("waterui-gtk")),
3771 (
3772 "waterui-gtk-git".to_owned(),
3773 "https://github.com/water-rs/gtk-backend".to_owned()
3774 ),
3775 (
3776 "waterui-gtk-rev".to_owned(),
3777 "3162043e618e759bea6d6e52ec75c6ee1273c080".to_owned()
3778 ),
3779 (
3780 "waterui-winui-version".to_owned(),
3781 workspace("waterui-winui")
3782 ),
3783 (
3784 "waterui-winui-git".to_owned(),
3785 "https://github.com/water-rs/waterui-winui".to_owned()
3786 ),
3787 (
3788 "waterui-winui-rev".to_owned(),
3789 "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee".to_owned()
3790 ),
3791 (
3792 "apple-backend-url".to_owned(),
3793 "https://github.com/water-rs/apple-backend.git".to_owned()
3794 ),
3795 ("apple-backend-version".to_owned(), "0.3.0-dev.2".to_owned()),
3796 (
3797 "android-backend-url".to_owned(),
3798 "https://github.com/water-rs/android-backend.git".to_owned()
3799 ),
3800 ("android-backend-revision".to_owned(), "c".repeat(40)),
3801 ])
3802 );
3803 }
3804
3805 #[test]
3806 fn framework_scaffold_rejects_a_git_package_without_a_revision() {
3807 let mut root: toml::Value = toml::from_str(include_str!(
3808 "../../tests/fixtures/framework_checkout_manifest.toml"
3809 ))
3810 .unwrap();
3811 let gtk = &mut root["workspace"]["dependencies"]["waterui-gtk"];
3812 gtk.as_table_mut()
3813 .unwrap()
3814 .insert("branch".to_owned(), toml::Value::String("dev".to_owned()));
3815 gtk.as_table_mut().unwrap().remove("rev");
3816 let error = framework_scaffold(&root).unwrap_err();
3817 assert!(error.to_string().contains("waterui-gtk"), "{error:?}");
3818 }
3819
3820 #[test]
3821 fn framework_scaffold_rejects_a_backend_revision_that_is_not_a_commit() {
3822 let mut root: toml::Value = toml::from_str(include_str!(
3823 "../../tests/fixtures/framework_checkout_manifest.toml"
3824 ))
3825 .unwrap();
3826 root["package"]["metadata"]["waterui"]["android-backend-revision"] =
3827 toml::Value::String("dev".to_owned());
3828 let error = framework_scaffold(&root).unwrap_err();
3829 assert!(
3830 error.to_string().contains("android-backend-revision"),
3831 "{error:?}"
3832 );
3833 }
3834
3835 #[test]
3836 fn declared_apple_revision_resolves_without_a_gitlink() {
3837 let directory = tempfile::tempdir().unwrap();
3838 let root = directory.path().join("waterui");
3839 let revision = "d".repeat(40);
3840 write_apple_revision_checkout(&root, &revision);
3841
3842 let framework = smol::block_on(ResolvedFramework::for_local_checkout(&root)).unwrap();
3843 assert_eq!(framework.apple_backend_revision(), Some(revision.as_str()));
3844 assert!(framework.apple_backend_version().is_none());
3845 assert!(framework.git_source().is_none());
3846 }
3847
3848 #[test]
3849 fn local_checkout_resolves_from_its_own_manifest() {
3850 let directory = tempfile::tempdir().unwrap();
3851 let root = directory.path().join("waterui");
3852 write_local_checkout(&root);
3853 let framework = smol::block_on(ResolvedFramework::for_local_checkout(&root)).unwrap();
3854 assert_eq!(framework.channel(), None);
3855 assert_eq!(framework.scaffold_value("hydrolysis-version"), "0.2.1");
3856 assert_eq!(
3857 framework.scaffold_value("apple-backend-version"),
3858 "0.3.0-dev.2"
3859 );
3860 assert_eq!(
3861 framework.scaffold_value("android-backend-revision"),
3862 "c".repeat(40)
3863 );
3864 assert_eq!(framework.scaffold_value("waterui-version"), "0.4.1");
3865 assert!(framework.git_source().is_none());
3866 }
3867
3868 #[test]
3872 fn local_checkout_predating_the_gitlink_removals_uses_its_pins() {
3873 let directory = tempfile::tempdir().unwrap();
3874 let root = directory.path().join("waterui");
3875 write_pre_decoupling_checkout(&root);
3876
3877 let framework = smol::block_on(ResolvedFramework::for_local_checkout(&root)).unwrap();
3878 assert_eq!(
3879 framework.scaffold_value("apple-backend-revision"),
3880 "b".repeat(40)
3881 );
3882 assert_eq!(
3883 framework.scaffold_value("android-backend-revision"),
3884 "c".repeat(40)
3885 );
3886 }
3887}