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