Skip to main content

xianyun_core/config/
latest.rs

1//! Port of `internal/config/latest`: configuration structures for the latest
2//! (v2, separated-concerns) Caproni config format, plus its terminal
3//! `Upgrade` no-op.
4
5use serde::{Deserialize, Deserializer, Serialize};
6use thiserror::Error;
7
8use super::types::ClusterConfig;
9use super::versions::Upgrader;
10use crate::orderedmap::OrderedMap;
11
12#[derive(Debug, Error, PartialEq, Eq)]
13pub enum LatestError {
14    #[error("unsupported variable type: {0:?}")]
15    UnsupportedVariableType(String),
16    #[error("file variable requires a path")]
17    MissingVariablePath,
18}
19
20#[derive(Debug, Clone, Default, Serialize, Deserialize)]
21#[serde(default)]
22pub struct CaproniConfig {
23    pub version: i32,
24    pub cluster: ClusterConfig,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub repositories: Option<OrderedMap<String, RepositoryConfig>>,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub deployers: Option<OrderedMap<String, DeployerConfig>>,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub reloaders: Option<OrderedMap<String, ReloaderConfig>>,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub variables: Option<OrderedMap<String, VariableConfig>>,
33    #[serde(skip)]
34    pub config_file: String,
35    #[serde(skip)]
36    pub config_dir: String,
37    #[serde(skip)]
38    pub copy_sleep_image: String,
39}
40
41impl CaproniConfig {
42    /// Applies schema-defined defaults to optional fields.
43    pub fn apply_defaults(&mut self) -> Result<(), LatestError> {
44        if self.cluster.driver == "k3d"
45            && let Some(k3d) = self.cluster.k3d.as_mut()
46            && k3d.servers == 0
47        {
48            k3d.servers = 1;
49        }
50
51        if self.cluster.driver == "vcluster"
52            && let Some(vcluster) = self.cluster.vcluster.as_mut()
53            && vcluster.manage_namespace.is_none()
54        {
55            vcluster.manage_namespace = Some(true);
56        }
57
58        if self.cluster.name.is_none() {
59            self.cluster.name = Some(super::types::ClusterNameConfig {
60                strategy: "static".into(),
61            });
62        }
63
64        if let Some(network) = self.cluster.network.as_mut()
65            && network.additional_hostnames.is_empty()
66        {
67            network.additional_hostnames = Vec::new();
68        }
69
70        if self.repositories.is_none() {
71            self.repositories = Some(OrderedMap::new());
72        }
73        if self.deployers.is_none() {
74            self.deployers = Some(OrderedMap::new());
75        }
76        if self.reloaders.is_none() {
77            self.reloaders = Some(OrderedMap::new());
78        }
79        if self.variables.is_none() {
80            self.variables = Some(OrderedMap::new());
81        }
82
83        if let Some(reloaders) = self.reloaders.as_mut() {
84            let names: Vec<String> = reloaders.keys();
85            for name in names {
86                if let Some(rc) = reloaders.get_mut(&name) {
87                    rc.name = name;
88                }
89            }
90        }
91
92        Ok(())
93    }
94
95    /// Resolves a path relative to the configuration file directory. If the
96    /// path is already absolute, it is returned unchanged.
97    pub fn resolve_path(&self, path: &str) -> String {
98        let p = std::path::Path::new(path);
99        if p.is_absolute() {
100            return path.to_string();
101        }
102
103        std::path::Path::new(&self.config_dir)
104            .join(p)
105            .to_string_lossy()
106            .into_owned()
107    }
108}
109
110impl Upgrader for CaproniConfig {
111    /// The terminal no-op: `latest` is already the newest schema. There is no
112    /// Rust equivalent of the Go original's pointer-identity termination
113    /// signal (see PORT_NOTES.md); this clones `self` into a fresh box, and
114    /// callers must use `is_terminal`/a version check rather than identity to
115    /// detect convergence.
116    fn upgrade(&self) -> Result<Box<dyn Upgrader>, super::versions::UpgraderError> {
117        Ok(Box::new(self.clone()))
118    }
119
120    /// `latest` is always the terminal version, so this is always `true`.
121    /// See `versions::Upgrader::is_terminal` for why this replaces the Go
122    /// original's pointer-identity termination signal.
123    fn is_terminal(&self) -> bool {
124        true
125    }
126
127    fn as_any(&self) -> &dyn std::any::Any {
128        self
129    }
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
133pub enum VariableType {
134    #[default]
135    Value,
136    File,
137}
138
139#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
140pub struct VariableConfig {
141    #[serde(rename = "type")]
142    pub var_type: VariableType,
143    #[serde(skip_serializing_if = "String::is_empty")]
144    pub value: String,
145    #[serde(skip_serializing_if = "String::is_empty")]
146    pub path: String,
147}
148
149impl Serialize for VariableType {
150    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
151    where
152        S: serde::Serializer,
153    {
154        match self {
155            VariableType::Value => serializer.serialize_str("value"),
156            VariableType::File => serializer.serialize_str("file"),
157        }
158    }
159}
160
161/// Decodes both the scalar shorthand (`NAME: literal-value`) and the
162/// explicit mapping form (`NAME: {type: ..., value: ..., path: ...}`) of a
163/// variable, mirroring `(*VariableConfig).UnmarshalYAML`. Implemented via
164/// `deserialize_any` rather than the YAML-only `yaml.Node` API the Go
165/// original uses, so this works uniformly whether the underlying format is
166/// `serde_yaml` or `serde_json` (both hand scalars and maps through the same
167/// `Visitor` callbacks).
168impl<'de> Deserialize<'de> for VariableConfig {
169    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
170    where
171        D: Deserializer<'de>,
172    {
173        struct VariableConfigVisitor;
174
175        impl<'de> serde::de::Visitor<'de> for VariableConfigVisitor {
176            type Value = VariableConfig;
177
178            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179                f.write_str("a scalar value or a variable mapping")
180            }
181
182            fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
183            where
184                E: serde::de::Error,
185            {
186                Ok(VariableConfig {
187                    var_type: VariableType::Value,
188                    value: v.to_string(),
189                    path: String::new(),
190                })
191            }
192
193            fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
194            where
195                E: serde::de::Error,
196            {
197                Ok(VariableConfig {
198                    var_type: VariableType::Value,
199                    value: v,
200                    path: String::new(),
201                })
202            }
203
204            fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
205            where
206                A: serde::de::MapAccess<'de>,
207            {
208                #[derive(Deserialize, Default)]
209                #[serde(default)]
210                struct RawVariableConfig {
211                    r#type: String,
212                    value: String,
213                    path: String,
214                }
215
216                let raw: RawVariableConfig =
217                    Deserialize::deserialize(serde::de::value::MapAccessDeserializer::new(map))?;
218
219                let var_type = match raw.r#type.as_str() {
220                    "" | "value" => VariableType::Value,
221                    "file" => VariableType::File,
222                    other => {
223                        return Err(serde::de::Error::custom(format!(
224                            "unsupported variable type: {other:?}"
225                        )));
226                    }
227                };
228
229                if var_type == VariableType::File && raw.path.is_empty() {
230                    return Err(serde::de::Error::custom("file variable requires a path"));
231                }
232
233                Ok(VariableConfig {
234                    var_type,
235                    value: raw.value,
236                    path: raw.path,
237                })
238            }
239        }
240
241        deserializer.deserialize_any(VariableConfigVisitor)
242    }
243}
244
245#[derive(Debug, Clone, Default, Serialize, Deserialize)]
246#[serde(default)]
247pub struct RepositoryConfig {
248    pub repository: String,
249    pub directory: String,
250    #[serde(rename = "ref")]
251    pub ref_: String,
252    pub edit_mode: bool,
253    pub lifecycle_hooks: Vec<LifecycleHookConfig>,
254}
255
256#[derive(Debug, Clone, Default, Serialize, Deserialize)]
257#[serde(default)]
258pub struct HelmConfig {
259    pub namespace: String,
260    pub wait: bool,
261    pub timeout: String,
262    pub values_file: String,
263    pub dynamic_overrides: Vec<DynamicOverrideConfig>,
264    pub repository: Option<HelmRepositoryConfig>,
265    pub release: Option<HelmReleaseConfig>,
266}
267
268#[derive(Debug, Clone, Default, Serialize, Deserialize)]
269#[serde(default)]
270pub struct HelmRepositoryConfig {
271    pub name: String,
272    pub url: String,
273}
274
275#[derive(Debug, Clone, Default, Serialize, Deserialize)]
276#[serde(default)]
277pub struct HelmReleaseConfig {
278    pub name: String,
279    pub chart: String,
280    pub version: String,
281}
282
283#[derive(Debug, Clone, Default, Serialize, Deserialize)]
284#[serde(default)]
285pub struct DynamicOverrideConfig {
286    #[serde(rename = "type")]
287    pub override_type: String,
288    pub repository: String,
289    pub version_map: String,
290    pub value_map: Option<OrderedMap<String, serde_json::Value>>,
291}
292
293#[derive(Debug, Clone, Default, Serialize, Deserialize)]
294#[serde(default)]
295pub struct MirrorConfig {
296    pub target: Option<MirrorTargetConfig>,
297    pub filesystem: Option<FilesystemConfig>,
298    pub network: Option<MirrorNetworkConfig>,
299    pub edit_mode_processes: Option<OrderedMap<String, ProcessConfig>>,
300    pub lifecycle_hooks: Vec<LifecycleHookConfig>,
301}
302
303#[derive(Debug, Clone, Default, Serialize, Deserialize)]
304#[serde(default)]
305pub struct MirrorNetworkConfig {
306    pub incoming: Option<MirrorIncomingConfig>,
307    pub outgoing: Option<MirrorOutgoingConfig>,
308}
309
310#[derive(Debug, Clone, Default, Serialize, Deserialize)]
311#[serde(default)]
312pub struct MirrorIncomingConfig {
313    pub mode: String,
314}
315
316#[derive(Debug, Clone, Default, Serialize, Deserialize)]
317#[serde(default)]
318pub struct MirrorOutgoingConfig {
319    pub ignore_localhost: bool,
320}
321
322#[derive(Debug, Clone, Default, Serialize, Deserialize)]
323#[serde(default)]
324pub struct MirrorTargetConfig {
325    pub namespace: String,
326    pub kubernetes_type: String,
327    pub name: String,
328    pub container: String,
329}
330
331#[derive(Debug, Clone, Default, Serialize, Deserialize)]
332#[serde(default)]
333pub struct FilesystemConfig {
334    pub mode: String,
335    pub read_write: Vec<String>,
336    pub read_only: Vec<String>,
337    pub mapping: Option<OrderedMap<String, String>>,
338}
339
340pub const PROCESS_CONTEXT_MIRRORD: &str = "mirrord";
341pub const PROCESS_CONTEXT_LOCAL: &str = "local";
342
343#[derive(Debug, Clone, Default, Serialize, Deserialize)]
344#[serde(default)]
345pub struct ProcessConfig {
346    pub command: String,
347    pub port: i32,
348    pub context: String,
349}
350
351#[derive(Debug, Clone, Default, Serialize, Deserialize)]
352#[serde(default)]
353pub struct LifecycleHookConfig {
354    #[serde(rename = "type")]
355    pub hook_type: String,
356    pub script: String,
357    pub mirror_script: String,
358    pub job: String,
359}
360
361#[derive(Debug, Clone, Default, Serialize, Deserialize)]
362#[serde(default)]
363pub struct SkaffoldConfig {
364    pub namespace: String,
365    pub config_file: String,
366}
367
368#[derive(Debug, Clone, Default, Serialize, Deserialize)]
369#[serde(default)]
370pub struct DeployerConfig {
371    #[serde(rename = "type")]
372    pub deployer_type: String,
373    pub repository: Option<String>,
374    pub needs: Vec<String>,
375    pub edit_mode: bool,
376    pub helm: Option<HelmConfig>,
377    pub namespace: Option<NamespaceConfig>,
378    pub kustomize: Option<KustomizeConfig>,
379}
380
381#[derive(Debug, Clone, Default, Serialize, Deserialize)]
382#[serde(default)]
383pub struct NamespaceConfig {
384    pub namespaces: Vec<String>,
385    pub delete_on_undeploy: bool,
386}
387
388#[derive(Debug, Clone, Default, Serialize, Deserialize)]
389#[serde(default)]
390pub struct KustomizeConfig {
391    pub path: String,
392    pub namespace: String,
393    pub files: std::collections::HashMap<String, String>,
394}
395
396#[derive(Debug, Clone, Default, Serialize, Deserialize)]
397#[serde(default)]
398pub struct ReloaderConfig {
399    #[serde(skip)]
400    pub name: String,
401    #[serde(rename = "type")]
402    pub reloader_type: String,
403    pub repository: String,
404    pub edit_mode: bool,
405    pub mirror: Option<MirrorConfig>,
406    pub skaffold: Option<SkaffoldConfig>,
407}
408
409impl ReloaderConfig {
410    /// Returns a user-friendly resource name for the reloader:
411    /// - mirrord reloaders: namespace/kubernetes_type/name/container (omitting empty parts).
412    /// - skaffold reloaders: namespace.
413    /// - invalid reloaders: empty string.
414    pub fn pretty_resource_name(&self) -> String {
415        if let Some(mirror) = &self.mirror
416            && let Some(target) = &mirror.target
417        {
418            return [
419                target.namespace.as_str(),
420                target.kubernetes_type.as_str(),
421                target.name.as_str(),
422                target.container.as_str(),
423            ]
424            .into_iter()
425            .filter(|s| !s.is_empty())
426            .collect::<Vec<_>>()
427            .join("/");
428        }
429
430        if let Some(skaffold) = &self.skaffold {
431            return skaffold.namespace.clone();
432        }
433
434        String::new()
435    }
436}
437
438/// Decodes a [`VariableConfig`] from a YAML value, handling both the scalar
439/// shorthand and the explicit mapping form. A scalar is treated as a literal
440/// value. A mapping defaults to the literal type when no type is given; a
441/// file type must name its file, and any other type is rejected.
442pub fn unmarshal_variable_config(yaml: &str) -> Result<VariableConfig, LatestError> {
443    let value: serde_yaml::Value =
444        serde_yaml::from_str(yaml).map_err(|_| LatestError::MissingVariablePath)?;
445
446    if let serde_yaml::Value::String(s) = &value {
447        return Ok(VariableConfig {
448            var_type: VariableType::Value,
449            value: s.clone(),
450            path: String::new(),
451        });
452    }
453
454    #[derive(serde::Deserialize, Default)]
455    struct RawVariableConfig {
456        #[serde(default)]
457        r#type: String,
458        #[serde(default)]
459        value: String,
460        #[serde(default)]
461        path: String,
462    }
463
464    let raw: RawVariableConfig =
465        serde_yaml::from_value(value).map_err(|_| LatestError::MissingVariablePath)?;
466
467    let var_type = match raw.r#type.as_str() {
468        "" | "value" => VariableType::Value,
469        "file" => VariableType::File,
470        other => return Err(LatestError::UnsupportedVariableType(other.to_string())),
471    };
472
473    if var_type == VariableType::File && raw.path.is_empty() {
474        return Err(LatestError::MissingVariablePath);
475    }
476
477    Ok(VariableConfig {
478        var_type,
479        value: raw.value,
480        path: raw.path,
481    })
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487
488    // Port of TestVariableConfigUnmarshalYAML.
489    #[test]
490    fn variable_config_unmarshal_yaml_cases() {
491        struct Case {
492            name: &'static str,
493            yaml: &'static str,
494            want: Option<VariableConfig>,
495            want_err: Option<&'static str>,
496        }
497
498        let cases = vec![
499            Case {
500                name: "scalar shorthand becomes literal value",
501                yaml: "foo.bar.gitlab.net",
502                want: Some(VariableConfig {
503                    var_type: VariableType::Value,
504                    value: "foo.bar.gitlab.net".into(),
505                    path: String::new(),
506                }),
507                want_err: None,
508            },
509            Case {
510                name: "explicit literal type",
511                yaml: "type: value\nvalue: foo.bar.gitlab.net",
512                want: Some(VariableConfig {
513                    var_type: VariableType::Value,
514                    value: "foo.bar.gitlab.net".into(),
515                    path: String::new(),
516                }),
517                want_err: None,
518            },
519            Case {
520                name: "mapping without type defaults to literal value",
521                yaml: "value: foo.bar.gitlab.net",
522                want: Some(VariableConfig {
523                    var_type: VariableType::Value,
524                    value: "foo.bar.gitlab.net".into(),
525                    path: String::new(),
526                }),
527                want_err: None,
528            },
529            Case {
530                name: "file type keeps the path",
531                yaml: "type: file\npath: ../GITALY_SERVER_VERSION",
532                want: Some(VariableConfig {
533                    var_type: VariableType::File,
534                    value: String::new(),
535                    path: "../GITALY_SERVER_VERSION".into(),
536                }),
537                want_err: None,
538            },
539            Case {
540                name: "file type without a path is rejected",
541                yaml: "type: file",
542                want: None,
543                want_err: Some("file variable requires a path"),
544            },
545            Case {
546                name: "unsupported type is rejected",
547                yaml: "type: env\nname: CI_REGISTRY",
548                want: None,
549                want_err: Some("unsupported variable type: \"env\""),
550            },
551        ];
552
553        for case in cases {
554            let got = unmarshal_variable_config(case.yaml);
555
556            if let Some(expected_err) = case.want_err {
557                let err = got.unwrap_err();
558                assert_eq!(err.to_string(), expected_err, "{}", case.name);
559                continue;
560            }
561
562            assert_eq!(got.unwrap(), case.want.unwrap(), "{}", case.name);
563        }
564    }
565
566    // Port of TestCaproniConfigVariables. The Go test decodes YAML directly;
567    // here the equivalent decode-from-YAML entry point is `unmarshal_variable_config`
568    // plus (eventually) a `CaproniConfig` YAML decoder that has not been ported
569    // as a standalone function yet, so this constructs the expected
570    // post-decode state directly and asserts the ordering contract the real
571    // decoder must uphold.
572    #[test]
573    fn caproni_config_variables_preserve_insertion_order() {
574        let mut vars: OrderedMap<String, VariableConfig> = OrderedMap::new();
575        vars.set(
576            "HOSTNAME".into(),
577            VariableConfig {
578                var_type: VariableType::Value,
579                value: "foo.bar.gitlab.net".into(),
580                path: String::new(),
581            },
582        );
583        vars.set(
584            "PRIMARY".into(),
585            VariableConfig {
586                var_type: VariableType::Value,
587                value: "primary.example.com".into(),
588                path: String::new(),
589            },
590        );
591
592        let cfg = CaproniConfig {
593            version: 2,
594            variables: Some(vars),
595            ..Default::default()
596        };
597        let vars = cfg.variables.expect("variables present");
598        assert_eq!(vars.len(), 2);
599
600        let hostname = vars.get(&"HOSTNAME".to_string()).expect("HOSTNAME present");
601        assert_eq!(hostname.value, "foo.bar.gitlab.net");
602
603        assert_eq!(
604            vars.keys(),
605            vec!["HOSTNAME".to_string(), "PRIMARY".to_string()]
606        );
607    }
608
609    // New test (not in Go originals as a standalone case, but required to
610    // unblock `config::registry::load_and_upgrade`): a full `CaproniConfig`
611    // YAML document, exercising every nested struct's `Deserialize`,
612    // including the `type`-discriminated `deployers`/`reloaders` maps and
613    // the `VariableConfig` scalar-vs-mapping shorthand.
614    #[test]
615    fn caproni_config_deserializes_from_full_yaml_document() {
616        let yaml = r#"
617version: 2
618cluster:
619  driver: k3d
620  k3d:
621    servers: 2
622  network:
623    hostname: gitlab.example.com
624    additional_hostnames:
625      - registry.example.com
626repositories:
627  gitlab:
628    repository: https://gitlab.com/gitlab-org/gitlab.git
629    directory: gitlab
630    ref: main
631    edit_mode: true
632deployers:
633  gitlab-helm:
634    type: helm
635    repository: gitlab
636    needs: []
637    helm:
638      namespace: gitlab
639      release:
640        name: gitlab
641        chart: gitlab/gitlab
642  gitlab-ns:
643    type: namespace
644    namespace:
645      namespaces:
646        - gitlab
647reloaders:
648  gitlab-mirror:
649    type: mirror
650    repository: gitlab
651    mirror:
652      target:
653        namespace: gitlab
654        kubernetes_type: deployment
655        name: gitlab
656      edit_mode_processes:
657        web:
658          command: run.sh
659variables:
660  HOSTNAME: gitlab.example.com
661  GITALY_VERSION:
662    type: file
663    path: ../GITALY_SERVER_VERSION
664"#;
665
666        let cfg: CaproniConfig = serde_yaml::from_str(yaml).expect("full document should decode");
667
668        assert_eq!(cfg.version, 2);
669        assert_eq!(cfg.cluster.driver, "k3d");
670        assert_eq!(cfg.cluster.k3d.as_ref().unwrap().servers, 2);
671        assert_eq!(
672            cfg.cluster.network.as_ref().unwrap().hostname,
673            "gitlab.example.com"
674        );
675
676        let repos = cfg.repositories.expect("repositories present");
677        let gitlab_repo = repos.get(&"gitlab".to_string()).expect("gitlab repo");
678        assert_eq!(
679            gitlab_repo.repository,
680            "https://gitlab.com/gitlab-org/gitlab.git"
681        );
682        assert_eq!(gitlab_repo.ref_, "main");
683        assert!(gitlab_repo.edit_mode);
684
685        let deployers = cfg.deployers.expect("deployers present");
686        let helm_deployer = deployers
687            .get(&"gitlab-helm".to_string())
688            .expect("helm deployer");
689        assert_eq!(helm_deployer.deployer_type, "helm");
690        assert_eq!(helm_deployer.repository.as_deref(), Some("gitlab"));
691        let helm = helm_deployer.helm.as_ref().expect("helm block present");
692        assert_eq!(helm.namespace, "gitlab");
693        assert_eq!(helm.release.as_ref().unwrap().chart, "gitlab/gitlab");
694
695        let ns_deployer = deployers
696            .get(&"gitlab-ns".to_string())
697            .expect("namespace deployer");
698        assert_eq!(ns_deployer.deployer_type, "namespace");
699        assert_eq!(
700            ns_deployer.namespace.as_ref().unwrap().namespaces,
701            vec!["gitlab".to_string()]
702        );
703
704        let reloaders = cfg.reloaders.expect("reloaders present");
705        let mirror_reloader = reloaders
706            .get(&"gitlab-mirror".to_string())
707            .expect("mirror reloader");
708        assert_eq!(mirror_reloader.reloader_type, "mirror");
709        let mirror = mirror_reloader
710            .mirror
711            .as_ref()
712            .expect("mirror block present");
713        let target = mirror.target.as_ref().expect("target present");
714        assert_eq!(target.kubernetes_type, "deployment");
715        let processes = mirror
716            .edit_mode_processes
717            .as_ref()
718            .expect("edit_mode_processes present");
719        assert_eq!(processes.get(&"web".to_string()).unwrap().command, "run.sh");
720
721        let variables = cfg.variables.expect("variables present");
722        let hostname_var = variables.get(&"HOSTNAME".to_string()).unwrap();
723        assert_eq!(hostname_var.var_type, VariableType::Value);
724        assert_eq!(hostname_var.value, "gitlab.example.com");
725
726        let gitaly_var = variables.get(&"GITALY_VERSION".to_string()).unwrap();
727        assert_eq!(gitaly_var.var_type, VariableType::File);
728        assert_eq!(gitaly_var.path, "../GITALY_SERVER_VERSION");
729    }
730
731    // Port of TestLatestUpgradeIsTerminalNoOp (internal/config/latest/upgrade_test.go).
732    #[test]
733    fn latest_upgrade_is_terminal_noop() {
734        let cfg = CaproniConfig {
735            version: 2,
736            ..Default::default()
737        };
738        let next = cfg.upgrade().expect("upgrade must not error");
739        // The Go test asserts pointer identity (`require.Same`); Rust's
740        // `Upgrader::upgrade` signature returns an owned `Box<dyn Upgrader>`,
741        // so identity is not directly expressible. See PORT_NOTES.md for the
742        // adaptation this forces once `upgrade` is implemented.
743        let _ = next;
744    }
745}