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(default, skip_serializing_if = "toml::Table::is_empty")]
111 metadata: toml::Table,
112 scaffold: BTreeMap<String, String>,
113 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
114 packages: BTreeMap<String, DependencyDetail>,
115 #[serde(default, skip_serializing_if = "PatchSet::is_empty")]
116 patches: PatchSet,
117}
118
119#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)]
120struct LockedPackage {
121 name: String,
122 version: String,
123 source: Option<String>,
124}
125
126impl From<&cargo_lock::Package> for LockedPackage {
127 fn from(package: &cargo_lock::Package) -> Self {
128 Self {
129 name: package.name.to_string(),
130 version: package.version.to_string(),
131 source: package.source.as_ref().map(ToString::to_string),
132 }
133 }
134}
135
136#[derive(Debug, Deserialize)]
137struct Release {
138 tag_name: String,
139 draft: bool,
140 prerelease: bool,
141 published_at: Option<String>,
142 assets: Vec<ReleaseAsset>,
143}
144
145#[derive(Debug, Deserialize)]
146struct ReleaseAsset {
147 name: String,
148 browser_download_url: String,
149}
150
151#[derive(Deserialize)]
152struct Certification {
153 schema_version: u32,
154 channel: FrameworkChannel,
155 repository: String,
156 revision: String,
157 tag: String,
158 lockfiles: BTreeMap<String, String>,
159 #[serde(default)]
161 submodules: BTreeMap<String, String>,
162 scaffold: BTreeMap<String, String>,
163 metadata: toml::Table,
166}
167
168pub(crate) fn rewrite_patch_tables(
173 document: &mut toml_edit::DocumentMut,
174 previous: &PatchSet,
175 patches: &PatchSet,
176) -> Result<()> {
177 for (source, dependencies) in previous {
178 if let Some(table) = document
179 .get_mut("patch")
180 .and_then(|patch| patch.get_mut(source))
181 .and_then(toml_edit::Item::as_table_like_mut)
182 {
183 for name in dependencies.keys() {
184 table.remove(name);
185 }
186 }
187 }
188 let patches = toml_edit::ser::to_document(patches)?;
189 for (source, dependencies) in patches.iter() {
190 let patch = document
194 .entry("patch")
195 .or_insert_with(toml_edit::table)
196 .as_table_mut()
197 .ok_or_else(|| eyre!("[patch] is not a table"))?;
198 patch.set_implicit(true);
199 let table = patch
200 .entry(source)
201 .or_insert_with(toml_edit::table)
202 .as_table_like_mut()
203 .ok_or_else(|| eyre!("[patch.{source}] is not a table"))?;
204 for (name, dependency) in dependencies
205 .as_table_like()
206 .expect("serialized patch dependencies are tables")
207 .iter()
208 {
209 table.insert(name, dependency.clone());
210 }
211 }
212 if let Some(patch) = document
213 .get_mut("patch")
214 .and_then(toml_edit::Item::as_table_mut)
215 {
216 let empty: Vec<String> = patch
217 .iter()
218 .filter(|(_, sources)| {
219 sources
220 .as_table_like()
221 .is_some_and(toml_edit::TableLike::is_empty)
222 })
223 .map(|(source, _)| source.to_owned())
224 .collect();
225 for source in empty {
226 patch.remove(&source);
227 }
228 if patch.is_empty() {
229 document.remove("patch");
230 }
231 }
232 Ok(())
233}
234
235impl ResolvedFramework {
236 #[must_use]
239 pub const fn channel(&self) -> Option<FrameworkChannel> {
240 match self.source {
241 Source::Stable { .. } => Some(FrameworkChannel::Stable),
242 Source::Dev { .. } => Some(FrameworkChannel::Dev),
243 Source::Nightly { .. } => Some(FrameworkChannel::Nightly),
244 Source::Local { .. } => None,
245 }
246 }
247
248 pub(crate) async fn for_manifest(
257 manifest: &crate::project::Manifest,
258 project_root: &Path,
259 ) -> Result<Self> {
260 if let Some(framework) = &manifest.framework {
261 return framework.clone().validated().wrap_err(
262 "the recorded framework selection predates a metadata key this CLI \
263 requires; re-run `water channel` to resolve it again",
264 );
265 }
266 let Some(waterui_path) = &manifest.waterui_path else {
267 bail!(
268 "the project records no framework selection; run `water channel` \
269 to select one or point `waterui_path` at a checkout"
270 );
271 };
272 Self::for_local_checkout(&project_root.join(waterui_path)).await
273 }
274
275 pub(crate) async fn for_local_checkout(root: &Path) -> Result<Self> {
280 let manifest: toml::Value = toml::from_str(
281 &smol::fs::read_to_string(root.join("Cargo.toml"))
282 .await
283 .wrap_err_with(|| {
284 format!(
285 "the WaterUI checkout at {} has no Cargo.toml",
286 root.display()
287 )
288 })?,
289 )?;
290 let metadata = framework_metadata(&manifest)?;
291 let minimum_cli_version = minimum_cli_version(&metadata)?;
292 if let Some(minimum) = &minimum_cli_version {
293 validate_installed_cli(minimum, &checkout_cli_update())?;
294 }
295 let mut scaffold = framework_scaffold(&manifest)?;
296 let lock: Lockfile = smol::fs::read_to_string(root.join("Cargo.lock"))
297 .await
298 .wrap_err_with(|| {
299 format!(
300 "the WaterUI checkout at {} has no Cargo.lock",
301 root.display()
302 )
303 })?
304 .parse()?;
305 let mut submodules = BTreeMap::new();
306 for path in BACKEND_SUBMODULES {
310 if declares_backend_revision(&scaffold, path) {
311 continue;
312 }
313 submodules.insert(
314 (*path).to_owned(),
315 local_submodule_revision(root, path).await?,
316 );
317 }
318 if !scaffold.contains_key("apple-backend-version") {
322 submodules.insert(
323 "backends/apple".to_owned(),
324 local_submodule_revision(root, "backends/apple").await?,
325 );
326 }
327 complete_scaffold(&mut scaffold, &submodules, &lock)?;
328 Self {
329 source: Source::Local {
330 root: root.to_path_buf(),
331 },
332 minimum_cli_version,
333 metadata,
334 scaffold,
335 packages: BTreeMap::new(),
336 patches: PatchSet::default(),
337 }
338 .validated()
339 }
340
341 fn validated(self) -> Result<Self> {
346 self.android_min_api_level()?;
347 Ok(self)
348 }
349
350 pub(crate) async fn resolve_manifest(path: &Path) -> Result<(Self, Option<Vec<u8>>)> {
359 let repository = framework_repository();
360 let slug = repository_slug(repository)?;
361 let certification = load_manifest(path, repository).await?;
362 let revision = certification.revision.clone();
363 Self::construct(repository, slug, &revision, Some(certification)).await
364 }
365
366 pub(crate) fn validate_cli(&self) -> Result<()> {
367 if let Some(minimum) = &self.minimum_cli_version {
368 let update = match &self.source {
369 Source::Stable { .. } => registry_cli_update(minimum),
370 Source::Dev { .. } | Source::Nightly { .. } | Source::Local { .. } => {
371 checkout_cli_update()
372 }
373 };
374 validate_installed_cli(minimum, &update)?;
375 }
376 Ok(())
377 }
378
379 pub(crate) fn scaffold_value(&self, key: &str) -> &str {
380 self.scaffold
381 .get(key)
382 .unwrap_or_else(|| panic!("resolved framework carries no `{key}` scaffold metadata"))
383 }
384
385 pub(crate) fn apple_backend_version(&self) -> Option<&str> {
389 self.scaffold
390 .get("apple-backend-version")
391 .map(String::as_str)
392 }
393
394 pub(crate) fn apple_backend_revision(&self) -> Option<&str> {
399 self.scaffold
400 .get("apple-backend-revision")
401 .map(String::as_str)
402 }
403
404 pub(crate) fn android_min_api_level(&self) -> Result<u32> {
414 const KEY: &str = "package.metadata.waterui.android-min-api-level";
415 let origin = match &self.source {
416 Source::Stable { release } => release.as_ref().map_or_else(
417 || "the stable framework manifest".to_owned(),
418 |release| format!("the framework manifest certified by {}", release.tag),
419 ),
420 Source::Dev {
421 repository,
422 revision,
423 ..
424 }
425 | Source::Nightly {
426 repository,
427 revision,
428 ..
429 } => format!("the framework manifest at {repository}@{revision}"),
430 Source::Local { root } => format!("{}", root.join("Cargo.toml").display()),
431 };
432 let value = self
433 .metadata
434 .get("android-min-api-level")
435 .ok_or_else(|| eyre!("{origin} does not declare {KEY}"))?;
436 value
437 .as_integer()
438 .and_then(|level| u32::try_from(level).ok())
439 .ok_or_else(|| eyre!("{origin} declares an invalid {KEY}: {value}"))
440 }
441
442 pub(crate) fn patches(&self) -> PatchSet {
443 self.patches.clone()
444 }
445
446 pub(crate) fn update_manifest(
451 &self,
452 document: &mut toml_edit::DocumentMut,
453 previous_patches: &PatchSet,
454 ) -> Result<()> {
455 for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
456 if let Some(dependencies) = document
457 .get_mut(section)
458 .and_then(toml_edit::Item::as_table_like_mut)
459 {
460 self.update_dependencies(dependencies)?;
461 }
462 }
463 if let Some(targets) = document
464 .get_mut("target")
465 .and_then(toml_edit::Item::as_table_like_mut)
466 {
467 for (_, target) in targets.iter_mut() {
468 for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
469 if let Some(dependencies) = target
470 .get_mut(section)
471 .and_then(toml_edit::Item::as_table_like_mut)
472 {
473 self.update_dependencies(dependencies)?;
474 }
475 }
476 }
477 }
478 rewrite_patch_tables(document, previous_patches, &self.patches)
479 }
480
481 fn update_dependencies(&self, dependencies: &mut dyn toml_edit::TableLike) -> Result<()> {
482 for (name, dependency) in dependencies.iter_mut() {
483 let package = dependency
484 .get("package")
485 .and_then(toml_edit::Item::as_str)
486 .unwrap_or(&name)
487 .to_owned();
488 if !self.scaffold.contains_key(&format!("{package}-version")) {
489 continue;
490 }
491 if dependency.is_str() {
492 let decor = dependency
493 .as_value()
494 .expect("string dependency")
495 .decor()
496 .clone();
497 let mut value = toml_edit::Value::InlineTable(toml_edit::InlineTable::new());
498 *value.decor_mut() = decor;
499 *dependency = toml_edit::Item::Value(value);
500 }
501 let table = dependency
502 .as_table_like_mut()
503 .ok_or_else(|| eyre!("invalid dependency {name}"))?;
504 if table.contains_key("workspace") {
505 bail!(
506 "{name} inherits its source; select the framework at its Cargo workspace root"
507 );
508 }
509 for key in [
510 "version",
511 "git",
512 "rev",
513 "branch",
514 "tag",
515 "path",
516 "registry",
517 "registry-index",
518 ] {
519 table.remove(key);
520 }
521 let source = toml_edit::ser::to_document(&self.dependency(&package))?;
522 for key in ["version", "git", "rev"] {
523 if let Some(value) = source.get(key) {
524 table.insert(key, value.clone());
525 }
526 }
527 }
528 Ok(())
529 }
530
531 pub(crate) fn validate_dependencies(
532 &self,
533 metadata: &cargo_metadata::Metadata,
534 contents: &[u8],
535 ) -> Result<()> {
536 let source = match &self.source {
537 Source::Stable { .. } | Source::Local { .. } => return Ok(()),
538 Source::Dev {
539 repository,
540 revision,
541 ..
542 }
543 | Source::Nightly {
544 repository,
545 revision,
546 ..
547 } => format!("git+{repository}?rev={revision}#{revision}"),
548 };
549 let locked = self.cargo_lock(contents)?;
550 let allowed = self.allowed_packages(&locked.packages);
551 let packages: BTreeMap<_, _> = metadata
552 .packages
553 .iter()
554 .map(|package| (package.id.clone(), package))
555 .collect();
556 let resolve = metadata
557 .resolve
558 .as_ref()
559 .ok_or_else(|| eyre!("framework verification requires a resolved Cargo graph"))?;
560 let nodes: BTreeMap<_, _> = resolve
561 .nodes
562 .iter()
563 .map(|node| (node.id.clone(), node))
564 .collect();
565 let is_framework_source = |package: &cargo_metadata::Package| {
566 package
567 .source
568 .as_ref()
569 .is_some_and(|candidate| candidate.repr == source)
570 };
571 if !metadata.packages.iter().any(is_framework_source) {
572 bail!("the project does not resolve its selected framework revision");
573 }
574 let mut pending: Vec<_> = metadata
575 .packages
576 .iter()
577 .filter(|package| {
578 is_framework_source(package) || self.packages.contains_key(package.name.as_str())
579 })
580 .map(|package| package.id.clone())
581 .collect();
582 let mut visited = BTreeSet::new();
583 while let Some(id) = pending.pop() {
584 if !visited.insert(id.clone()) {
585 continue;
586 }
587 let package = packages[&id];
588 let identity = LockedPackage {
589 name: package.name.to_string(),
590 version: package.version.to_string(),
591 source: package.source.as_ref().map(|source| source.repr.clone()),
592 };
593 if !allowed.contains(&identity) {
594 bail!(
595 "framework dependency {} {} differs from Water.lock; select a compatible channel explicitly",
596 identity.name,
597 identity.version
598 );
599 }
600 pending.extend(nodes[&id].dependencies.iter().cloned());
601 }
602 Ok(())
603 }
604
605 pub(crate) async fn prepare_build(
606 &self,
607 project: &Project,
608 directory: &std::path::Path,
609 features: &[String],
610 ) -> Result<()> {
611 self.validate_cli()?;
612 let project_lock: Lockfile = smol::fs::read_to_string(project.lockfile_path().await?)
613 .await?
614 .parse()?;
615 let lock_path = directory.join("Cargo.lock");
616 let previous: Option<Lockfile> = match smol::fs::read_to_string(&lock_path).await {
617 Ok(contents) => Some(contents.parse()?),
618 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
619 Err(error) => return Err(error.into()),
620 };
621 let allow_new = previous.as_ref().is_none_or(|previous| {
622 let previous: BTreeSet<_> = previous
623 .packages
624 .iter()
625 .map(LockedDependency::from)
626 .collect();
627 project_lock
628 .packages
629 .iter()
630 .filter(|package| package.name.as_str() == "waterui")
631 .any(|package| !previous.contains(&LockedDependency::from(package)))
632 });
633 let canonical = if self.channel() == Some(FrameworkChannel::Stable) {
634 None
635 } else {
636 Some(smol::fs::read(project.root().join("Water.lock")).await?)
637 };
638 let mut packages = BTreeMap::new();
639 if let Some(previous) = &previous {
640 packages.extend(
641 previous
642 .packages
643 .iter()
644 .map(|package| (LockedDependency::from(package), package.clone())),
645 );
646 }
647 if let Some(canonical) = &canonical {
648 packages.extend(
649 self.cargo_lock(canonical)?
650 .packages
651 .into_iter()
652 .map(|package| (LockedDependency::from(&package), package)),
653 );
654 }
655 packages.extend(
656 project_lock
657 .packages
658 .iter()
659 .map(|package| (LockedDependency::from(package), package.clone())),
660 );
661 let allowed = self.allowed_packages(packages.values());
662 let mut seed = project_lock;
663 seed.packages = packages.into_values().collect();
664 smol::fs::write(&lock_path, seed.to_string()).await?;
665 let root = directory.to_path_buf();
666 let features = features.to_vec();
667 let result = async {
668 let metadata = smol::unblock(move || {
669 cargo_metadata::MetadataCommand::new().current_dir(root)
670 .features(cargo_metadata::CargoOpt::SomeFeatures(features)).exec()
671 }).await?;
672 validate_resolved_cli(&metadata)?;
673 if !allow_new {
674 for package in &metadata.packages {
675 if package.source.is_some() && !allowed.contains(&LockedPackage {
676 name: package.name.to_string(),
677 version: package.version.to_string(),
678 source: package.source.as_ref().map(|source| source.repr.clone()),
679 }) {
680 bail!("generated build would change locked dependency {}; update the framework channel explicitly", package.name);
681 }
682 }
683 }
684 if let Some(canonical) = canonical {
685 self.validate_dependencies(&metadata, &canonical)?;
686 }
687 Ok(())
688 }.await;
689 if let Err(error) = &result {
690 let restore = if let Some(previous) = previous {
691 smol::fs::write(&lock_path, previous.to_string()).await
692 } else {
693 smol::fs::remove_file(&lock_path).await
694 };
695 restore.wrap_err_with(|| {
696 format!("failed to preserve the previous lock after resolution failed: {error}")
697 })?;
698 }
699 result
700 }
701
702 pub(crate) fn cargo_lock(&self, contents: &[u8]) -> Result<Lockfile> {
703 let (repository, revision, expected) = match &self.source {
704 Source::Stable { .. } => bail!("stable uses the application's Cargo.lock"),
705 Source::Local { .. } => {
706 bail!("a local framework checkout has no canonical lock")
707 }
708 Source::Dev {
709 repository,
710 revision,
711 lock_sha256,
712 }
713 | Source::Nightly {
714 repository,
715 revision,
716 lock_sha256,
717 ..
718 } => (repository, revision, lock_sha256),
719 };
720 if hex::encode(Sha256::digest(contents)) != *expected {
721 bail!("Water.lock does not match the selected framework revision");
722 }
723 let mut lock: Lockfile = std::str::from_utf8(contents)?.parse()?;
724 let source = format!("git+{repository}?rev={revision}#{revision}")
725 .parse::<cargo_lock::SourceId>()?;
726 let mut local = BTreeMap::new();
727 for package in &mut lock.packages {
728 if package.source.is_none() {
729 package.source = Some(source.clone());
730 local.insert(
731 (package.name.clone(), package.version.clone()),
732 LockedDependency::from(&*package),
733 );
734 }
735 }
736 for package in &mut lock.packages {
737 for dependency in &mut package.dependencies {
738 if dependency.source.is_none()
739 && let Some(replacement) =
740 local.get(&(dependency.name.clone(), dependency.version.clone()))
741 {
742 *dependency = replacement.clone();
743 }
744 }
745 }
746 Ok(lock)
747 }
748
749 pub(crate) const fn git_source(&self) -> Option<(&str, &str)> {
756 match &self.source {
757 Source::Stable { .. } | Source::Local { .. } => None,
758 Source::Dev {
759 repository,
760 revision,
761 ..
762 }
763 | Source::Nightly {
764 repository,
765 revision,
766 ..
767 } => Some((repository.as_str(), revision.as_str())),
768 }
769 }
770
771 fn allowed_packages<'p>(
779 &self,
780 packages: impl IntoIterator<Item = &'p cargo_lock::Package>,
781 ) -> BTreeSet<LockedPackage> {
782 let mut allowed = BTreeSet::new();
783 let Some((repository, revision)) = self.git_source() else {
784 return packages.into_iter().map(LockedPackage::from).collect();
785 };
786 let framework_source = format!("git+{repository}?rev={revision}#{revision}");
787 let pinned: BTreeMap<&str, String> = self
789 .patches
790 .values()
791 .flatten()
792 .filter_map(|(name, dependency)| {
793 let Dependency::Detailed(detail) = dependency else {
794 return None;
795 };
796 let (git, rev) = detail.git.as_deref().zip(detail.rev.as_deref())?;
797 Some((name.as_str(), format!("git+{git}?rev={rev}#{rev}")))
798 })
799 .collect();
800 for package in packages {
801 let identity = LockedPackage::from(package);
802 if let Some(source) = &identity.source
803 && let Some(pin) = pinned.get(identity.name.as_str())
804 {
805 if source == &framework_source {
806 allowed.insert(LockedPackage {
807 source: Some(pin.clone()),
808 ..identity.clone()
809 });
810 } else if source == pin {
811 allowed.insert(LockedPackage {
812 source: Some(framework_source.clone()),
813 ..identity.clone()
814 });
815 }
816 }
817 allowed.insert(identity);
818 }
819 allowed
820 }
821
822 pub(crate) fn dependency(&self, name: &str) -> DependencyDetail {
823 match &self.source {
824 Source::Stable { .. } => DependencyDetail {
825 version: Some(
826 format!("={}", self.scaffold_value(&format!("{name}-version")))
827 .parse()
828 .expect("resolved package version is valid"),
829 ),
830 ..Default::default()
831 },
832 Source::Dev { .. } | Source::Nightly { .. } => self.packages[name].clone(),
833 Source::Local { .. } => {
834 unreachable!("a local checkout resolves framework crates by path")
835 }
836 }
837 }
838
839 pub(crate) async fn resolve(channel: FrameworkChannel) -> Result<(Self, Option<Vec<u8>>)> {
851 let repository = framework_repository();
852 let slug = repository_slug(repository)?;
853 match channel {
854 FrameworkChannel::Stable | FrameworkChannel::Nightly => {
855 let certification = latest_certification(repository, channel).await?;
856 let revision = certification.revision.clone();
857 Self::construct(repository, slug, &revision, Some(certification)).await
858 }
859 FrameworkChannel::Dev => {
860 let revision = resolve_dev(repository, slug).await?;
861 Self::construct(repository, slug, &revision, None).await
862 }
863 }
864 }
865
866 async fn construct(
877 repository: &str,
878 slug: &str,
879 revision: &str,
880 certification: Option<Certification>,
881 ) -> Result<(Self, Option<Vec<u8>>)> {
882 validate_revision(revision)?;
883 let base = format!("https://raw.githubusercontent.com/{slug}/{revision}");
884 let manifest_bytes = fetch(&format!("{base}/Cargo.toml")).await?;
885 let root: toml::Value = toml::from_str(std::str::from_utf8(&manifest_bytes)?)?;
886 let metadata = framework_metadata(&root)?;
887 let minimum_cli_version = minimum_cli_version(&metadata)?;
888 let mut scaffold = framework_scaffold(&root)?;
889 let lock_bytes = fetch(&format!("{base}/Cargo.lock")).await?;
890 let lock_sha256 = hex::encode(Sha256::digest(&lock_bytes));
891 let lock: Lockfile = std::str::from_utf8(&lock_bytes)?.parse()?;
892
893 let channel = certification
894 .as_ref()
895 .map_or(FrameworkChannel::Dev, |certification| certification.channel);
896 if let Some(minimum) = &minimum_cli_version {
897 let update = match channel {
898 FrameworkChannel::Stable => registry_cli_update(minimum),
899 FrameworkChannel::Dev | FrameworkChannel::Nightly => checkout_cli_update(),
900 };
901 validate_installed_cli(minimum, &update)?;
902 }
903
904 let submodule_repositories = match channel {
909 FrameworkChannel::Stable => BTreeMap::new(),
910 FrameworkChannel::Dev | FrameworkChannel::Nightly => {
911 match fetch_optional(&format!("{base}/.gitmodules")).await? {
912 Some(bytes) => parse_gitmodules(std::str::from_utf8(&bytes)?),
913 None => BTreeMap::new(),
915 }
916 }
917 };
918
919 let (source, submodules) = if let Some(certification) = &certification {
920 (
921 certified_source(
922 certification,
923 repository,
924 revision,
925 &metadata,
926 &scaffold,
927 &lock_sha256,
928 )?,
929 certification.submodules.clone(),
930 )
931 } else {
932 (
933 Source::Dev {
934 repository: repository.to_owned(),
935 revision: revision.to_owned(),
936 lock_sha256,
937 },
938 dev_submodules(slug, revision, &scaffold, &submodule_repositories).await?,
939 )
940 };
941 complete_scaffold(&mut scaffold, &submodules, &lock)?;
942 channel_apple_backend_pin(channel, &mut scaffold, certification.as_ref()).await?;
943
944 let (packages, patches, lockfile) = match channel {
945 FrameworkChannel::Stable => (BTreeMap::new(), PatchSet::default(), None),
949 FrameworkChannel::Dev | FrameworkChannel::Nightly => {
950 let patches: PatchSet = root
951 .get("patch")
952 .cloned()
953 .map(toml::Value::try_into)
954 .transpose()?
955 .unwrap_or_default();
956 let pins = submodule_pins(submodule_repositories, &submodules);
960 let patches = rebase_patches_onto_source(patches, repository, revision, &pins);
961 let packages = resolve_packages(&scaffold, &lock, repository, revision)?;
962 (packages, patches, Some(lock_bytes))
963 }
964 };
965 Ok((
966 Self {
967 source,
968 minimum_cli_version,
969 metadata,
970 scaffold,
971 packages,
972 patches,
973 }
974 .validated()?,
975 lockfile,
976 ))
977 }
978}
979
980async fn channel_apple_backend_pin(
989 channel: FrameworkChannel,
990 scaffold: &mut BTreeMap<String, String>,
991 certification: Option<&Certification>,
992) -> Result<()> {
993 match channel {
994 FrameworkChannel::Dev if scaffold.contains_key("apple-backend-version") => {
995 let url = scaffold.get("apple-backend-url").ok_or_else(|| {
996 eyre!("framework manifest declares apple-backend-version without apple-backend-url")
997 })?;
998 let revision = backend_dev_revision(url).await?;
999 scaffold.insert("apple-backend-revision".to_owned(), revision);
1000 }
1001 FrameworkChannel::Nightly => {
1002 if let Some(revision) = certification
1003 .and_then(|certification| certification.scaffold.get("apple-backend-revision"))
1004 {
1005 validate_revision(revision)
1006 .wrap_err("nightly certification scaffold `apple-backend-revision`")?;
1007 scaffold.insert("apple-backend-revision".to_owned(), revision.clone());
1008 }
1009 }
1010 FrameworkChannel::Stable | FrameworkChannel::Dev => {}
1011 }
1012 Ok(())
1013}
1014
1015async fn dev_submodules(
1020 slug: &str,
1021 revision: &str,
1022 scaffold: &BTreeMap<String, String>,
1023 submodule_repositories: &BTreeMap<String, String>,
1024) -> Result<BTreeMap<String, String>> {
1025 let mut submodules = BTreeMap::new();
1026 for path in BACKEND_SUBMODULES {
1027 if declares_backend_revision(scaffold, path) {
1028 continue;
1029 }
1030 submodules.insert(
1031 (*path).to_owned(),
1032 submodule_revision(slug, revision, path).await?,
1033 );
1034 }
1035 if !scaffold.contains_key("apple-backend-version") {
1039 submodules.insert(
1040 "backends/apple".to_owned(),
1041 submodule_revision(slug, revision, "backends/apple").await?,
1042 );
1043 }
1044 for path in submodule_repositories.keys() {
1049 if !submodules.contains_key(path)
1050 && let Some(commit) = submodule_pin(slug, revision, path).await?
1051 {
1052 submodules.insert(path.clone(), commit);
1053 }
1054 }
1055 Ok(submodules)
1056}
1057
1058fn submodule_pins(
1061 repositories: BTreeMap<String, String>,
1062 submodules: &BTreeMap<String, String>,
1063) -> BTreeMap<String, SubmodulePin> {
1064 repositories
1065 .into_iter()
1066 .filter_map(|(path, url)| {
1067 submodules.get(&path).map(|commit| {
1068 (
1069 path,
1070 SubmodulePin {
1071 repository: canonical_git_url(&url).to_owned(),
1072 commit: commit.clone(),
1073 },
1074 )
1075 })
1076 })
1077 .collect()
1078}
1079
1080fn certified_source(
1085 certification: &Certification,
1086 repository: &str,
1087 revision: &str,
1088 metadata: &toml::Table,
1089 scaffold: &BTreeMap<String, String>,
1090 lock_sha256: &str,
1091) -> Result<Source> {
1092 let channel = certification.channel;
1093 if certification.metadata != *metadata {
1094 bail!("{channel} framework metadata does not match its certification");
1095 }
1096 for (key, value) in scaffold {
1097 if certification.scaffold.get(key) != Some(value) {
1098 bail!("{channel} certification scaffold `{key}` does not match the framework manifest");
1099 }
1100 }
1101 let expected = certification
1102 .lockfiles
1103 .get("Cargo.lock")
1104 .ok_or_else(|| eyre!("{channel} certification has no dependency lock"))?;
1105 if lock_sha256 != *expected {
1106 bail!("{channel} dependency lock does not match its certification");
1107 }
1108 let release = FrameworkRelease {
1109 repository: repository.to_owned(),
1110 revision: revision.to_owned(),
1111 tag: certification.tag.clone(),
1112 };
1113 Ok(match certification.channel {
1114 FrameworkChannel::Stable => Source::Stable {
1115 release: Some(release),
1116 },
1117 FrameworkChannel::Nightly => Source::Nightly {
1118 repository: repository.to_owned(),
1119 revision: revision.to_owned(),
1120 tag: certification.tag.clone(),
1121 lock_sha256: lock_sha256.to_owned(),
1122 },
1123 FrameworkChannel::Dev => unreachable!("verify_certification rejects a dev manifest"),
1124 })
1125}
1126
1127const BACKEND_SUBMODULES: &[&str] = &["backends/android"];
1134
1135fn declares_backend_revision(scaffold: &BTreeMap<String, String>, submodule_path: &str) -> bool {
1138 scaffold.contains_key(&format!(
1139 "{}-backend-revision",
1140 backend_name(submodule_path)
1141 ))
1142}
1143
1144const FRAMEWORK_PACKAGES: &[&str] = &[
1148 "waterui",
1149 "waterui-core",
1150 "waterui-testing",
1151 "waterui-ffi",
1152 "waterui-locale",
1153 "waterui-browser-cef",
1154 "waterui-preview",
1155 "waterui-preview-protocol",
1156 "waterui-mcp",
1157];
1158
1159fn backend_name(submodule_path: &str) -> &str {
1160 submodule_path
1161 .rsplit('/')
1162 .next()
1163 .expect("a submodule path has a basename")
1164}
1165
1166fn framework_repository() -> &'static str {
1171 env!("WATERUI_FRAMEWORK_REPOSITORY").trim_end_matches(".git")
1172}
1173
1174fn repository_slug(repository: &str) -> Result<&str> {
1176 repository
1177 .strip_prefix("https://github.com/")
1178 .ok_or_else(|| eyre!("{repository} must identify its GitHub source"))
1179}
1180
1181fn framework_metadata(manifest: &toml::Value) -> Result<toml::Table> {
1185 manifest
1186 .get("package")
1187 .and_then(|package| package.get("metadata"))
1188 .and_then(|metadata| metadata.get("waterui"))
1189 .map_or_else(
1190 || Ok(toml::Table::new()),
1191 |metadata| {
1192 metadata
1193 .clone()
1194 .try_into()
1195 .wrap_err("invalid package.metadata.waterui metadata")
1196 },
1197 )
1198}
1199
1200fn framework_scaffold(manifest: &toml::Value) -> Result<BTreeMap<String, String>> {
1210 let metadata = framework_metadata(manifest)?;
1211 let workspace = manifest
1212 .get("workspace")
1213 .and_then(|workspace| workspace.get("dependencies"))
1214 .and_then(toml::Value::as_table)
1215 .ok_or_else(|| eyre!("framework manifest has no [workspace.dependencies]"))?;
1216 let packages = metadata
1217 .get("scaffold-packages")
1218 .and_then(toml::Value::as_array)
1219 .ok_or_else(|| {
1220 eyre!("framework manifest has no package.metadata.waterui.scaffold-packages")
1221 })?;
1222 let mut scaffold = BTreeMap::new();
1223 for package in packages {
1224 let name = package.as_str().ok_or_else(|| {
1225 eyre!("package.metadata.waterui.scaffold-packages entries must be crate names")
1226 })?;
1227 let dependency = workspace.get(name).ok_or_else(|| {
1228 eyre!("scaffold package {name} has no [workspace.dependencies] requirement")
1229 })?;
1230 let requirement = dependency
1231 .as_str()
1232 .or_else(|| dependency.get("version").and_then(toml::Value::as_str))
1233 .ok_or_else(|| eyre!("workspace.dependencies.{name} declares no version"))?;
1234 scaffold.insert(format!("{name}-version"), requirement.to_owned());
1235 }
1236 for (key, value) in &metadata {
1237 if !(key.ends_with("-backend-url")
1238 || key.ends_with("-backend-version")
1239 || key.ends_with("-backend-revision"))
1240 {
1241 continue;
1242 }
1243 let value = value
1244 .as_str()
1245 .ok_or_else(|| eyre!("package.metadata.waterui.{key} must be a string"))?;
1246 if key.ends_with("-backend-revision") {
1247 validate_revision(value).wrap_err_with(|| format!("package.metadata.waterui.{key}"))?;
1248 }
1249 scaffold.insert(key.clone(), value.to_owned());
1250 }
1251 Ok(scaffold)
1252}
1253
1254fn minimum_cli_version(metadata: &toml::Table) -> Result<Option<cargo_toml::SemVer>> {
1258 metadata
1259 .get("minimum-cli-version")
1260 .cloned()
1261 .map(toml::Value::try_into)
1262 .transpose()
1263 .wrap_err("invalid package.metadata.waterui.minimum-cli-version")
1264}
1265
1266fn checkout_cli_update() -> String {
1270 format!(
1271 "cargo install {} --git {} --locked",
1272 env!("CARGO_PKG_NAME"),
1273 env!("CARGO_PKG_REPOSITORY")
1274 )
1275}
1276
1277fn registry_cli_update(minimum: &cargo_toml::SemVer) -> String {
1278 format!(
1279 "cargo install {} --version '>={minimum}' --locked",
1280 env!("CARGO_PKG_NAME")
1281 )
1282}
1283
1284fn validate_installed_cli(minimum: &cargo_toml::SemVer, update: &str) -> Result<()> {
1285 let current = env!("CARGO_PKG_VERSION")
1286 .parse()
1287 .expect("CLI package version is valid");
1288 validate_cli_version(minimum, ¤t, update)
1289}
1290
1291fn validate_cli_version(
1292 minimum: &cargo_toml::SemVer,
1293 current: &cargo_toml::SemVer,
1294 update: &str,
1295) -> Result<()> {
1296 if current.cmp_precedence(minimum).is_lt() {
1297 bail!(
1298 "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`."
1299 );
1300 }
1301 Ok(())
1302}
1303
1304pub(crate) async fn validate_local_cli(root: &Path) -> Result<()> {
1305 let contents = smol::fs::read_to_string(root.join("Cargo.toml")).await?;
1306 let manifest = toml::from_str(&contents)?;
1307 if let Some(minimum) = minimum_cli_version(&framework_metadata(&manifest)?)? {
1308 validate_installed_cli(&minimum, &checkout_cli_update())?;
1309 }
1310 Ok(())
1311}
1312
1313pub(crate) fn validate_resolved_cli(metadata: &cargo_metadata::Metadata) -> Result<()> {
1314 for package in metadata
1315 .packages
1316 .iter()
1317 .filter(|package| package.name.as_str() == "waterui")
1318 {
1319 let Some(value) = package
1320 .metadata
1321 .get("waterui")
1322 .and_then(|metadata| metadata.get("minimum-cli-version"))
1323 else {
1324 continue;
1325 };
1326 let minimum: cargo_toml::SemVer = serde_json::from_value(value.clone())
1327 .wrap_err("invalid package.metadata.waterui.minimum-cli-version")?;
1328 let source = package
1329 .source
1330 .as_ref()
1331 .map(|source| source.repr.parse::<cargo_lock::SourceId>())
1332 .transpose()?;
1333 let update = match source {
1334 Some(source) if !source.is_git() => registry_cli_update(&minimum),
1335 Some(_) | None => checkout_cli_update(),
1336 };
1337 validate_installed_cli(&minimum, &update)?;
1338 }
1339 Ok(())
1340}
1341
1342fn resolve_packages(
1343 scaffold: &BTreeMap<String, String>,
1344 lock: &Lockfile,
1345 repository: &str,
1346 revision: &str,
1347) -> Result<BTreeMap<String, DependencyDetail>> {
1348 let mut packages = BTreeMap::new();
1349 for (key, version) in scaffold {
1350 let Some(name) = key.strip_suffix("-version") else {
1351 continue;
1352 };
1353 if name.ends_with("-backend") {
1356 continue;
1357 }
1358 let candidates: Vec<_> = lock
1359 .packages
1360 .iter()
1361 .filter(|package| {
1362 package.name.as_str() == name && package.version.to_string() == *version
1363 })
1364 .collect();
1365 let package = match candidates.as_slice() {
1366 [package] => *package,
1367 [] => {
1374 packages.insert(
1375 name.to_owned(),
1376 DependencyDetail {
1377 version: Some(format!("={version}").parse()?),
1378 ..Default::default()
1379 },
1380 );
1381 continue;
1382 }
1383 _ => bail!("framework lock has multiple sources for {name} {version}"),
1384 };
1385 let mut dependency = DependencyDetail::default();
1386 match &package.source {
1387 None => {
1388 dependency.git = Some(repository.to_owned());
1389 dependency.rev = Some(revision.to_owned());
1390 }
1391 Some(source) if source.is_default_registry() => {
1392 dependency.version = Some(format!("={version}").parse()?);
1393 }
1394 Some(source) if source.is_git() => {
1395 let Some(cargo_lock::package::GitReference::Rev(revision)) = source.git_reference()
1396 else {
1397 bail!(
1398 "framework package {name} must use an immutable Git revision in the framework manifest"
1399 );
1400 };
1401 validate_revision(revision)?;
1402 if source.precise() != Some(revision.as_str()) {
1403 bail!("framework package {name} does not resolve its declared revision");
1404 }
1405 dependency.git = Some(source.url().to_string());
1406 dependency.rev = Some(revision.clone());
1407 }
1408 Some(source) => bail!("unsupported framework package source for {name}: {source}"),
1409 }
1410 packages.insert(name.to_owned(), dependency);
1411 }
1412 Ok(packages)
1413}
1414
1415#[derive(Deserialize)]
1416struct SubmoduleEntry {
1417 sha: String,
1418 #[serde(rename = "type")]
1419 kind: String,
1420}
1421
1422fn complete_scaffold(
1427 scaffold: &mut BTreeMap<String, String>,
1428 submodules: &BTreeMap<String, String>,
1429 lock: &Lockfile,
1430) -> Result<()> {
1431 for &submodule in BACKEND_SUBMODULES {
1435 if declares_backend_revision(scaffold, submodule) {
1436 continue;
1437 }
1438 let commit = submodules
1439 .get(submodule)
1440 .ok_or_else(|| eyre!("framework records no {submodule} submodule pin"))?;
1441 validate_revision(commit)?;
1442 scaffold.insert(
1443 format!("{}-backend-revision", backend_name(submodule)),
1444 commit.clone(),
1445 );
1446 }
1447 if !scaffold.contains_key("apple-backend-version") {
1453 let commit = submodules
1454 .get("backends/apple")
1455 .ok_or_else(|| eyre!("framework records no Apple backend pin"))?;
1456 validate_revision(commit)?;
1457 scaffold.insert("apple-backend-revision".to_owned(), commit.clone());
1458 }
1459 for &name in FRAMEWORK_PACKAGES {
1460 let candidates: Vec<_> = lock
1461 .packages
1462 .iter()
1463 .filter(|package| package.name.as_str() == name)
1464 .collect();
1465 let version = match candidates.as_slice() {
1466 [package] => package.version.to_string(),
1467 [] => bail!("framework lock has no package named {name}"),
1468 _ => bail!("framework lock has multiple packages named {name}"),
1469 };
1470 scaffold.insert(format!("{name}-version"), version);
1471 }
1472 Ok(())
1473}
1474
1475async fn local_submodule_revision(root: &Path, path: &str) -> Result<String> {
1479 let treeish = format!("HEAD:{path}");
1480 let output = Command::new("git")
1481 .arg("-C")
1482 .arg(root)
1483 .args(["rev-parse", treeish.as_str()])
1484 .output()
1485 .await?;
1486 if !output.status.success() {
1487 bail!(
1488 "the WaterUI checkout at {} records no {path} submodule pin: {}",
1489 root.display(),
1490 String::from_utf8_lossy(&output.stderr).trim()
1491 );
1492 }
1493 let revision = std::str::from_utf8(&output.stdout)?.trim().to_owned();
1494 validate_revision(&revision)?;
1495 Ok(revision)
1496}
1497
1498async fn submodule_revision(slug: &str, revision: &str, path: &str) -> Result<String> {
1502 let bytes = fetch(&format!(
1503 "https://api.github.com/repos/{slug}/contents/{path}?ref={revision}"
1504 ))
1505 .await?;
1506 let entry: SubmoduleEntry = serde_json::from_slice(&bytes)?;
1507 if entry.kind != "submodule" {
1508 bail!(
1509 "{path} at {slug}@{revision} is a {}, not a submodule",
1510 entry.kind
1511 );
1512 }
1513 Ok(entry.sha)
1514}
1515
1516async fn submodule_pin(slug: &str, revision: &str, path: &str) -> Result<Option<String>> {
1520 let Some(bytes) = fetch_optional(&format!(
1521 "https://api.github.com/repos/{slug}/contents/{path}?ref={revision}"
1522 ))
1523 .await?
1524 else {
1525 return Ok(None);
1526 };
1527 let Ok(entry) = serde_json::from_slice::<SubmoduleEntry>(&bytes) else {
1530 return Ok(None);
1531 };
1532 Ok((entry.kind == "submodule").then_some(entry.sha))
1533}
1534
1535fn parse_gitmodules(contents: &str) -> BTreeMap<String, String> {
1539 let mut submodules = BTreeMap::new();
1540 let mut submodule = false;
1541 let mut path = None::<String>;
1542 let mut url = None::<String>;
1543 for line in contents.lines().map(str::trim) {
1544 if line.starts_with('[') {
1545 if submodule && let (Some(path), Some(url)) = (path.take(), url.take()) {
1546 submodules.insert(path, url);
1547 }
1548 submodule = line.starts_with("[submodule");
1549 continue;
1550 }
1551 if !submodule {
1552 continue;
1553 }
1554 if let Some((key, value)) = line.split_once('=') {
1555 match key.trim() {
1556 "path" => path = Some(value.trim().trim_matches('"').to_owned()),
1557 "url" => url = Some(value.trim().trim_matches('"').to_owned()),
1558 _ => {}
1559 }
1560 }
1561 }
1562 if submodule && let (Some(path), Some(url)) = (path, url) {
1563 submodules.insert(path, url);
1564 }
1565 submodules
1566}
1567
1568fn validate_revision(revision: &str) -> Result<()> {
1569 if revision.len() != 40 || !revision.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1570 bail!("framework revision must be a full Git commit hash");
1571 }
1572 Ok(())
1573}
1574
1575async fn fetch(url: &str) -> Result<Vec<u8>> {
1576 fetch_optional(url)
1577 .await?
1578 .ok_or_else(|| eyre!("framework resolution returned HTTP 404 from {url}"))
1579}
1580
1581async fn fetch_optional(url: &str) -> Result<Option<Vec<u8>>> {
1586 let mut client = zenwave::client();
1587 let response = match client
1588 .method(Method::GET, url)?
1589 .header("User-Agent", env!("CARGO_PKG_NAME"))?
1590 .await
1591 {
1592 Ok(response) => response,
1593 Err(zenwave::Error::Http { status, .. }) if status == StatusCode::NOT_FOUND => {
1594 return Ok(None);
1595 }
1596 Err(error) => return Err(error.into()),
1597 };
1598 Ok(Some(response.into_body().into_bytes().await?.to_vec()))
1599}
1600
1601#[cfg(test)]
1602pub(crate) mod test_fixtures {
1603 use std::process::Command as StdCommand;
1604
1605 use super::*;
1606
1607 pub fn stable_framework() -> ResolvedFramework {
1611 let revision = |seed: char| seed.to_string().repeat(40);
1612 let scaffold = FRAMEWORK_PACKAGES
1613 .iter()
1614 .map(|name| (format!("{name}-version"), "0.4.1".to_owned()))
1615 .chain([
1616 ("hydrolysis-version".to_owned(), "0.2.1".to_owned()),
1617 ("hydrolysis-m3-version".to_owned(), "0.2.0".to_owned()),
1618 ("waterui-dew-version".to_owned(), "0.2.1".to_owned()),
1619 ("waterui-gtk-version".to_owned(), "0.1.2".to_owned()),
1620 (
1621 "apple-backend-url".to_owned(),
1622 "https://github.com/water-rs/apple-backend.git".to_owned(),
1623 ),
1624 ("apple-backend-version".to_owned(), "0.3.0-dev.2".to_owned()),
1625 (
1626 "android-backend-url".to_owned(),
1627 "https://github.com/water-rs/android-backend.git".to_owned(),
1628 ),
1629 ("android-backend-revision".to_owned(), revision('c')),
1630 ])
1631 .collect();
1632 ResolvedFramework {
1633 source: Source::Stable {
1634 release: Some(FrameworkRelease {
1635 repository: framework_repository().to_owned(),
1636 revision: revision('a'),
1637 tag: "v0.4.1".to_owned(),
1638 }),
1639 },
1640 minimum_cli_version: None,
1641 metadata: toml::toml! {
1642 android-min-api-level = 26
1643 },
1644 scaffold,
1645 packages: BTreeMap::new(),
1646 patches: PatchSet::default(),
1647 }
1648 }
1649
1650 pub fn dev_framework() -> ResolvedFramework {
1655 let mut framework = stable_framework();
1656 framework.source = Source::Dev {
1657 repository: framework_repository().to_owned(),
1658 revision: 'a'.to_string().repeat(40),
1659 lock_sha256: 'f'.to_string().repeat(64),
1660 };
1661 framework.scaffold.insert(
1662 "apple-backend-revision".to_owned(),
1663 'd'.to_string().repeat(40),
1664 );
1665 framework
1666 }
1667
1668 pub fn nightly_framework(backend_revision: bool) -> ResolvedFramework {
1673 let mut framework = dev_framework();
1674 if !backend_revision {
1675 framework.scaffold.remove("apple-backend-revision");
1676 }
1677 framework.source = Source::Nightly {
1678 repository: framework_repository().to_owned(),
1679 revision: 'a'.to_string().repeat(40),
1680 tag: "nightly-2026.09.15".to_owned(),
1681 lock_sha256: 'f'.to_string().repeat(64),
1682 };
1683 framework
1684 }
1685
1686 pub fn write_local_checkout(root: &Path) {
1691 std::fs::create_dir_all(root).expect("checkout dir");
1692 std::fs::write(root.join("Cargo.toml"), local_checkout_manifest()).expect("manifest");
1693 let lock = test_lock();
1694 std::fs::write(root.join("Cargo.lock"), lock.to_string()).expect("lockfile");
1695 let git = |args: &[String]| {
1696 let status = StdCommand::new("git")
1697 .arg("-C")
1698 .arg(root)
1699 .args(args)
1700 .status()
1701 .expect("git must run");
1702 assert!(status.success(), "git {args:?} failed");
1703 };
1704 git(&["init".to_owned(), "-q".to_owned()]);
1705 git(&[
1706 "add".to_owned(),
1707 "Cargo.toml".to_owned(),
1708 "Cargo.lock".to_owned(),
1709 ]);
1710 git(&[
1711 "-c".to_owned(),
1712 "user.name=waterui-test".to_owned(),
1713 "-c".to_owned(),
1714 "user.email=waterui-test@waterui.dev".to_owned(),
1715 "commit".to_owned(),
1716 "-qm".to_owned(),
1717 "init".to_owned(),
1718 ]);
1719 }
1720
1721 pub fn write_pre_decoupling_checkout(root: &Path) {
1725 write_local_checkout(root);
1726 write_submodule_pin(root, "backends/apple", 'b');
1727 write_submodule_pin(root, "backends/android", 'c');
1728 let manifest = local_checkout_manifest()
1729 .lines()
1730 .filter(|line| {
1731 let line = line.trim_start();
1732 !(line.starts_with("apple-backend-version")
1733 || line.starts_with("android-backend-revision"))
1734 })
1735 .collect::<Vec<_>>()
1736 .join("\n");
1737 assert!(
1738 !manifest.contains("apple-backend-version")
1739 && !manifest.contains("android-backend-revision"),
1740 "the fixture manifest moved; the pre-decoupling rewrite must be revisited"
1741 );
1742 std::fs::write(root.join("Cargo.toml"), manifest).expect("manifest");
1743 let git = |args: &[&str]| {
1744 let status = StdCommand::new("git")
1745 .arg("-C")
1746 .arg(root)
1747 .args(args)
1748 .status()
1749 .expect("git must run");
1750 assert!(status.success(), "git {args:?} failed");
1751 };
1752 git(&["add", "Cargo.toml"]);
1753 git(&[
1754 "-c",
1755 "user.name=waterui-test",
1756 "-c",
1757 "user.email=waterui-test@waterui.dev",
1758 "commit",
1759 "-qm",
1760 "pre-decoupling manifest",
1761 ]);
1762 }
1763
1764 fn write_submodule_pin(root: &Path, path: &str, seed: char) {
1767 let status = StdCommand::new("git")
1768 .arg("-C")
1769 .arg(root)
1770 .args([
1771 "update-index",
1772 "--add",
1773 "--cacheinfo",
1774 &format!("160000,{},{}", seed.to_string().repeat(40), path),
1775 ])
1776 .status()
1777 .expect("git must run");
1778 assert!(status.success(), "git update-index failed");
1779 }
1780
1781 fn local_checkout_manifest() -> &'static str {
1784 include_str!("../../tests/fixtures/framework_checkout_manifest.toml")
1785 }
1786
1787 pub fn test_lock() -> Lockfile {
1789 Lockfile {
1790 packages: FRAMEWORK_PACKAGES
1791 .iter()
1792 .map(|name| package(name, "0.4.1", None))
1793 .collect(),
1794 version: cargo_lock::ResolveVersion::V4,
1795 root: None,
1796 metadata: BTreeMap::default(),
1797 patch: cargo_lock::Patch::default(),
1798 }
1799 }
1800
1801 pub fn package(name: &str, version: &str, source: Option<&str>) -> cargo_lock::Package {
1802 cargo_lock::Package {
1803 name: name.parse().unwrap(),
1804 version: version.parse().unwrap(),
1805 source: source.map(|source| source.parse().unwrap()),
1806 checksum: None,
1807 dependencies: Vec::new(),
1808 replace: None,
1809 }
1810 }
1811}
1812
1813async fn resolve_dev(repository: &str, slug: &str) -> Result<String> {
1814 gated_dev_head(repository, slug, "dev.yml", "framework").await
1815}
1816
1817async fn backend_dev_revision(url: &str) -> Result<String> {
1823 gated_dev_head(
1824 url,
1825 repository_slug(url.trim_end_matches(".git"))?,
1826 "ci.yml",
1827 "Apple backend",
1828 )
1829 .await
1830}
1831
1832async fn gated_dev_head(repository: &str, slug: &str, gate: &str, what: &str) -> Result<String> {
1836 let output = Command::new("git")
1837 .args(["ls-remote", repository, "refs/heads/dev"])
1838 .output()
1839 .await?;
1840 if !output.status.success() {
1841 bail!(
1842 "could not resolve {what} dev: {}",
1843 String::from_utf8_lossy(&output.stderr)
1844 );
1845 }
1846 let revision = std::str::from_utf8(&output.stdout)?
1847 .split_whitespace()
1848 .next()
1849 .ok_or_else(|| eyre!("{what} repository has no dev branch"))?
1850 .to_owned();
1851 validate_revision(&revision)?;
1852 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?;
1853 let runs: serde_json::Value = serde_json::from_slice(&response)?;
1854 let checked = runs["workflow_runs"].as_array().is_some_and(|runs| {
1855 runs.iter().any(|run| {
1856 run["head_sha"].as_str() == Some(&revision) && run["conclusion"] == "success"
1857 })
1858 });
1859 if !checked {
1860 bail!("{what} dev revision {revision} has not passed its compilation gate");
1861 }
1862 Ok(revision)
1863}
1864
1865async fn latest_certification(
1868 repository: &str,
1869 channel: FrameworkChannel,
1870) -> Result<Certification> {
1871 let slug = repository_slug(repository)?;
1872 let mut releases = Vec::new();
1873 let mut page = 1;
1874 loop {
1875 let bytes = fetch(&format!(
1876 "https://api.github.com/repos/{slug}/releases?per_page=100&page={page}"
1877 ))
1878 .await?;
1879 let batch: Vec<Release> = serde_json::from_slice(&bytes)?;
1880 let complete = batch.len() < 100;
1881 releases.extend(
1882 batch
1883 .into_iter()
1884 .filter(|release| release_matches(release, channel)),
1885 );
1886 if complete {
1887 break;
1888 }
1889 page += 1;
1890 }
1891 let release = newest_release(releases, channel)?;
1892 let asset = certification_asset(&release, channel)?;
1893 let certification = parse_certification(&fetch(&asset.browser_download_url).await?)?;
1894 verify_certification(&certification, Some(&release), repository)?;
1895 certifies_channel(&certification, channel)?;
1896 Ok(certification)
1897}
1898
1899fn certifies_channel(certification: &Certification, channel: FrameworkChannel) -> Result<()> {
1902 if certification.channel != channel {
1903 bail!(
1904 "{} certifies the {} channel, not {channel}",
1905 certification.tag,
1906 certification.channel
1907 );
1908 }
1909 Ok(())
1910}
1911
1912#[derive(Deserialize)]
1916struct CertificationSchema {
1917 schema_version: u32,
1918}
1919
1920const CERTIFICATION_SCHEMA_VERSION: u32 = 2;
1921
1922fn parse_certification(bytes: &[u8]) -> Result<Certification> {
1923 let schema: CertificationSchema = serde_json::from_slice(bytes)?;
1924 if schema.schema_version != CERTIFICATION_SCHEMA_VERSION {
1925 bail!(
1926 "framework manifest schema version {} is not supported; this CLI requires schema version {CERTIFICATION_SCHEMA_VERSION}",
1927 schema.schema_version
1928 );
1929 }
1930 Ok(serde_json::from_slice(bytes)?)
1931}
1932
1933fn release_matches(release: &Release, channel: FrameworkChannel) -> bool {
1935 match channel {
1936 FrameworkChannel::Nightly => {
1937 release.prerelease && !release.draft && release.tag_name.starts_with("nightly-")
1938 }
1939 FrameworkChannel::Stable => {
1940 !release.prerelease && !release.draft && is_stable_tag(&release.tag_name)
1941 }
1942 FrameworkChannel::Dev => false,
1943 }
1944}
1945
1946fn is_stable_tag(tag: &str) -> bool {
1949 tag.strip_prefix('v').is_some_and(|version| {
1950 version
1951 .parse::<cargo_toml::SemVer>()
1952 .is_ok_and(|version| version.pre.is_empty() && version.build.is_empty())
1953 })
1954}
1955
1956fn newest_release(releases: Vec<Release>, channel: FrameworkChannel) -> Result<Release> {
1960 let version = |release: &Release| -> Option<cargo_toml::SemVer> {
1961 match channel {
1962 FrameworkChannel::Stable => release.tag_name.strip_prefix('v')?.parse().ok(),
1963 FrameworkChannel::Nightly | FrameworkChannel::Dev => None,
1964 }
1965 };
1966 releases
1967 .into_iter()
1968 .max_by(|left, right| {
1969 version(left)
1970 .cmp(&version(right))
1971 .then_with(|| left.published_at.cmp(&right.published_at))
1972 })
1973 .ok_or_else(|| match channel {
1974 FrameworkChannel::Nightly => {
1975 eyre!("no certified nightly exists; select dev or stable explicitly")
1976 }
1977 FrameworkChannel::Stable => eyre!(
1978 "no stable framework release carries a manifest yet; \
1979 select dev or nightly explicitly"
1980 ),
1981 FrameworkChannel::Dev => unreachable!("dev releases are not certified"),
1982 })
1983}
1984
1985fn certification_asset(release: &Release, channel: FrameworkChannel) -> Result<&ReleaseAsset> {
1987 release
1988 .assets
1989 .iter()
1990 .find(|asset| asset.name == "framework.json")
1991 .ok_or_else(|| match channel {
1992 FrameworkChannel::Nightly => {
1993 eyre!("nightly {} has no certification manifest", release.tag_name)
1994 }
1995 FrameworkChannel::Stable => eyre!(
1996 "stable release {} carries no framework.json — it predates manifest publishing",
1997 release.tag_name
1998 ),
1999 FrameworkChannel::Dev => unreachable!("dev releases are not certified"),
2000 })
2001}
2002
2003async fn load_manifest(path: &Path, repository: &str) -> Result<Certification> {
2007 let contents = smol::fs::read(path)
2008 .await
2009 .wrap_err_with(|| format!("failed to read framework manifest {}", path.display()))?;
2010 let certification = parse_certification(&contents)
2011 .wrap_err_with(|| format!("invalid framework manifest {}", path.display()))?;
2012 verify_certification(&certification, None, repository)?;
2013 Ok(certification)
2014}
2015
2016fn verify_certification(
2021 certification: &Certification,
2022 release: Option<&Release>,
2023 repository: &str,
2024) -> Result<()> {
2025 let channel = certification.channel;
2026 if certification.schema_version != CERTIFICATION_SCHEMA_VERSION {
2027 bail!(
2028 "framework manifest schema version {} is not supported; this CLI requires schema version {CERTIFICATION_SCHEMA_VERSION}",
2029 certification.schema_version
2030 );
2031 }
2032 if channel == FrameworkChannel::Dev {
2033 bail!("framework manifest channel `dev` is not a certified distribution");
2034 }
2035 if certification.repository != repository_slug(repository)? {
2036 bail!(
2037 "{channel} manifest names a different repository ({})",
2038 certification.repository
2039 );
2040 }
2041 if let Some(release) = release
2042 && certification.tag != release.tag_name
2043 {
2044 bail!("{channel} certification does not match its release");
2045 }
2046 validate_revision(&certification.revision)?;
2047 if let Some(minimum) = &minimum_cli_version(&certification.metadata)? {
2048 let update = match channel {
2049 FrameworkChannel::Stable => registry_cli_update(minimum),
2050 FrameworkChannel::Dev | FrameworkChannel::Nightly => checkout_cli_update(),
2051 };
2052 validate_installed_cli(minimum, &update)?;
2053 }
2054 Ok(())
2055}
2056
2057struct SubmodulePin {
2061 repository: String,
2063 commit: String,
2065}
2066
2067fn rebase_patches_onto_source(
2083 mut patches: PatchSet,
2084 repository: &str,
2085 revision: &str,
2086 submodules: &BTreeMap<String, SubmodulePin>,
2087) -> PatchSet {
2088 patches.retain(|source, _| !same_git_source(source, repository));
2089 for dependencies in patches.values_mut() {
2090 for dependency in dependencies.values_mut() {
2091 let Dependency::Detailed(detail) = dependency else {
2092 continue;
2093 };
2094 let Some(path) = detail.path.take() else {
2095 continue;
2096 };
2097 let path = path.trim_start_matches("./");
2098 let pin = submodules.iter().find_map(|(root, pin)| {
2099 (path == root.as_str() || path.starts_with(&format!("{root}/"))).then_some(pin)
2100 });
2101 let (git, rev) = pin.map_or((repository, revision), |pin| {
2102 (pin.repository.as_str(), pin.commit.as_str())
2103 });
2104 detail.git = Some(git.to_owned());
2105 detail.rev = Some(rev.to_owned());
2106 }
2107 }
2108 patches
2109}
2110
2111fn canonical_git_url(url: &str) -> &str {
2114 let url = url.split(['?', '#']).next().unwrap_or_default();
2115 url.trim_end_matches('/')
2116 .trim_end_matches(".git")
2117 .trim_end_matches('/')
2118}
2119
2120fn same_git_source(source: &str, repository: &str) -> bool {
2124 canonical_git_url(source) == canonical_git_url(repository)
2125}
2126
2127#[cfg(test)]
2128mod tests {
2129 use test_fixtures::{
2130 package, stable_framework, test_lock, write_local_checkout, write_pre_decoupling_checkout,
2131 };
2132
2133 use super::*;
2134
2135 fn snapshot(lock: &Lockfile) -> (ResolvedFramework, Vec<u8>) {
2136 let bytes = lock.to_string().into_bytes();
2137 let scaffold = lock
2138 .packages
2139 .iter()
2140 .map(|package| {
2141 (
2142 format!("{}-version", package.name),
2143 package.version.to_string(),
2144 )
2145 })
2146 .collect();
2147 let repository = framework_repository();
2148 let revision = "a".repeat(40);
2149 let framework = ResolvedFramework {
2150 source: Source::Nightly {
2151 repository: repository.to_owned(),
2152 revision: revision.clone(),
2153 tag: "nightly-test".into(),
2154 lock_sha256: hex::encode(Sha256::digest(&bytes)),
2155 },
2156 minimum_cli_version: None,
2157 metadata: toml::toml! {
2158 android-min-api-level = 26
2159 },
2160 packages: resolve_packages(&scaffold, lock, repository, &revision).unwrap(),
2161 scaffold,
2162 patches: PatchSet::default(),
2163 };
2164 (framework, bytes)
2165 }
2166
2167 #[test]
2168 fn cli_requirement_uses_semver_precedence() {
2169 for (minimum, current, compatible) in [
2170 ("0.1.4", "0.1.3", false),
2171 ("0.1.4", "0.1.4", true),
2172 ("0.1.9", "0.1.10", true),
2173 ("0.1.4", "0.1.4-rc.1", false),
2174 ("0.1.4-rc.1", "0.1.4-rc.2", true),
2175 ("0.1.4+z", "0.1.4+a", true),
2176 ("0.1.4", "1.0.0", true),
2177 ] {
2178 let minimum = minimum.parse().unwrap();
2179 let current = current.parse().unwrap();
2180 let update = registry_cli_update(&minimum);
2181 let result = validate_cli_version(&minimum, ¤t, &update);
2182 assert_eq!(result.is_ok(), compatible, "{current} against {minimum}");
2183 if let Err(error) = result {
2184 let message = error.to_string();
2185 assert!(message.contains(&minimum.to_string()));
2186 assert!(message.contains(¤t.to_string()));
2187 assert!(message.contains(&update));
2188 assert!(message.contains("water --version"));
2189 }
2190 }
2191 }
2192
2193 #[test]
2194 fn cli_requirement_metadata_rejects_invalid_versions() {
2195 let mut metadata = toml::toml! {
2196 minimum-cli-version = "0.1.4"
2197 };
2198 assert_eq!(
2199 minimum_cli_version(&metadata).unwrap(),
2200 Some("0.1.4".parse().unwrap())
2201 );
2202 metadata["minimum-cli-version"] = toml::Value::String(">=0.1.4".into());
2203 assert!(minimum_cli_version(&metadata).is_err());
2204 assert!(minimum_cli_version(&toml::Table::new()).unwrap().is_none());
2205 }
2206
2207 #[test]
2208 fn android_min_api_level_is_required_framework_metadata() {
2209 assert_eq!(stable_framework().android_min_api_level().unwrap(), 26);
2210
2211 let mut missing = stable_framework();
2212 missing.metadata.remove("android-min-api-level");
2213 let error = missing.android_min_api_level().unwrap_err().to_string();
2214 assert!(error.contains("android-min-api-level"), "{error}");
2215 assert!(error.contains("v0.4.1"), "{error}");
2216
2217 let mut invalid = stable_framework();
2218 invalid.metadata["android-min-api-level"] = toml::Value::String("26".to_owned());
2219 let error = invalid.android_min_api_level().unwrap_err().to_string();
2220 assert!(error.contains("android-min-api-level"), "{error}");
2221 }
2222
2223 #[test]
2224 fn persisted_cli_requirement_blocks_an_older_cli_with_update_guidance() {
2225 let mut minimum: cargo_toml::SemVer = env!("CARGO_PKG_VERSION").parse().unwrap();
2226 minimum.major += 1;
2227 let mut framework = stable_framework();
2228 framework.minimum_cli_version = Some(minimum.clone());
2229 let contents = toml::to_string(&framework).unwrap();
2230 let framework: ResolvedFramework = toml::from_str(&contents).unwrap();
2231 let error = framework.validate_cli().unwrap_err().to_string();
2232 assert!(error.contains(®istry_cli_update(&minimum)));
2233 assert_eq!(framework.minimum_cli_version, Some(minimum));
2234 }
2235
2236 #[test]
2237 fn snapshot_preserves_independent_package_sources() {
2238 let backend_revision = "b".repeat(40);
2239 let backend_source =
2240 format!("git+https://example.com/hydrolysis?rev={backend_revision}#{backend_revision}");
2241 let lock = Lockfile {
2242 packages: vec![
2243 package("waterui", "0.3.0", None),
2244 package("hydrolysis", "0.1.0", Some(&backend_source)),
2245 package(
2246 "hydrolysis-m3",
2247 "0.1.0",
2248 Some("registry+https://github.com/rust-lang/crates.io-index"),
2249 ),
2250 ],
2251 version: cargo_lock::ResolveVersion::V4,
2252 root: None,
2253 metadata: BTreeMap::default(),
2254 patch: cargo_lock::Patch::default(),
2255 };
2256 let (framework, _) = snapshot(&lock);
2257 let persisted = toml::to_string(&framework).unwrap();
2258 let framework: ResolvedFramework = toml::from_str(&persisted).unwrap();
2259 assert_eq!(framework.channel(), Some(FrameworkChannel::Nightly));
2260 assert_eq!(framework.dependency("waterui").rev, Some("a".repeat(40)));
2261 let backend = framework.dependency("hydrolysis");
2262 assert_eq!(
2263 backend.git.as_deref(),
2264 Some("https://example.com/hydrolysis")
2265 );
2266 assert_eq!(backend.rev, Some(backend_revision));
2267 let theme = framework.dependency("hydrolysis-m3");
2268 assert!(theme.git.is_none());
2269 assert_eq!(theme.version.unwrap().to_string(), "=0.1.0");
2270 }
2271
2272 #[test]
2273 fn extracted_crate_absent_from_the_lock_resolves_to_its_declared_requirement() {
2274 let lock = Lockfile {
2278 packages: vec![package("waterui", "0.3.0", None)],
2279 version: cargo_lock::ResolveVersion::V4,
2280 root: None,
2281 metadata: BTreeMap::default(),
2282 patch: cargo_lock::Patch::default(),
2283 };
2284 let scaffold = BTreeMap::from([
2285 ("waterui-version".to_string(), "0.3.0".to_string()),
2286 ("waterui-dew-version".to_string(), "0.2.1".to_string()),
2287 ("waterui-gtk-version".to_string(), "0.1.2".to_string()),
2288 ]);
2289 let packages =
2290 resolve_packages(&scaffold, &lock, framework_repository(), &"a".repeat(40)).unwrap();
2291 assert!(packages["waterui"].git.is_some());
2292 let dew = &packages["waterui-dew"];
2293 assert!(dew.git.is_none());
2294 assert_eq!(dew.version.as_ref().unwrap().to_string(), "=0.2.1");
2295 let gtk = &packages["waterui-gtk"];
2296 assert!(gtk.git.is_none());
2297 assert_eq!(gtk.version.as_ref().unwrap().to_string(), "=0.1.2");
2298 }
2299
2300 fn scaffolded(field: &str) -> String {
2306 let version = &stable_framework().scaffold[field];
2307 format!("={version}")
2308 }
2309
2310 #[test]
2311 fn channel_update_preserves_aliases_features_and_unrelated_dependencies() {
2312 let manifest = toml::toml! {
2313 [dependencies.ui]
2314 package = "waterui"
2315 path = "../waterui"
2316 default-features = false
2317 features = ["gpu"]
2318 [dependencies.serde]
2319 version = "1"
2320 features = ["derive"]
2321 [target."cfg(unix)".build-dependencies]
2322 waterui-core = "0.2"
2323 };
2324 let mut document = toml_edit::ser::to_document(&manifest).unwrap();
2325 stable_framework()
2326 .update_manifest(&mut document, &PatchSet::default())
2327 .unwrap();
2328 assert_eq!(
2329 document["dependencies"]["ui"]["package"].as_str(),
2330 Some("waterui")
2331 );
2332 assert!(document["dependencies"]["ui"].get("path").is_none());
2333 assert_eq!(
2334 document["dependencies"]["ui"]["version"].as_str(),
2335 Some(scaffolded("waterui-version").as_str())
2336 );
2337 assert_eq!(
2338 document["dependencies"]["ui"]["default-features"].as_bool(),
2339 Some(false)
2340 );
2341 assert_eq!(
2342 document["dependencies"]["ui"]["features"][0].as_str(),
2343 Some("gpu")
2344 );
2345 let updated: toml::Value = toml::from_str(&document.to_string()).unwrap();
2346 assert_eq!(
2347 updated["dependencies"]["serde"],
2348 manifest["dependencies"]["serde"]
2349 );
2350 assert_eq!(
2351 updated["target"]["cfg(unix)"]["build-dependencies"]["waterui-core"]["version"]
2352 .as_str(),
2353 Some(scaffolded("waterui-core-version").as_str())
2354 );
2355 }
2356
2357 #[test]
2358 fn channel_update_writes_patches_as_tables_and_clears_stale_ones() {
2359 let mut document: toml_edit::DocumentMut =
2360 "[package]\nname = \"app\"\n\n[dependencies]\nwaterui = \"0.3.0\"\n"
2361 .parse()
2362 .unwrap();
2363 let (dev, _) = snapshot(&Lockfile {
2364 packages: vec![package("waterui", "0.3.0", None)],
2365 version: cargo_lock::ResolveVersion::V4,
2366 root: None,
2367 metadata: BTreeMap::default(),
2368 patch: cargo_lock::Patch::default(),
2369 });
2370 let mut dev = dev;
2371 let vello: Dependency = toml::from_str::<toml::Value>(
2372 r#"git = "https://github.com/lexoliu/vello"
2373rev = "d68d9e9825bcd1ffee762323881c13a2e7a3f639""#,
2374 )
2375 .unwrap()
2376 .try_into()
2377 .unwrap();
2378 dev.patches
2379 .entry("crates-io".into())
2380 .or_default()
2381 .insert("vello".into(), vello);
2382 dev.update_manifest(&mut document, &PatchSet::default())
2383 .unwrap();
2384 let rendered = document.to_string();
2385 assert!(rendered.starts_with("[package]"), "{rendered}");
2386 assert!(rendered.contains("[patch.crates-io]\n"), "{rendered}");
2387 assert!(!rendered.contains("\n[patch]\n"), "{rendered}");
2388 assert_eq!(
2389 document["patch"]["crates-io"]["vello"]["rev"].as_str(),
2390 Some("d68d9e9825bcd1ffee762323881c13a2e7a3f639")
2391 );
2392 assert_eq!(
2393 document["dependencies"]["waterui"]["rev"].as_str(),
2394 Some("a".repeat(40).as_str())
2395 );
2396
2397 stable_framework()
2398 .update_manifest(&mut document, &dev.patches())
2399 .unwrap();
2400 let rendered = document.to_string();
2401 assert!(!rendered.contains("patch"), "{rendered}");
2402 assert_eq!(
2403 document["dependencies"]["waterui"]["version"].as_str(),
2404 Some(scaffolded("waterui-version").as_str())
2405 );
2406 }
2407
2408 #[test]
2409 fn snapshot_lock_preserves_external_sources_and_rewrites_local_edges() {
2410 let core = package("waterui-core", "0.3.0", None);
2411 let mut facade = package("waterui", "0.3.0", None);
2412 facade.dependencies.push(LockedDependency::from(&core));
2413 let theme = package(
2414 "hydrolysis-m3",
2415 "0.1.0",
2416 Some("registry+https://github.com/rust-lang/crates.io-index"),
2417 );
2418 let lock = Lockfile {
2419 packages: vec![facade, core, theme.clone()],
2420 version: cargo_lock::ResolveVersion::V4,
2421 root: None,
2422 metadata: BTreeMap::default(),
2423 patch: cargo_lock::Patch::default(),
2424 };
2425 let (framework, bytes) = snapshot(&lock);
2426 let resolved = framework.cargo_lock(&bytes).unwrap();
2427 let facade = resolved
2428 .packages
2429 .iter()
2430 .find(|package| package.name.as_str() == "waterui")
2431 .unwrap();
2432 let core = resolved
2433 .packages
2434 .iter()
2435 .find(|package| package.name.as_str() == "waterui-core")
2436 .unwrap();
2437 assert_eq!(facade.dependencies, vec![LockedDependency::from(core)]);
2438 assert!(core.source.as_ref().unwrap().is_git());
2439 assert!(resolved.packages.contains(&theme));
2440 let mut changed = bytes;
2441 changed.push(b'\n');
2442 assert!(
2443 framework
2444 .cargo_lock(&changed)
2445 .unwrap_err()
2446 .to_string()
2447 .contains("does not match")
2448 );
2449 }
2450
2451 #[test]
2452 fn rebase_patches_onto_source_redirects_git_source_dependencies() {
2453 let mut patches = PatchSet::default();
2454 let mut crates_io = std::collections::BTreeMap::new();
2455 crates_io.insert(
2456 "waterui-core".to_string(),
2457 Dependency::Detailed(Box::new(DependencyDetail {
2458 path: Some("core".to_string()),
2459 ..DependencyDetail::default()
2460 })),
2461 );
2462 crates_io.insert(
2463 "waterkit-audio".to_string(),
2464 Dependency::Detailed(Box::new(DependencyDetail {
2465 path: Some("kit/multimedia/audio".to_string()),
2466 ..DependencyDetail::default()
2467 })),
2468 );
2469 crates_io.insert(
2470 "vello".to_string(),
2471 Dependency::Detailed(Box::new(DependencyDetail {
2472 git: Some("https://github.com/lexoliu/vello".to_string()),
2473 rev: Some("5e5f538556be16527f67379b105af82f408b747d".to_string()),
2474 ..DependencyDetail::default()
2475 })),
2476 );
2477 patches.insert("crates-io".to_string(), crates_io);
2478 patches.insert(
2482 "https://github.com/water-rs/waterui".to_string(),
2483 std::collections::BTreeMap::new(),
2484 );
2485 patches.insert(
2486 "https://github.com/water-rs/waterui.git?branch=dev".to_string(),
2487 std::collections::BTreeMap::new(),
2488 );
2489
2490 let submodules = BTreeMap::from([(
2491 "kit".to_string(),
2492 SubmodulePin {
2493 repository: "https://github.com/water-rs/waterkit".to_string(),
2494 commit: "98c89ee702c5629094030023fb8d55464592d35d".to_string(),
2495 },
2496 )]);
2497 let rebased = rebase_patches_onto_source(
2498 patches,
2499 "https://github.com/water-rs/waterui",
2500 "475b4bb884a5f4e2b1156f1af74c40feaf71fdc1",
2501 &submodules,
2502 );
2503
2504 let Dependency::Detailed(core) = &rebased["crates-io"]["waterui-core"] else {
2506 panic!("a path patch stays a detailed dependency");
2507 };
2508 assert!(core.path.is_none());
2509 assert_eq!(
2510 core.git.as_deref(),
2511 Some("https://github.com/water-rs/waterui")
2512 );
2513 assert_eq!(
2514 core.rev.as_deref(),
2515 Some("475b4bb884a5f4e2b1156f1af74c40feaf71fdc1")
2516 );
2517
2518 let Dependency::Detailed(audio) = &rebased["crates-io"]["waterkit-audio"] else {
2521 panic!("a submodule path patch stays a detailed dependency");
2522 };
2523 assert_eq!(
2524 audio.git.as_deref(),
2525 Some("https://github.com/water-rs/waterkit")
2526 );
2527 assert_eq!(
2528 audio.rev.as_deref(),
2529 Some("98c89ee702c5629094030023fb8d55464592d35d")
2530 );
2531
2532 let Dependency::Detailed(vello) = &rebased["crates-io"]["vello"] else {
2536 panic!("a git patch stays a detailed dependency");
2537 };
2538 assert_eq!(
2539 vello.git.as_deref(),
2540 Some("https://github.com/lexoliu/vello")
2541 );
2542 assert!(!rebased.contains_key("https://github.com/water-rs/waterui"));
2543 assert!(!rebased.contains_key("https://github.com/water-rs/waterui.git?branch=dev"));
2544 }
2545
2546 #[test]
2547 fn coherence_admits_a_submodule_crate_at_either_source() {
2548 let revision = "a".repeat(40);
2549 let pin = "98c89ee702c5629094030023fb8d55464592d35d";
2550 let framework_source = format!("git+{}?rev={revision}#{revision}", framework_repository());
2551 let pin_source = format!("git+https://github.com/water-rs/waterkit?rev={pin}#{pin}");
2552 let (mut framework, _) = snapshot(&test_lock());
2553 framework
2554 .patches
2555 .entry("crates-io".into())
2556 .or_default()
2557 .insert(
2558 "waterkit-codec".into(),
2559 Dependency::Detailed(Box::new(DependencyDetail {
2560 git: Some("https://github.com/water-rs/waterkit".into()),
2561 rev: Some(pin.into()),
2562 ..DependencyDetail::default()
2563 })),
2564 );
2565 framework
2566 .patches
2567 .entry("crates-io".into())
2568 .or_default()
2569 .insert(
2570 "waterkit-fs".into(),
2571 Dependency::Detailed(Box::new(DependencyDetail {
2572 git: Some("https://github.com/water-rs/waterkit".into()),
2573 rev: Some(pin.into()),
2574 ..DependencyDetail::default()
2575 })),
2576 );
2577 let packages = vec![
2578 package("waterkit-codec", "0.1.1", Some(&framework_source)),
2581 package("waterkit-fs", "0.1.1", Some(&pin_source)),
2583 package(
2584 "serde",
2585 "1.0.0",
2586 Some("registry+https://github.com/rust-lang/crates.io-index"),
2587 ),
2588 ];
2589 let allowed = framework.allowed_packages(&packages);
2590 let identity = |name: &str, source: &str| LockedPackage {
2591 name: name.to_owned(),
2592 version: "0.1.1".to_owned(),
2593 source: Some(source.to_owned()),
2594 };
2595 assert!(allowed.contains(&identity("waterkit-codec", &framework_source)));
2596 assert!(allowed.contains(&identity("waterkit-codec", &pin_source)));
2597 assert!(allowed.contains(&identity("waterkit-fs", &pin_source)));
2598 assert!(allowed.contains(&identity("waterkit-fs", &framework_source)));
2599 assert_eq!(
2601 allowed
2602 .iter()
2603 .filter(|package| package.name == "serde")
2604 .count(),
2605 1
2606 );
2607 }
2608
2609 #[test]
2610 fn parse_gitmodules_reads_submodule_paths_and_urls() {
2611 let submodules = parse_gitmodules(
2612 "[submodule \"backends/android\"]\n\
2613 \tpath = backends/android\n\
2614 \turl = https://github.com/water-rs/android-backend.git\n\
2615 \tbranch = dev\n\
2616 [submodule \"kit\"]\n\
2617 \tpath = kit\n\
2618 \turl = \"https://github.com/water-rs/waterkit.git\"\n",
2619 );
2620 assert_eq!(
2621 submodules,
2622 BTreeMap::from([
2623 (
2624 "backends/android".to_string(),
2625 "https://github.com/water-rs/android-backend.git".to_string(),
2626 ),
2627 (
2628 "kit".to_string(),
2629 "https://github.com/water-rs/waterkit.git".to_string(),
2630 ),
2631 ])
2632 );
2633 }
2634
2635 fn release(tag: &str, draft: bool, prerelease: bool, published_at: &str) -> Release {
2636 Release {
2637 tag_name: tag.to_owned(),
2638 draft,
2639 prerelease,
2640 published_at: Some(published_at.to_owned()),
2641 assets: vec![ReleaseAsset {
2642 name: "framework.json".to_owned(),
2643 browser_download_url: format!(
2644 "https://github.com/water-rs/waterui/releases/download/{tag}/framework.json"
2645 ),
2646 }],
2647 }
2648 }
2649
2650 #[test]
2651 fn stable_release_selection_takes_the_newest_stable_tag() {
2652 let releases = vec![
2653 release("v0.4.0", false, false, "2025-10-01T00:00:00Z"),
2654 release("v0.5.0-rc.1", true, false, "2025-12-01T00:00:00Z"),
2657 release("nightly-2025-12-01", false, true, "2025-12-02T00:00:00Z"),
2658 release("v0.4.1", false, false, "2025-11-01T00:00:00Z"),
2659 release("v0.9.9", true, false, "2025-12-03T00:00:00Z"),
2660 release("v0.6.0+build.5", false, false, "2025-12-04T00:00:00Z"),
2662 release("v0.3.9", false, false, "2025-12-05T00:00:00Z"),
2665 ];
2666 let eligible: Vec<_> = releases
2667 .into_iter()
2668 .filter(|release| release_matches(release, FrameworkChannel::Stable))
2669 .collect();
2670 let release = newest_release(eligible, FrameworkChannel::Stable).unwrap();
2671 assert_eq!(release.tag_name, "v0.4.1");
2672 assert_eq!(
2673 certification_asset(&release, FrameworkChannel::Stable)
2674 .unwrap()
2675 .name,
2676 "framework.json"
2677 );
2678 }
2679
2680 #[test]
2681 fn stable_release_without_a_manifest_reports_it_predates_publishing() {
2682 let mut latest = release("v0.4.1", false, false, "2025-11-01T00:00:00Z");
2683 latest.assets.clear();
2684 let releases = vec![
2685 release("v0.4.0", false, false, "2025-10-01T00:00:00Z"),
2686 latest,
2687 ];
2688 let eligible: Vec<_> = releases
2689 .into_iter()
2690 .filter(|release| release_matches(release, FrameworkChannel::Stable))
2691 .collect();
2692 let release = newest_release(eligible, FrameworkChannel::Stable).unwrap();
2693 let error = certification_asset(&release, FrameworkChannel::Stable)
2694 .unwrap_err()
2695 .to_string();
2696 assert!(error.contains("v0.4.1"), "{error}");
2697 assert!(error.contains("predates manifest publishing"), "{error}");
2698 }
2699
2700 fn certification(channel: FrameworkChannel, tag: &str) -> Certification {
2701 Certification {
2702 schema_version: 2,
2703 channel,
2704 repository: "water-rs/waterui".to_owned(),
2705 revision: "a".repeat(40),
2706 tag: tag.to_owned(),
2707 lockfiles: BTreeMap::from([("Cargo.lock".to_owned(), "f".repeat(64))]),
2708 submodules: BTreeMap::new(),
2709 scaffold: BTreeMap::new(),
2710 metadata: toml::toml! {
2711 android-min-api-level = 26
2712 },
2713 }
2714 }
2715
2716 #[test]
2717 fn certification_verification_rejects_uncertified_or_mismatched_manifests() {
2718 let repository = framework_repository();
2719 let release = release("v0.4.1", false, false, "2025-11-01T00:00:00Z");
2720
2721 let dev = certification(FrameworkChannel::Dev, "dev");
2722 assert!(
2723 verify_certification(&dev, None, repository)
2724 .unwrap_err()
2725 .to_string()
2726 .contains("dev")
2727 );
2728
2729 let mut wrong_schema = certification(FrameworkChannel::Stable, "v0.4.1");
2730 wrong_schema.schema_version = 1;
2731 assert!(
2732 verify_certification(&wrong_schema, None, repository)
2733 .unwrap_err()
2734 .to_string()
2735 .contains("schema")
2736 );
2737 let nightly_on_a_stable_tag = certification(FrameworkChannel::Nightly, "v0.4.1");
2738 let error = certifies_channel(&nightly_on_a_stable_tag, FrameworkChannel::Stable)
2739 .unwrap_err()
2740 .to_string();
2741 assert!(error.contains("certifies the nightly channel"), "{error}");
2742 certifies_channel(&nightly_on_a_stable_tag, FrameworkChannel::Nightly).unwrap();
2743
2744 let error = parse_certification(
2747 br#"{"schema_version": 1, "channel": "nightly", "repository": "water-rs/waterui"}"#,
2748 )
2749 .err()
2750 .expect("a schema-1 manifest is rejected")
2751 .to_string();
2752 assert!(
2753 error.contains("schema version 1 is not supported"),
2754 "{error}"
2755 );
2756
2757 let mut wrong_repository = certification(FrameworkChannel::Stable, "v0.4.1");
2758 wrong_repository.repository = "water-rs/android-backend".to_owned();
2759 assert!(
2760 verify_certification(&wrong_repository, None, repository)
2761 .unwrap_err()
2762 .to_string()
2763 .contains("water-rs/android-backend")
2764 );
2765
2766 let wrong_tag = certification(FrameworkChannel::Stable, "v0.4.0");
2767 assert!(
2768 verify_certification(&wrong_tag, Some(&release), repository)
2769 .unwrap_err()
2770 .to_string()
2771 .contains("does not match its release")
2772 );
2773
2774 verify_certification(&wrong_tag, None, repository).unwrap();
2776
2777 let stable = certification(FrameworkChannel::Stable, "v0.4.1");
2778 verify_certification(&stable, Some(&release), repository).unwrap();
2779 }
2780
2781 #[test]
2782 fn manifest_loading_verifies_a_certification_from_disk() {
2783 let directory = tempfile::tempdir().unwrap();
2784 let path = directory.path().join("framework.json");
2785 let manifest = serde_json::json!({
2786 "schema_version": 2,
2787 "channel": "stable",
2788 "repository": "water-rs/waterui",
2789 "revision": "a".repeat(40),
2790 "tag": "v0.4.1",
2791 "lockfiles": {"Cargo.lock": "f".repeat(64)},
2792 "submodules": {
2793 "backends/apple": "b".repeat(40),
2794 "backends/android": "c".repeat(40),
2795 },
2796 "scaffold": {
2797 "hydrolysis-version": "0.2.1",
2798 "hydrolysis-m3-version": "0.2.0",
2799 "waterui-dew-version": "0.2.1",
2800 "waterui-gtk-version": "0.1.2",
2801 "apple-backend-url": "https://github.com/water-rs/apple-backend.git",
2802 "android-backend-url": "https://github.com/water-rs/android-backend.git",
2803 },
2804 "metadata": {
2805 "minimum-cli-version": "0.1.0",
2806 "android-min-api-level": 26,
2807 },
2808 });
2809 std::fs::write(&path, serde_json::to_vec(&manifest).unwrap()).unwrap();
2810 let repository = framework_repository();
2811 let certification = smol::block_on(load_manifest(&path, repository)).unwrap();
2812 assert_eq!(certification.channel, FrameworkChannel::Stable);
2813 assert_eq!(certification.tag, "v0.4.1");
2814
2815 std::fs::write(&path, b"not json").unwrap();
2816 assert!(smol::block_on(load_manifest(&path, repository)).is_err());
2817 }
2818
2819 #[test]
2825 fn framework_scaffold_derives_from_the_framework_manifest() {
2826 let root: toml::Value = toml::from_str(include_str!(
2827 "../../tests/fixtures/framework_checkout_manifest.toml"
2828 ))
2829 .unwrap();
2830 let scaffold = framework_scaffold(&root).unwrap();
2831 let workspace = |name: &str| {
2832 let dependency = &root["workspace"]["dependencies"][name];
2833 dependency
2834 .as_str()
2835 .or_else(|| dependency.get("version").and_then(toml::Value::as_str))
2836 .unwrap()
2837 .to_owned()
2838 };
2839 assert_eq!(
2840 scaffold,
2841 BTreeMap::from([
2842 ("hydrolysis-version".to_owned(), workspace("hydrolysis")),
2843 (
2844 "hydrolysis-m3-version".to_owned(),
2845 workspace("hydrolysis-m3")
2846 ),
2847 ("waterui-dew-version".to_owned(), workspace("waterui-dew")),
2848 ("waterui-gtk-version".to_owned(), workspace("waterui-gtk")),
2849 (
2850 "apple-backend-url".to_owned(),
2851 "https://github.com/water-rs/apple-backend.git".to_owned()
2852 ),
2853 ("apple-backend-version".to_owned(), "0.3.0-dev.2".to_owned()),
2854 (
2855 "android-backend-url".to_owned(),
2856 "https://github.com/water-rs/android-backend.git".to_owned()
2857 ),
2858 ("android-backend-revision".to_owned(), "c".repeat(40)),
2859 ])
2860 );
2861 }
2862
2863 #[test]
2864 fn framework_scaffold_rejects_a_backend_revision_that_is_not_a_commit() {
2865 let mut root: toml::Value = toml::from_str(include_str!(
2866 "../../tests/fixtures/framework_checkout_manifest.toml"
2867 ))
2868 .unwrap();
2869 root["package"]["metadata"]["waterui"]["android-backend-revision"] =
2870 toml::Value::String("dev".to_owned());
2871 let error = framework_scaffold(&root).unwrap_err();
2872 assert!(
2873 error.to_string().contains("android-backend-revision"),
2874 "{error:?}"
2875 );
2876 }
2877
2878 #[test]
2879 fn local_checkout_resolves_from_its_own_manifest() {
2880 let directory = tempfile::tempdir().unwrap();
2881 let root = directory.path().join("waterui");
2882 write_local_checkout(&root);
2883 let framework = smol::block_on(ResolvedFramework::for_local_checkout(&root)).unwrap();
2884 assert_eq!(framework.channel(), None);
2885 assert_eq!(framework.scaffold_value("hydrolysis-version"), "0.2.1");
2886 assert_eq!(
2887 framework.scaffold_value("apple-backend-version"),
2888 "0.3.0-dev.2"
2889 );
2890 assert_eq!(
2891 framework.scaffold_value("android-backend-revision"),
2892 "c".repeat(40)
2893 );
2894 assert_eq!(framework.scaffold_value("waterui-version"), "0.4.1");
2895 assert!(framework.git_source().is_none());
2896 }
2897
2898 #[test]
2902 fn local_checkout_predating_the_gitlink_removals_uses_its_pins() {
2903 let directory = tempfile::tempdir().unwrap();
2904 let root = directory.path().join("waterui");
2905 write_pre_decoupling_checkout(&root);
2906
2907 let framework = smol::block_on(ResolvedFramework::for_local_checkout(&root)).unwrap();
2908 assert_eq!(
2909 framework.scaffold_value("apple-backend-revision"),
2910 "b".repeat(40)
2911 );
2912 assert_eq!(
2913 framework.scaffold_value("android-backend-revision"),
2914 "c".repeat(40)
2915 );
2916 }
2917}