Skip to main content

waterui_cli/project_model/
framework.rs

1//! Framework channel resolution and persisted dependency selection.
2
3use 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/// A framework distribution channel, independent of the Rust toolchain.
20#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "lowercase")]
22pub enum FrameworkChannel {
23    /// The integration branch, resolved to an exact compilation-checked
24    /// commit; the Apple backend's `dev` HEAD resolves the same way.
25    Dev,
26    /// An immutable revision certified by the complete nightly suite,
27    /// including the backend pin the suite's certification records.
28    Nightly,
29    /// Published packages and the compatible native backends bundled with the CLI.
30    #[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/// The GitHub release a certified channel resolved from — the provenance the
58/// persisted selection keeps.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60struct FrameworkRelease {
61    /// The repository the release lives in.
62    repository: String,
63    /// The commit the release certifies.
64    revision: String,
65    /// The release tag the manifest rode in on.
66    tag: String,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70#[serde(tag = "channel", rename_all = "lowercase")]
71enum Source {
72    /// A published framework release whose `framework.json` resolved every
73    /// scaffold pin.
74    Stable {
75        /// The release the selection resolved from — absent in manifests
76        /// written before the stable channel carried a manifest.
77        #[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    /// A local checkout reached through `waterui_path`: a filesystem source,
92    /// not a channel. Never persisted — `waterui_path` itself is the record.
93    #[serde(skip)]
94    Local { root: PathBuf },
95}
96
97/// The persisted framework and backend selection used without channel re-resolution.
98#[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    /// The framework's own `rust-version` — `[workspace.package].rust-version`
109    /// of the manifest at the selected revision — persisted with the
110    /// selection so the project's Rust floor is known without a checkout.
111    #[serde(
112        default,
113        rename = "rust-version",
114        skip_serializing_if = "Option::is_none"
115    )]
116    rust_version: Option<cargo_toml::SemVer>,
117    /// The framework's `[package.metadata.waterui]` table at the selected
118    /// revision, carried verbatim from its manifest.
119    #[serde(default, skip_serializing_if = "toml::Table::is_empty")]
120    metadata: toml::Table,
121    scaffold: BTreeMap<String, String>,
122    /// The scaffold packages the selected channel withholds: a git-pinned
123    /// requirement has no registry release `stable` can resolve, so a stable
124    /// manifest omits its `scaffold` entries and records the pin under
125    /// `experimental-packages` instead. Empty on `dev`/`nightly` and on a
126    /// local checkout — they distribute every scaffold package.
127    #[serde(
128        default,
129        rename = "experimental-packages",
130        skip_serializing_if = "BTreeMap::is_empty"
131    )]
132    experimental_packages: BTreeMap<String, ExperimentalPackage>,
133    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
134    packages: BTreeMap<String, DependencyDetail>,
135    #[serde(default, skip_serializing_if = "PatchSet::is_empty")]
136    patches: PatchSet,
137}
138
139/// The selection in one line, as `water create` reports it: the channel and
140/// what it resolved to — the release tag for stable and nightly, the commit
141/// for dev — with the short revision after it.
142impl 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/// A scaffold package a channel withholds.
165///
166/// Its workspace requirement pins a git revision because the package has no
167/// registry release, so the manifest records the pin — git URL, commit and
168/// declared version — by name instead of emitting `scaffold` entries for it.
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170pub struct ExperimentalPackage {
171    /// The repository the framework pins the package to.
172    pub git: String,
173    /// The pinned commit.
174    pub rev: String,
175    /// The declared version requirement.
176    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
186/// Whether a resolved package took the place of one the lock pins.
187///
188/// The managed crate's graph is a superset of the project's: `waterui-ffi`
189/// and its feature-gated platform dependencies add packages the project
190/// never locked, and among them a second major of a name the project already
191/// carries (`annotate-snippets` 0.11 beside the locked 0.12, through
192/// bindgen). Cargo keeps both, so that is an addition, not a change. A change
193/// is a resolved version that Cargo could only have reached by moving a
194/// locked one — a version the locked entry's caret requirement accepts, from
195/// the same source.
196fn 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    /// Submodule path -> commit the certification recorded for the revision.
254    #[serde(default)]
255    submodules: BTreeMap<String, String>,
256    scaffold: BTreeMap<String, String>,
257    /// The scaffold packages the certification withholds from `scaffold` —
258    /// `stable` records every git-pinned package here; `nightly` carries
259    /// them in `scaffold`, so this is empty. The manifest's record must equal
260    /// the set the framework manifest's own dependency shapes derive.
261    #[serde(default, rename = "experimental-packages")]
262    experimental_packages: BTreeMap<String, ExperimentalPackage>,
263    /// The framework's `[package.metadata.waterui]` table, verbatim — the CLI
264    /// floor and every future framework-owned fact ride inside it.
265    metadata: toml::Table,
266}
267
268/// Make `document`'s `[patch]` tables carry `patches` in place of `previous`:
269/// the entries of `previous` are removed, those of `patches` written, and
270/// sources left empty are dropped, so a manifest moving between a channel, a
271/// local checkout and the registry never keeps a stale override.
272pub(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        // `[patch]` and `[patch.<source>]` are written as explicit tables:
291        // indexing into a missing key would vivify an inline value and
292        // hoist `patch = { … }` above `[package]`.
293        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    /// The selected distribution channel — `None` for a local checkout, which
337    /// is a filesystem source rather than a channel.
338    #[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    /// The framework a manifest resolves its generated code against: the
349    /// channel selection `framework` records, or the checkout `waterui_path`
350    /// names. A manifest carrying neither has no framework to resolve — an
351    /// explicit channel selection creates the record.
352    ///
353    /// # Errors
354    /// Returns an error when the manifest records no framework source, or the
355    /// local checkout's framework facts cannot be read.
356    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    /// The framework facts a local checkout supplies: its own
376    /// `[package.metadata.waterui]` table, the scaffold requirements its
377    /// `[workspace.dependencies]` declares, and the backend/workspace pins its
378    /// gitlinks and lockfile record.
379    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        // A checkout from before a backend's revision was declared in the
408        // manifest still carries its gitlink; a manifest declaring
409        // `{name}-backend-revision` has none to read.
410        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        // A checkout from before the Apple backend left the tree still carries
420        // its `backends/apple` gitlink. A declared version or revision supplies
421        // the pin without a gitlink.
422        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            // A local checkout is a filesystem source, not a channel: it
438            // withholds nothing.
439            experimental_packages: BTreeMap::new(),
440            packages: BTreeMap::new(),
441            patches: PatchSet::default(),
442        }
443        .validated()
444    }
445
446    /// Hold the framework to the metadata keys the CLI reads later without a
447    /// `Result` in hand — the scaffold's `minSdk` above all. A framework that
448    /// reaches a template context has passed here, so a template accessor
449    /// failing on it is an internal invariant, not an input error.
450    fn validated(mut self) -> Result<Self> {
451        self.android_min_api_level()?;
452        // The stable split is an invariant of the source, not of the writer:
453        // a selection persisted before `experimental-packages` existed keeps
454        // the withheld set inside `scaffold`, so re-derive it on load —
455        // `dependency` honoring a stale `-git` entry would resurrect a
456        // package the channel no longer distributes.
457        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    /// Resolve a certified framework manifest (`framework.json`) from disk —
465    /// the `--framework-manifest` source that pins a project to the channel
466    /// and revision it declares. The file is verified exactly as a manifest
467    /// downloaded from its release is.
468    ///
469    /// # Errors
470    /// Returns an error when the file cannot be read or parsed, fails
471    /// verification, or the revision it certifies cannot be fetched.
472    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    /// Assert the selected channel distributes `name` — a scaffold package a
500    /// generated crate links. The stable channel withholds every git-pinned
501    /// scaffold package, so scaffolding one must fail before anything is
502    /// written, naming the package, the channel and the fix.
503    ///
504    /// # Errors
505    /// Returns an error when the channel records `name` under
506    /// `experimental-packages`.
507    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    /// The Rust floor the selected framework declares — its
524    /// `[workspace.package].rust-version` at the resolved revision. A record
525    /// written before this key existed carries `None`; the caller falls back
526    /// to the CLI's own `rust-version`.
527    #[must_use]
528    pub const fn rust_version(&self) -> Option<&cargo_toml::SemVer> {
529        self.rust_version.as_ref()
530    }
531
532    /// The Apple backend release a scaffolded project pins, when the
533    /// framework declares one; a framework older than the submodule's
534    /// removal pins `apple-backend-revision` — a gitlink commit — instead.
535    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    /// The Apple backend commit a `dev` or `nightly` selection pins — the
542    /// backend's `dev` HEAD `dev` resolved at selection time, or the
543    /// revision a certification records — and the gitlink pin a framework
544    /// from before the backend's extraction carries on every channel.
545    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    /// The Android API floor the selected framework's native runtime
552    /// supports — the `android-min-api-level` its
553    /// `[package.metadata.waterui]` table declares. The backend's Gradle
554    /// `minSdk` declares the same floor independently; CI holds the two to
555    /// agreement.
556    ///
557    /// # Errors
558    /// Returns an error when the resolved framework's metadata does not
559    /// declare a valid `android-min-api-level` integer.
560    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    /// Rewrite a project manifest's dependencies and `[patch]` tables for this
594    /// framework, clearing `previous_patches` first: the entries the manifest
595    /// carried for whatever it was built against before, a channel's or a
596    /// local checkout's.
597    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        // The names the framework contract knows: `Water.lock`'s packages and
699        // the scaffold's extracted crates. Anything else — an extracted
700        // crate's private dependencies, which can never enter `Water.lock` —
701        // is foreign to the check.
702        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    /// The `(repository, revision)` a channel framework resolves its packages
913    /// from — `None` on the stable channel, which resolves from the registry,
914    /// and on a local checkout, which resolves by path.
915    ///
916    /// Generated crates use this to point `[patch]` entries the framework's own
917    /// table does not carry at the same source the framework resolves to.
918    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    /// The locked identities a generated project's resolution may produce
935    /// for each recorded package: the recorded one, plus — for a crate the
936    /// patch tables pin to a repository of its own — the same package at the
937    /// pin and at the framework's own source. Cargo vendors a git
938    /// dependency's submodules, so a submodule crate's path edges resolve
939    /// inside the framework's source while its `[patch]` edge resolves at
940    /// the submodule repository — the same commit either way (#807).
941    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        // Crate name → the `git+<repo>?rev=<rev>` source its patch pins it to.
951        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    /// Whether `identity` resolves a scaffold package at the source its
986    /// declared requirement sanctions — the declared `git + rev`, or the
987    /// registry at the pinned `=version`. An extracted crate never enters
988    /// `Water.lock`; the declared pin is the certification of what it must
989    /// resolve to.
990    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                // The registry substitutes for a declared git pin only once
1020                // the workspace names the crate by version alone.
1021                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    /// Resolve a channel's exact framework selection.
1041    ///
1042    /// `dev` resolves the integration branch head once it has passed its
1043    /// compilation gate; `nightly` and `stable` resolve the newest eligible
1044    /// GitHub release carrying a `framework.json` — a published `nightly-*`
1045    /// prerelease, a published `v<semver>` release — and pin what it
1046    /// certifies.
1047    ///
1048    /// # Errors
1049    /// Returns an error when the channel has no eligible release, the manifest
1050    /// fails verification, or the certified revision cannot be fetched.
1051    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    /// Build the resolved selection for the framework tree at `revision`, plus
1068    /// the certification a certified channel carries.
1069    ///
1070    /// Every channel shares this path: the fetched root manifest supplies the
1071    /// scaffold requirements and framework metadata, the fetched lock the
1072    /// workspace versions, and the submodule pins — the certification's record
1073    /// for a certified channel, the repository's gitlinks for `dev` — the
1074    /// backend revisions. The certification is then held to the tree it names:
1075    /// its scaffold table must agree with the manifest's, its lock hash with
1076    /// the fetched lock.
1077    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        // A scaffold package pinned to a git revision has no registry
1099        // release the stable channel could resolve: its scaffold entries are
1100        // withheld and the pin recorded under `experimental-packages`, the
1101        // same split `channel_scaffold` in `framework_manifest.py` makes for
1102        // the manifest. `dev`/`nightly` distribute it through `scaffold`.
1103        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        // `.gitmodules` names each submodule path's repository at the
1117        // revision; the pin's commit half comes from the tree's gitlinks
1118        // (`dev`) or the certification (a certified channel). `stable`
1119        // resolves from the registry and carries neither.
1120        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                    // Every submodule was extracted; the revision records none.
1126                    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            // A stable project resolves its graph from the registry; nothing is
1159            // pinned to the framework repository, so there is no package detail
1160            // or canonical lock to persist.
1161            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                // A path under a submodule belongs to the submodule's
1170                // repository at the pinned commit, not the superproject's —
1171                // whose tree holds a gitlink there, not the crate.
1172                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
1195/// The Apple backend follows the framework's channel. `dev` resolves the
1196/// backend's own `dev` HEAD — the compilation-gated revision the channel
1197/// promises — because the `backends/apple` gitlink that used to record the
1198/// pairing is gone and `apple-backend-version` is a stable pin. A
1199/// certification may likewise name the backend revision its suite ran.
1200/// Either lands as `apple-backend-revision`, the pin a non-stable channel's
1201/// requirement prefers; a framework from before the backend's extraction
1202/// instead keeps the gitlink pin `complete_scaffold` recorded.
1203async 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
1230/// `dev` has no certification; the repository tree's own gitlinks record which
1231/// submodule revisions the revision was built against — every submodule
1232/// `.gitmodules` names plus `backends/apple`, whose manifest declaration
1233/// predates its extraction from the tree.
1234async 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    // Revisions from before the Apple backend left the tree still carry its
1251    // `backends/apple` gitlink. A declared version or revision supplies the pin.
1252    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    // The remaining `.gitmodules` entries (`kit`, `utils/nami`, …) pin no
1259    // scaffold fact, but a `[patch]` path under one rebases onto the
1260    // submodule's repository at the gitlink's commit — the same record the
1261    // certification supplies for `nightly`.
1262    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
1272/// Marry each `.gitmodules` path's repository URL to its recorded commit, the
1273/// pin a `[patch]` path under it rebases onto.
1274fn 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
1294/// The persisted source a certification proves, checked against the tree it
1295/// names: the certified scaffold table, metadata, CLI floor and lock hash must
1296/// all agree with the fetched framework manifest before the release pin is
1297/// trusted.
1298fn 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    // The withheld set must agree too: a stable manifest may neither drop a
1317    // git-pinned package silently nor leave it in `scaffold` while also
1318    // recording it as experimental.
1319    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
1356/// The submodule each native backend repository used to be pinned through;
1357/// the directory's basename keys the scaffold's `{name}-backend-revision`
1358/// entry. A framework that declares `{name}-backend-revision` in
1359/// `[package.metadata.waterui]` (Android, since water-rs/waterui#940) or
1360/// `{name}-backend-version` (Apple, since #839) carries no gitlink, and the
1361/// gitlink is read only for a revision from before that declaration.
1362const 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
1369/// Whether the scaffold already names `submodule_path`'s backend pin — a
1370/// declared `{name}-backend-revision` — so no gitlink has to be read for it.
1371fn 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
1378/// The workspace crates a scaffolded project pins; each `{name}-version`
1379/// scaffold entry comes from the framework's own lockfile at the selected
1380/// revision.
1381const 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
1400/// The repository the CLI's pinned `waterui-*` dependencies resolve from —
1401/// where certified manifests, releases, and `dev` revisions live. `build.rs`
1402/// bakes it in from the git source in `Cargo.toml` so the pin is declared
1403/// exactly once.
1404fn framework_repository() -> &'static str {
1405    env!("WATERUI_FRAMEWORK_REPOSITORY").trim_end_matches(".git")
1406}
1407
1408/// A repository's `owner/name` slug, from its GitHub URL.
1409fn 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
1415/// The framework's own metadata table — `[package.metadata.waterui]` of the
1416/// manifest at the selected revision — carried verbatim into every published
1417/// `framework.json` and every persisted selection.
1418fn 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
1434/// The scaffold facts the framework manifest itself declares: each
1435/// `scaffold-packages` entry's requirement from `[workspace.dependencies]` —
1436/// `{name}-version`, plus `{name}-git` and `{name}-rev` when the requirement
1437/// pins a repository — and every backend coordinate — `{name}-backend-url`,
1438/// plus the `{name}-backend-version` of a backend pinned by release or the
1439/// `{name}-backend-revision` of one pinned by commit, rather than by
1440/// gitlink — from `[package.metadata.waterui]`.
1441///
1442/// `framework_manifest.py` emits exactly this table into every `framework.json`
1443/// it publishes; both must produce the same table for the same tree.
1444fn 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        // A scaffold package pinned from git keeps that source: a bare
1471        // `{name}-version` cannot express the commit the framework builds
1472        // against, and the registry may not carry it at all.
1473        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
1504/// Move every git-pinned scaffold package out of `scaffold` — the split
1505/// `channel_scaffold` in `framework_manifest.py` makes for `stable`: a
1506/// package pinned to a git revision has no registry release the channel can
1507/// resolve, so its `{name}-*` entries leave the scaffold table and the pin
1508/// is recorded by name instead.
1509fn split_experimental_packages(
1510    scaffold: &mut BTreeMap<String, String>,
1511) -> BTreeMap<String, ExperimentalPackage> {
1512    let mut experimental = BTreeMap::new();
1513    // `-git` is the marker: a `{name}-git` scaffold entry is a git pin with
1514    // no registry release; its `-rev`/`-version` siblings are lifted out in
1515    // the second pass.
1516    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
1541/// The Rust floor a root manifest declares — `[workspace.package].rust-version`,
1542/// or `[package].rust-version` when the manifest is a plain package. Shared by
1543/// framework resolution (the framework's own manifest at the selected
1544/// revision) and the doctor (the project's and a local checkout's manifests).
1545pub(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
1565/// The CLI floor a `package.metadata.waterui` metadata table declares —
1566/// read the same way from a checked-out root manifest and from a
1567/// certification's `metadata` table.
1568fn 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
1577/// The CLI update hint for a framework that is not a registry release — a
1578/// local checkout or a git-pinned `dev`/`nightly` source pairs with the
1579/// development line of this repository.
1580fn 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, &current, 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        // The fallback command is what a registry or git source already
1609        // selected; the detected install source may replace it with `water
1610        // update` or `brew upgrade water`.
1611        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        // A `{name}-backend-version` entry pins a backend repository's release
1669        // tag, not a crate — there is no package to resolve for it.
1670        if name.ends_with("-backend") {
1671            continue;
1672        }
1673        // The scaffold value is a requirement, not the resolved version: a
1674        // declaration that names an older but still-satisfied version
1675        // (`"0.2.0"` where the lock carries 0.2.1) must still find the lock
1676        // candidate and inherit its source. String equality would take the
1677        // lock-absent arm and pin `=<requirement>` — a release the framework
1678        // never certified. `sanctioned_source` matches the same way.
1679        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        // A scaffold package the workspace pins from git resolves from that
1692        // pin on every channel: an extracted crate never enters the framework
1693        // lock, and for one built in-tree the lock only witnesses that the
1694        // framework resolves the same commit.
1695        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            // An extracted crate the framework no longer builds never enters
1718            // its lock — `waterui-dew` releases from water-rs/dew (#614) — so
1719            // the scaffold's declared requirement is the resolution, the same
1720            // `=<version>` the registry-source arm below produces for a crate
1721            // the framework still carries.
1722            [] => {
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
1764/// Assert `package`'s lock source is the git repository a declared
1765/// `{name}-git`/`{name}-rev` scaffold pair names — the witness that the
1766/// framework builds the same commit a scaffolded project receives.
1767fn 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
1794/// Fill in what the framework manifest cannot carry itself: each backend's
1795/// pinned revision — `submodules` maps submodule path to the commit the
1796/// certification or the checkout records — and every framework package's
1797/// version from the framework's own lock.
1798fn complete_scaffold(
1799    scaffold: &mut BTreeMap<String, String>,
1800    submodules: &BTreeMap<String, String>,
1801    lock: &Lockfile,
1802) -> Result<()> {
1803    // A declared `{name}-backend-revision` is already in the scaffold
1804    // (`framework_scaffold` copied and validated it); the gitlink is the pin
1805    // record only for a framework from before the declaration.
1806    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    // `framework_scaffold` already copied declared Apple versions or revisions.
1820    // Only a framework without either declaration needs its historical gitlink
1821    // promoted to the revision requirement consumed by the package template.
1822    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
1845/// The commit a submodule of a local checkout records at `HEAD` — the same
1846/// fact `submodule_revision` reads from the repository tree for a remote
1847/// revision.
1848async 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
1868/// The commit a submodule of the framework repository records at `revision`,
1869/// read from the repository tree — the only record that pairs the revision
1870/// with the backends it was built and tested against.
1871async 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
1886/// The commit `path`'s gitlink records at `revision`, or `None` when `path`
1887/// is not a submodule there — a `.gitmodules` entry can outlive the gitlink
1888/// it once named, and the patch paths under it then belong in the tree.
1889async 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    // A present-but-ordinary path lists as a directory array or carries a
1898    // non-submodule type; neither is a pin.
1899    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
1905/// The `path → url` pairs `.gitmodules` records — git-config syntax rather
1906/// than TOML (values go unquoted), so a line scan keyed on `[submodule]`
1907/// sections.
1908fn 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
1951/// [`fetch`] that answers `None` when the resource does not exist —
1952/// `.gitmodules` is absent on a revision whose submodules were all
1953/// extracted. zenwave surfaces a non-success status as `Err`, so the 404
1954/// arrives as an [`Error::Http`], never as a response to inspect.
1955async 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
1973/// The token a GitHub REST request carries: `WATERUI_GITHUB_TOKEN`, else the
1974/// `GITHUB_TOKEN` every Actions job holds. Unauthenticated requests share
1975/// sixty an hour across every machine behind one address — CI runners and
1976/// cloud VMs first of all — and the same token `water update` sends. Only
1977/// `api.github.com` sees it; release downloads and raw file reads are not
1978/// metered and must not receive a credential.
1979fn 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    /// A stable-channel resolution carrying every scaffold fact the templates
1996    /// may read — the shape `resolve` produces, built in place because the
1997    /// real resolution lives on the network. `stable` withholds the git-pinned
1998    /// scaffold packages under `experimental-packages`, the split a published
1999    /// `framework.json` makes for the channel.
2000    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    /// The git-pinned scaffold packages the checkout fixture's
2041    /// `[workspace.dependencies]` declares — `waterui-dew`, `waterui-gtk` and
2042    /// `waterui-winui` have no registry release, so `stable` withholds them
2043    /// under `experimental-packages` while `dev`/`nightly` distribute the
2044    /// pins through `scaffold`.
2045    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    /// A `dev`-channel resolution: the manifest's scaffold facts — including
2068    /// the git-pinned packages `stable` withholds, which `dev` distributes
2069    /// through `scaffold` — plus the `apple-backend-revision` `construct`
2070    /// resolves for the channel — the backend's `dev` HEAD at selection
2071    /// time — beside the declared `apple-backend-version` the channel must
2072    /// not follow.
2073    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    /// A `nightly`-channel resolution; `backend_revision` carries the
2107    /// `apple-backend-revision` a certification records when its suite names
2108    /// the backend it ran — absent, the declared `apple-backend-version` is
2109    /// what the certification certified.
2110    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    /// A local framework checkout fixture: the repository's own root manifest
2125    /// and a lock naming the workspace crates, inside a git worktree. Like the
2126    /// repository today it carries no backend gitlink: both backend pins are
2127    /// literals in the manifest.
2128    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    /// The same fixture as it existed while both backends still rode
2176    /// gitlinks: no `apple-backend-version` and no `android-backend-revision`
2177    /// in the manifest, the submodule pins recorded in the index.
2178    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    /// Record a gitlink pin the way a checked-out submodule records it —
2219    /// `160000` is the mode `git submodule` writes into the index.
2220    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    /// The manifest a local checkout fixture carries: the framework's own
2236    /// metadata and the workspace requirements `scaffold-packages` names.
2237    fn local_checkout_manifest() -> &'static str {
2238        include_str!("../../tests/fixtures/framework_checkout_manifest.toml")
2239    }
2240
2241    /// A framework lock naming every workspace crate a scaffolded project pins.
2242    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
2271/// The Apple backend's `dev` HEAD for a `dev` framework selection. The
2272/// backend moved out of the framework tree, so nothing records the backend
2273/// revision a `dev` framework was built against — `apple-backend-version`
2274/// is the stable pin and must not serve `dev`. The backend's `dev` is held
2275/// to the same promise the framework's makes: `ci.yml` gates the branch.
2276async 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
2286/// The `dev` HEAD of `repository`, held to the channel's promise that the
2287/// resolved commit passed `gate` — the workflow file gating `dev` in that
2288/// repository: `dev.yml` for the framework, `ci.yml` for a backend.
2289async 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
2319/// The newest distribution `channel` accepts, with its certification
2320/// manifest loaded and verified against the release it rode in on.
2321///
2322/// Stable is the registry: the highest published `waterui` version names the
2323/// framework release tag (`v<version>`, the version group release-plz tags),
2324/// so the version comes from the crates.io sparse index and the manifest from
2325/// the release's download URL — neither is a GitHub API request, so a
2326/// resolution costs nothing against the sixty-an-hour unauthenticated limit
2327/// that paging through every crate release used to exhaust (#110). Nightly
2328/// prereleases exist only on GitHub and are still listed there.
2329async 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
2369/// The crates.io sparse index entry for `name`: one JSON line per published
2370/// version, served from a CDN with no request metering.
2371fn 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/// One version line of a sparse index entry, reduced to what selection reads.
2382#[derive(Deserialize)]
2383struct IndexVersion {
2384    vers: cargo_toml::SemVer,
2385    yanked: bool,
2386}
2387
2388/// The highest published, unyanked, bare-semver version in a sparse index
2389/// entry: prereleases are not stable distributions, and a yanked version has
2390/// no release a user should scaffold against.
2391fn 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
2416/// The newest published `nightly-*` prerelease of the framework repository.
2417async 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
2439/// A release selected for `channel` must carry that channel's manifest: the
2440/// tag alone does not bind the contents to the distribution it names.
2441fn 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/// The schema a manifest declares, read before the rest of it so an
2453/// unsupported schema is reported as such rather than as whichever field it
2454/// happens to lack.
2455#[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
2473/// A published `nightly-*` prerelease: the only release shape the nightly
2474/// channel certifies.
2475fn is_nightly_release(release: &Release) -> bool {
2476    release.prerelease && !release.draft && release.tag_name.starts_with("nightly-")
2477}
2478
2479/// The first stable framework release whose GitHub release publishes a
2480/// `framework.json`. Manifest publishing began here; every earlier tag
2481/// resolves nothing on the stable channel.
2482const FIRST_STABLE_MANIFEST_RELEASE: &str = "0.5.0";
2483
2484/// The stable channel selected a release that predates manifest publishing:
2485/// it has no `framework.json` to resolve. The diagnostic names the first
2486/// manifest-carrying release and the channels that resolve today.
2487#[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    /// The tag of the release the stable channel resolved to.
2496    tag: String,
2497}
2498
2499/// The `framework.json` asset of a nightly release.
2500fn 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
2508/// Read and verify a `framework.json` from disk: the same schema, channel,
2509/// repository, revision and CLI checks a downloaded manifest passes, with no
2510/// release for the tag to be checked against.
2511async 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
2521/// The checks a `framework.json` must pass before it resolves anything —
2522/// identical whether the manifest was downloaded from `release` or read from
2523/// disk via `--framework-manifest`, where there is no release to check the
2524/// tag against.
2525fn 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
2562/// A submodule the resolved revision pins: the repository `.gitmodules`
2563/// names for the path and the commit the revision's gitlink — or a certified
2564/// channel's certification — records.
2565struct SubmodulePin {
2566    /// The submodule's repository, canonicalized like [`framework_repository`].
2567    repository: String,
2568    /// The pinned commit.
2569    commit: String,
2570}
2571
2572/// Rebase a fetched root manifest's `[patch]` tables onto the channel's own
2573/// sources: a path entry under one of the revision's submodules becomes
2574/// `git + rev` on the submodule's repository at the recorded commit, and any
2575/// other path entry becomes `git + rev` on the framework repository at the
2576/// resolved revision.
2577///
2578/// A fetched table keyed on the framework repository itself is dropped in
2579/// any spelling — Cargo canonicalizes a source's query, fragment, `.git`
2580/// suffix and trailing slash away, so every one names the patched source
2581/// itself, and a patch may not point at the source it patches. No
2582/// repository-source mirror is synthesized for the path entries either:
2583/// mirroring them at the channel's revision was the same-source patch Cargo
2584/// rejects (#807), and the extracted crates that once named framework
2585/// crates by `git` (#758) are consumed from the registry, where
2586/// `[patch.crates-io]` already applies.
2587fn 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
2616/// A git URL in the spelling Cargo canonicalizes sources to: the query,
2617/// fragment, `.git` suffix and trailing slash carry no meaning.
2618fn 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
2625/// Whether two URLs name the same git source — `repo?branch=dev`, `repo.git`
2626/// and `repo` canonicalize to one source, so a `[patch]` table keyed on any
2627/// of them patches the framework repository itself.
2628fn 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, &current, &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(&current.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(&registry_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        // A crate released from its own repository and not consumed by the
2824        // framework never enters the framework lock — the scaffold's declared
2825        // requirement is the requirement a dev/nightly resolution pins,
2826        // whether the workspace names a version or a git revision.
2827        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        // The declared pin is authoritative, but a framework that also builds
2863        // the crate in-tree must not lock a different commit than it declares.
2864        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        // The scaffold value is a requirement: a declaration satisfied by a
2903        // newer locked version — `hydrolysis-m3 = "0.2.0"` where the lock
2904        // carries the pinned 0.2.1 — still resolves the lock candidate's git
2905        // source instead of pinning `=0.2.0`, a registry release the patch
2906        // table cannot rescue and the framework never certified.
2907        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        // A persisted stable selection written before `experimental-packages`
2938        // existed can still carry a `{name}-git`/`{name}-rev` pair in
2939        // `scaffold`; `dependency` keeps honoring the declared pin.
2940        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        // A scaffold package declared by version alone still resolves the
2963        // registry pin.
2964        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        // `waterui-dew`, `waterui-gtk` and `waterui-winui` have no registry
2972        // release, so a stable manifest withholds them — recorded under
2973        // `experimental-packages`, absent from `scaffold` — and scaffolding
2974        // one fails naming the package, the channel and the fix.
2975        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        // Registry-backed scaffold packages stay distributable on stable.
2992        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        // A `Water.toml` written before `experimental-packages` existed
3019        // keeps the git pins inside `scaffold`; validation restores the
3020        // split so the withheld packages stay unscaffoldable.
3021        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        // Dropping a git-pinned package without recording it is not a valid
3085        // stable manifest.
3086        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        // Neither is recording it while still scaffolding it.
3100        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        // `Water.lock` cannot record an extracted crate, so
3119        // `validate_dependencies` holds it to its declared pin instead: the
3120        // exact commit for a git source, the exact version for the registry.
3121        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(&gtk_revision)
3151        )));
3152        // The pinned commit carries whatever version its manifest declares.
3153        assert!(framework.sanctioned_source(&identity(
3154            "waterui-gtk",
3155            "0.2.1",
3156            gtk_source(&gtk_revision)
3157        )));
3158        // A different commit is a different pin.
3159        let drifted = "c".repeat(40);
3160        assert!(!framework.sanctioned_source(&identity(
3161            "waterui-gtk",
3162            "0.2.0",
3163            gtk_source(&drifted)
3164        )));
3165        // The registry pin holds only its exact version.
3166        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    /// The exact requirement a stable channel writes for one scaffold entry.
3172    ///
3173    /// Spelling the number out here would put a third copy of it beside the two
3174    /// the manifest and the workspace already keep in step (#548), and it would
3175    /// have to be edited on every release.
3176    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        // A fetched table keyed on the framework repository itself — in any
3350        // spelling Cargo canonicalizes to it — must not survive: a patch may
3351        // not point at the source it patches.
3352        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        // The crates-io path entry became a git pin at the channel revision.
3376        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        // A path under a submodule rebases onto the submodule's repository at
3390        // the pinned commit — the superproject holds a gitlink, not the crate.
3391        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        // Dependencies patched to another source stay untouched, and no
3404        // repository-source table is synthesized — it would patch a source
3405        // onto itself.
3406        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            // Recorded at the framework's own source — a submodule path dep
3450            // cargo vendors in-source.
3451            package("waterkit-codec", "0.1.1", Some(&framework_source)),
3452            // Recorded at the submodule repository the patch pins it to.
3453            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        // A registry package gains no variants.
3471        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            // A prerelease is not a stable distribution no matter how recent.
3527            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            // A yanked version has no release a user should scaffold against.
3532            r#"{"name":"waterui","vers":"0.9.9","yanked":true}"#,
3533            "\n",
3534            // A backport published after a newer version does not outrank it:
3535            // stable is ordered by version, not by publication order.
3536            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            // Stable tags, drafts and non-prerelease tags are not nightlies.
3585            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        // A schema-1 manifest has no `metadata`; the schema is still what the
3659        // error names, not the field the newer schema happens to require.
3660        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        // A manifest read from disk has no release; the tag check is skipped.
3689        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    /// The Rust scaffold derivation and `framework_manifest.py`'s must produce
3734    /// the same table for the same tree — this asserts the Rust side against
3735    /// the fixture manifest, which carries the framework root manifest's
3736    /// metadata table and the workspace requirements `scaffold-packages`
3737    /// names.
3738    #[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    /// A checkout from before the backends left the tree: its manifest
3869    /// declares neither `apple-backend-version` nor
3870    /// `android-backend-revision`, so the gitlinks supply the pins.
3871    #[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}