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