Skip to main content

greentic_dev/
release_cmd.rs

1use std::collections::BTreeMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::str::FromStr;
5
6use anyhow::{Context, Result, anyhow, bail};
7use async_trait::async_trait;
8use oci_distribution::Reference;
9use oci_distribution::client::{Client, ClientConfig, ClientProtocol, Config, ImageLayer};
10use oci_distribution::secrets::RegistryAuth;
11use semver::Version;
12use serde::{Deserialize, Serialize};
13use time::OffsetDateTime;
14use time::format_description::well_known::Rfc3339;
15
16use crate::cli::{
17    ReleaseGenerateArgs, ReleaseLatestArgs, ReleasePromoteArgs, ReleasePublishArgs,
18    ReleaseSnapshotArgs, ReleaseViewArgs,
19};
20use crate::install::block_on_maybe_runtime;
21use crate::passthrough::{ToolchainChannel, delegated_binary_name_for_channel};
22use crate::toolchain_catalogue::{
23    GREENTIC_COMPONENT_PACKAGES, GREENTIC_EXTENSION_PACK_PACKAGES, GREENTIC_TOOLCHAIN_PACKAGES,
24    OciPackageSpec,
25};
26
27const DEFAULT_OAUTH_USER: &str = "oauth2";
28pub const TOOLCHAIN_MANIFEST_SCHEMA: &str = "greentic.toolchain-manifest.v1";
29pub const TOOLCHAIN_NAME: &str = "gtc";
30pub const TOOLCHAIN_LAYER_MEDIA_TYPE: &str = "application/vnd.greentic.toolchain.manifest.v1+json";
31const TOOLCHAIN_CONFIG_MEDIA_TYPE: &str = "application/vnd.greentic.toolchain.config.v1+json";
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
34pub struct ToolchainManifest {
35    pub schema: String,
36    pub toolchain: String,
37    pub version: String,
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub channel: Option<String>,
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub created_at: Option<String>,
42    pub packages: Vec<ToolchainPackage>,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub extension_packs: Option<Vec<ExtensionPackRef>>,
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub components: Option<Vec<ComponentRef>>,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
50pub struct ToolchainPackage {
51    #[serde(rename = "crate")]
52    pub crate_name: String,
53    pub bins: Vec<String>,
54    pub version: String,
55}
56
57#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
58pub struct ExtensionPackRef {
59    pub id: String,
60    pub version: String,
61}
62
63#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
64pub struct ComponentRef {
65    pub id: String,
66    pub version: String,
67}
68
69pub fn generate(args: ReleaseGenerateArgs) -> Result<()> {
70    let resolver = default_resolver();
71    let artifact_resolver = GhcrArtifactVersionResolver::new(args.token.as_deref())?;
72    let source = block_on_maybe_runtime(load_source_manifest(
73        &args.repo,
74        &args.from,
75        args.token.as_deref(),
76    ))
77    .with_context(|| {
78        format!(
79            "failed to resolve source manifest `{}`",
80            toolchain_ref(&args.repo, &args.from)
81        )
82    })?;
83    let source = match source {
84        Some(source) => Some(source),
85        None => bootstrap_source_manifest_if_needed(
86            &args.repo,
87            &args.from,
88            args.token.as_deref(),
89            args.dry_run,
90            &resolver,
91        )?,
92    };
93    let manifest = generate_manifest_with_artifact_resolver(
94        &args.release,
95        &args.from,
96        source.as_ref(),
97        &resolver,
98        &artifact_resolver,
99        Some(created_at_now()?),
100    )?;
101    if args.dry_run {
102        println!("{}", serde_json::to_string_pretty(&manifest)?);
103        return Ok(());
104    }
105    let path = write_manifest(&args.out, &manifest)?;
106    println!("Wrote {}", path.display());
107    Ok(())
108}
109
110fn bootstrap_source_manifest_if_needed<R: CrateVersionResolver>(
111    repo: &str,
112    tag: &str,
113    token: Option<&str>,
114    dry_run: bool,
115    resolver: &R,
116) -> Result<Option<ToolchainManifest>> {
117    let manifest = bootstrap_source_manifest(tag, resolver, Some(created_at_now()?))?;
118    if dry_run {
119        eprintln!(
120            "Dry run: would bootstrap missing source manifest {}",
121            toolchain_ref(repo, tag)
122        );
123        return Ok(Some(manifest));
124    }
125
126    let auth = match optional_registry_auth(token)? {
127        RegistryAuth::Anonymous => {
128            eprintln!(
129                "Source manifest {} is missing; no GHCR token is available, so only the local release manifest will be generated.",
130                toolchain_ref(repo, tag)
131            );
132            return Ok(Some(manifest));
133        }
134        auth => auth,
135    };
136    block_on_maybe_runtime(async {
137        let client = oci_client();
138        let source_ref = parse_reference(repo, tag)?;
139        push_manifest_layer(&client, &source_ref, &auth, &manifest).await
140    })
141    .with_context(|| format!("failed to bootstrap {}", toolchain_ref(repo, tag)))?;
142    println!("Bootstrapped {}", toolchain_ref(repo, tag));
143    Ok(Some(manifest))
144}
145
146fn bootstrap_source_manifest<R: CrateVersionResolver>(
147    tag: &str,
148    resolver: &R,
149    created_at: Option<String>,
150) -> Result<ToolchainManifest> {
151    generate_manifest(tag, tag, None, resolver, created_at)
152}
153
154pub fn publish(args: ReleasePublishArgs) -> Result<()> {
155    let (release, manifest, source) = publish_manifest_input(&args)?;
156
157    if args.dry_run {
158        println!(
159            "Dry run: would publish {}",
160            toolchain_ref(&args.repo, &release)
161        );
162        if let Some(tag) = &args.tag {
163            println!(
164                "Dry run: would tag {} as {}",
165                toolchain_ref(&args.repo, &release),
166                toolchain_ref(&args.repo, tag)
167            );
168        }
169        return Ok(());
170    }
171
172    let auth = registry_auth(args.token.as_deref())?;
173    block_on_maybe_runtime(async {
174        let client = oci_client();
175        let release_ref = parse_reference(&args.repo, &release)?;
176        if !args.force && manifest_exists(&client, &release_ref, &auth).await? {
177            bail!(
178                "release tag `{}` already exists; pass --force to overwrite it",
179                toolchain_ref(&args.repo, &release)
180            );
181        }
182        push_manifest_layer(&client, &release_ref, &auth, &manifest).await?;
183        if let Some(tag) = &args.tag {
184            let tag_ref = parse_reference(&args.repo, tag)?;
185            push_manifest_layer(&client, &tag_ref, &auth, &manifest).await?;
186        }
187        Ok(())
188    })?;
189
190    if let Some(source) = source {
191        match source {
192            PublishManifestSource::Generated(path) => println!("Wrote {}", path.display()),
193            PublishManifestSource::Local(path) => println!("Read {}", path.display()),
194        }
195    }
196    println!("Published {}", toolchain_ref(&args.repo, &release));
197    if let Some(tag) = &args.tag {
198        println!("Updated {}", toolchain_ref(&args.repo, tag));
199    }
200    Ok(())
201}
202
203#[derive(Debug, Clone, PartialEq, Eq)]
204enum PublishManifestSource {
205    Generated(PathBuf),
206    Local(PathBuf),
207}
208
209fn publish_manifest_input(
210    args: &ReleasePublishArgs,
211) -> Result<(String, ToolchainManifest, Option<PublishManifestSource>)> {
212    if let Some(path) = &args.manifest {
213        let mut manifest = read_manifest_file(path)?;
214        validate_manifest(&manifest)?;
215        let release = if let Some(release) = &args.release {
216            manifest.version = release.clone();
217            release.clone()
218        } else {
219            manifest.version.clone()
220        };
221        return Ok((
222            release,
223            manifest,
224            Some(PublishManifestSource::Local(path.clone())),
225        ));
226    }
227
228    let release = args
229        .release
230        .as_deref()
231        .context("pass --release or --manifest")?;
232    let from = args.from.as_deref().unwrap_or("latest");
233    let resolver = default_resolver();
234    let source = block_on_maybe_runtime(load_source_manifest(
235        &args.repo,
236        from,
237        args.token.as_deref(),
238    ))
239    .with_context(|| {
240        format!(
241            "failed to resolve source manifest `{}`",
242            toolchain_ref(&args.repo, from)
243        )
244    })?;
245    if let Some(source_manifest) = source.as_ref()
246        && source_manifest_has_concrete_pins(source_manifest)
247    {
248        eprintln!(
249            "warning: `release publish --from {from}` reuses the pinned versions in `{}` instead \
250             of querying crates.io. To refresh a channel from the latest crates.io versions, use \
251             `release snapshot --channel <dev|research|stable>`. To copy an existing release tag without \
252             re-resolving, use `release promote`. The conflated `--from` semantics will be \
253             removed in a future release.",
254            toolchain_ref(&args.repo, from),
255        );
256    }
257    let manifest = generate_manifest(
258        release,
259        from,
260        source.as_ref(),
261        &resolver,
262        Some(created_at_now()?),
263    )?;
264    let path = if args.dry_run {
265        println!("{}", serde_json::to_string_pretty(&manifest)?);
266        None
267    } else {
268        Some(PublishManifestSource::Generated(write_manifest(
269            &args.out, &manifest,
270        )?))
271    };
272    Ok((release.to_string(), manifest, path))
273}
274
275/// True when the source manifest has at least one package pinned to a concrete
276/// (non-`"latest"`) version. Used to detect the case where `release publish
277/// --from <X>` would silently copy old pins instead of re-resolving — see the
278/// deprecation warning emitted from `publish_manifest_input`.
279fn source_manifest_has_concrete_pins(manifest: &ToolchainManifest) -> bool {
280    manifest
281        .packages
282        .iter()
283        .any(|package| package.version != "latest")
284}
285
286fn read_manifest_file(path: &Path) -> Result<ToolchainManifest> {
287    let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
288    serde_json::from_slice(&bytes).with_context(|| format!("failed to parse {}", path.display()))
289}
290
291pub fn promote(args: ReleasePromoteArgs) -> Result<()> {
292    if args.dry_run {
293        println!(
294            "Dry run: would promote {} to {}",
295            toolchain_ref(&args.repo, &args.release),
296            toolchain_ref(&args.repo, &args.tag)
297        );
298        return Ok(());
299    }
300
301    let auth = registry_auth(args.token.as_deref())?;
302    block_on_maybe_runtime(async {
303        let client = oci_client();
304        let source_ref = parse_reference(&args.repo, &args.release)?;
305        let target_ref = parse_reference(&args.repo, &args.tag)?;
306        let (manifest, _) = client
307            .pull_manifest(&source_ref, &auth)
308            .await
309            .with_context(|| {
310                format!(
311                    "failed to resolve source release `{}`",
312                    toolchain_ref(&args.repo, &args.release)
313                )
314            })?;
315        client
316            .push_manifest(&target_ref, &manifest)
317            .await
318            .with_context(|| {
319                format!(
320                    "failed to update tag `{}`",
321                    toolchain_ref(&args.repo, &args.tag)
322                )
323            })?;
324        Ok(())
325    })?;
326    println!(
327        "Promoted {} to {}",
328        toolchain_ref(&args.repo, &args.release),
329        toolchain_ref(&args.repo, &args.tag)
330    );
331    Ok(())
332}
333
334/// Snapshot the current crates.io state into a new toolchain manifest.
335///
336/// Unlike `publish --from <X>`, snapshot **never** reads an existing manifest
337/// and **always** queries the resolver. That makes it safe to call repeatedly
338/// to refresh a channel — `:dev` after each nightly publish, `:stable` after
339/// a weekly release — without the promote-vs-snapshot conflation that bit
340/// callers of `publish --from dev`.
341pub fn snapshot(args: ReleaseSnapshotArgs) -> Result<()> {
342    let channel = parse_channel(&args.channel)?;
343    let resolver = CratesIoApiVersionResolver::default();
344    let manifest = snapshot_manifest(&args.release, channel, &resolver, Some(created_at_now()?))?;
345
346    if args.dry_run {
347        println!("{}", serde_json::to_string_pretty(&manifest)?);
348        println!(
349            "Dry run: would publish {}",
350            toolchain_ref(&args.repo, &args.release)
351        );
352        if let Some(tag) = &args.tag {
353            println!(
354                "Dry run: would tag {} as {}",
355                toolchain_ref(&args.repo, &args.release),
356                toolchain_ref(&args.repo, tag)
357            );
358        }
359        return Ok(());
360    }
361
362    let path = write_manifest(&args.out, &manifest)?;
363    println!("Wrote {}", path.display());
364
365    let auth = registry_auth(args.token.as_deref())?;
366    block_on_maybe_runtime(async {
367        let client = oci_client();
368        let release_ref = parse_reference(&args.repo, &args.release)?;
369        if !args.force && manifest_exists(&client, &release_ref, &auth).await? {
370            bail!(
371                "release tag `{}` already exists; pass --force to overwrite it",
372                toolchain_ref(&args.repo, &args.release)
373            );
374        }
375        push_manifest_layer(&client, &release_ref, &auth, &manifest).await?;
376        if let Some(tag) = &args.tag {
377            let tag_ref = parse_reference(&args.repo, tag)?;
378            push_manifest_layer(&client, &tag_ref, &auth, &manifest).await?;
379        }
380        Ok(())
381    })?;
382    println!("Published {}", toolchain_ref(&args.repo, &args.release));
383    if let Some(tag) = &args.tag {
384        println!("Updated {}", toolchain_ref(&args.repo, tag));
385    }
386    Ok(())
387}
388
389fn parse_channel(channel: &str) -> Result<ToolchainChannel> {
390    match channel {
391        "dev" | "development" => Ok(ToolchainChannel::Development),
392        "rnd" | "research" => Ok(ToolchainChannel::Rnd),
393        "stable" => Ok(ToolchainChannel::Stable),
394        other => bail!(
395            "unknown channel `{other}` (expected `dev`, `research` (alias `rnd`), or `stable`); \
396             pass --channel dev for the dev lane, --channel research for the research lane, or \
397             --channel stable for the stable lane"
398        ),
399    }
400}
401
402fn channel_tag(channel: ToolchainChannel) -> &'static str {
403    match channel {
404        ToolchainChannel::Stable => "stable",
405        ToolchainChannel::Development => "dev",
406        ToolchainChannel::Rnd => "rnd",
407    }
408}
409
410/// Resolve the version for a single toolchain manifest entry on `channel`.
411/// Returns `Ok(None)` when the research channel has no `-rnd` build for the
412/// crate (skip it) so manifest assembly does not abort on the ~10 of 13
413/// toolchain crates that ship no research build.
414fn resolve_manifest_version<R: CrateVersionResolver>(
415    resolver: &R,
416    crate_in_manifest: &str,
417    channel: ToolchainChannel,
418) -> Result<Option<String>> {
419    if channel == ToolchainChannel::Rnd {
420        match resolver
421            .resolve_research_version(crate_in_manifest)
422            .with_context(|| {
423                format!("failed to resolve research version for `{crate_in_manifest}`")
424            })? {
425            ResearchVersion::Pinned(version) => Ok(Some(version)),
426            ResearchVersion::Absent => {
427                eprintln!(
428                    "note: `{crate_in_manifest}` has no research build on crates.io; \
429                     omitting it from the research toolchain manifest"
430                );
431                Ok(None)
432            }
433        }
434    } else {
435        resolver
436            .resolve_latest_for_channel(crate_in_manifest, channel)
437            .with_context(|| format!("failed to resolve latest version for `{crate_in_manifest}`"))
438            .map(Some)
439    }
440}
441
442pub fn snapshot_manifest<R: CrateVersionResolver>(
443    release: &str,
444    channel: ToolchainChannel,
445    resolver: &R,
446    created_at: Option<String>,
447) -> Result<ToolchainManifest> {
448    let from = channel_tag(channel);
449    let mut packages = Vec::new();
450    for package in GREENTIC_TOOLCHAIN_PACKAGES {
451        let crate_in_manifest = manifest_crate_name_for_source(from, package.crate_name);
452        let Some(version) = resolve_manifest_version(resolver, &crate_in_manifest, channel)? else {
453            continue;
454        };
455        packages.push(ToolchainPackage {
456            crate_name: crate_in_manifest,
457            bins: manifest_bins_for_source(from, package.bins),
458            version,
459        });
460    }
461    Ok(ToolchainManifest {
462        schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
463        toolchain: TOOLCHAIN_NAME.to_string(),
464        version: release.to_string(),
465        channel: Some(from.to_string()),
466        created_at,
467        packages,
468        extension_packs: None,
469        components: None,
470    })
471}
472
473pub fn view(args: ReleaseViewArgs) -> Result<()> {
474    let tag = release_view_tag(&args)?;
475    let manifest = block_on_maybe_runtime(load_source_manifest(
476        &args.repo,
477        &tag,
478        args.token.as_deref(),
479    ))
480    .with_context(|| {
481        format!(
482            "failed to resolve manifest `{}`",
483            toolchain_ref(&args.repo, &tag)
484        )
485    })?
486    .with_context(|| {
487        format!(
488            "manifest `{}` was not found or is not authorized for this token",
489            toolchain_ref(&args.repo, &tag)
490        )
491    })?;
492    println!("{}", serde_json::to_string_pretty(&manifest)?);
493    Ok(())
494}
495
496pub fn latest(args: ReleaseLatestArgs) -> Result<()> {
497    let manifest = latest_manifest(Some(created_at_now()?));
498    if args.dry_run {
499        println!("{}", serde_json::to_string_pretty(&manifest)?);
500        println!(
501            "Dry run: would publish {}",
502            toolchain_ref(&args.repo, "latest")
503        );
504        return Ok(());
505    }
506
507    let auth = registry_auth(args.token.as_deref())?;
508    block_on_maybe_runtime(async {
509        let client = oci_client();
510        let latest_ref = parse_reference(&args.repo, "latest")?;
511        if !args.force && manifest_exists(&client, &latest_ref, &auth).await? {
512            bail!(
513                "latest tag `{}` already exists; pass --force to overwrite it",
514                toolchain_ref(&args.repo, "latest")
515            );
516        }
517        push_manifest_layer(&client, &latest_ref, &auth, &manifest).await
518    })?;
519    println!("Published {}", toolchain_ref(&args.repo, "latest"));
520    Ok(())
521}
522
523fn latest_manifest(created_at: Option<String>) -> ToolchainManifest {
524    ToolchainManifest {
525        schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
526        toolchain: TOOLCHAIN_NAME.to_string(),
527        version: "latest".to_string(),
528        channel: Some("latest".to_string()),
529        created_at,
530        packages: latest_manifest_packages(),
531        extension_packs: Some(
532            GREENTIC_EXTENSION_PACK_PACKAGES
533                .iter()
534                .map(|package| ExtensionPackRef {
535                    id: package.package.to_string(),
536                    version: "latest".to_string(),
537                })
538                .collect(),
539        ),
540        components: Some(
541            GREENTIC_COMPONENT_PACKAGES
542                .iter()
543                .map(|package| ComponentRef {
544                    id: package.package.to_string(),
545                    version: "latest".to_string(),
546                })
547                .collect(),
548        ),
549    }
550}
551
552fn latest_manifest_packages() -> Vec<ToolchainPackage> {
553    std::iter::once(ToolchainPackage {
554        crate_name: delegated_binary_name_for_channel(
555            TOOLCHAIN_NAME,
556            ToolchainChannel::Development,
557        ),
558        bins: vec![delegated_binary_name_for_channel(
559            TOOLCHAIN_NAME,
560            ToolchainChannel::Development,
561        )],
562        version: "latest".to_string(),
563    })
564    .chain(GREENTIC_TOOLCHAIN_PACKAGES.iter().map(|package| {
565        ToolchainPackage {
566            crate_name: delegated_binary_name_for_channel(
567                package.crate_name,
568                ToolchainChannel::Development,
569            ),
570            bins: package
571                .bins
572                .iter()
573                .map(|bin| delegated_binary_name_for_channel(bin, ToolchainChannel::Development))
574                .collect(),
575            version: "latest".to_string(),
576        }
577    }))
578    .collect()
579}
580
581fn release_view_tag(args: &ReleaseViewArgs) -> Result<String> {
582    match (&args.release, &args.tag) {
583        (Some(release), None) => Ok(release.clone()),
584        (None, Some(tag)) => Ok(tag.clone()),
585        _ => bail!("pass exactly one of --release or --tag"),
586    }
587}
588
589pub fn generate_manifest<R: CrateVersionResolver>(
590    release: &str,
591    from: &str,
592    source: Option<&ToolchainManifest>,
593    resolver: &R,
594    created_at: Option<String>,
595) -> Result<ToolchainManifest> {
596    let artifact_resolver = ReleaseArtifactVersionResolver { release };
597    generate_manifest_with_artifact_resolver(
598        release,
599        from,
600        source,
601        resolver,
602        &artifact_resolver,
603        created_at,
604    )
605}
606
607pub fn generate_manifest_with_artifact_resolver<R, A>(
608    release: &str,
609    from: &str,
610    source: Option<&ToolchainManifest>,
611    resolver: &R,
612    artifact_resolver: &A,
613    created_at: Option<String>,
614) -> Result<ToolchainManifest>
615where
616    R: CrateVersionResolver,
617    A: ArtifactVersionResolver,
618{
619    if let Some(source) = source {
620        validate_manifest(source)?;
621    }
622    let source_versions = source_version_map(source);
623    let mut packages = Vec::new();
624    for package in GREENTIC_TOOLCHAIN_PACKAGES {
625        let crate_in_manifest = manifest_crate_name_for_source(from, package.crate_name);
626        let source_version = source_versions.get(&crate_in_manifest);
627        let version = match source_version.map(String::as_str) {
628            Some(version) if version != "latest" => Some(version.to_string()),
629            _ => resolve_manifest_version(
630                resolver,
631                &crate_in_manifest,
632                channel_from_source_tag(from),
633            )?,
634        };
635        let Some(version) = version else {
636            continue;
637        };
638        packages.push(ToolchainPackage {
639            crate_name: crate_in_manifest,
640            bins: manifest_bins_for_source(from, package.bins),
641            version,
642        });
643    }
644    Ok(ToolchainManifest {
645        schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
646        toolchain: TOOLCHAIN_NAME.to_string(),
647        version: release.to_string(),
648        channel: Some(from.to_string()),
649        created_at,
650        packages,
651        extension_packs: Some(extension_pack_refs_for_release(source, artifact_resolver)?),
652        components: Some(component_refs_for_release(source, artifact_resolver)?),
653    })
654}
655
656/// Map a manifest source-tag (`dev`/`rnd`/`stable`) to its channel.
657fn channel_from_source_tag(from: &str) -> ToolchainChannel {
658    match from {
659        "dev" => ToolchainChannel::Development,
660        "rnd" => ToolchainChannel::Rnd,
661        _ => ToolchainChannel::Stable,
662    }
663}
664
665fn manifest_bins_for_source(from: &str, bins: &[&str]) -> Vec<String> {
666    let channel = channel_from_source_tag(from);
667    bins.iter()
668        .map(|bin| delegated_binary_name_for_channel(bin, channel))
669        .collect()
670}
671
672fn extension_pack_refs_for_release<A: ArtifactVersionResolver>(
673    source: Option<&ToolchainManifest>,
674    artifact_resolver: &A,
675) -> Result<Vec<ExtensionPackRef>> {
676    let source_versions = source_ref_version_map(source.and_then(|manifest| {
677        manifest
678            .extension_packs
679            .as_ref()
680            .map(|refs| refs.iter().map(|item| (&item.id, &item.version)))
681    }));
682    GREENTIC_EXTENSION_PACK_PACKAGES
683        .iter()
684        .map(|package| {
685            Ok(ExtensionPackRef {
686                id: package.package.to_string(),
687                version: ref_version_for_package(package, &source_versions, artifact_resolver)?,
688            })
689        })
690        .collect()
691}
692
693fn component_refs_for_release<A: ArtifactVersionResolver>(
694    source: Option<&ToolchainManifest>,
695    artifact_resolver: &A,
696) -> Result<Vec<ComponentRef>> {
697    let source_versions = source_ref_version_map(source.and_then(|manifest| {
698        manifest
699            .components
700            .as_ref()
701            .map(|refs| refs.iter().map(|item| (&item.id, &item.version)))
702    }));
703    GREENTIC_COMPONENT_PACKAGES
704        .iter()
705        .map(|package| {
706            Ok(ComponentRef {
707                id: package.package.to_string(),
708                version: ref_version_for_package(package, &source_versions, artifact_resolver)?,
709            })
710        })
711        .collect()
712}
713
714fn source_ref_version_map<'a, I>(refs: Option<I>) -> BTreeMap<String, String>
715where
716    I: Iterator<Item = (&'a String, &'a String)>,
717{
718    let mut out = BTreeMap::new();
719    if let Some(refs) = refs {
720        for (id, version) in refs {
721            out.insert(id.clone(), version.clone());
722        }
723    }
724    out
725}
726
727fn ref_version_for_package(
728    package: &OciPackageSpec,
729    source_versions: &BTreeMap<String, String>,
730    artifact_resolver: &impl ArtifactVersionResolver,
731) -> Result<String> {
732    match source_versions.get(package.package).map(String::as_str) {
733        Some(version) if version != "latest" => Ok(version.to_string()),
734        _ => artifact_resolver
735            .resolve_latest(package.package)
736            .with_context(|| format!("failed to resolve GHCR version for `{}`", package.package)),
737    }
738}
739
740/// Apply the dev-channel `-dev` suffix to a crate name when the manifest
741/// channel is `"dev"`. The dev-publish lane mirrors every binary crate as
742/// `<crate>-dev` (binary bifurcation); the toolchain manifest must pin the
743/// mirrored crate so `cargo binstall` resolves the dev artifact instead of
744/// the stable one. Reuses `delegated_binary_name_for_channel` because the
745/// rule is identical for crates and binaries (`-dev` suffix, with the
746/// special carve-out that `greentic-dev` itself becomes `greentic-dev-dev`).
747fn manifest_crate_name_for_source(from: &str, crate_name: &str) -> String {
748    if from == "dev" {
749        delegated_binary_name_for_channel(crate_name, ToolchainChannel::Development)
750    } else {
751        crate_name.to_string()
752    }
753}
754
755pub fn validate_manifest(manifest: &ToolchainManifest) -> Result<()> {
756    if manifest.schema != TOOLCHAIN_MANIFEST_SCHEMA {
757        bail!(
758            "unsupported toolchain manifest schema `{}`",
759            manifest.schema
760        );
761    }
762    if manifest.toolchain != TOOLCHAIN_NAME {
763        bail!("unsupported toolchain `{}`", manifest.toolchain);
764    }
765    Ok(())
766}
767
768pub fn toolchain_ref(repo: &str, tag: &str) -> String {
769    format!("{repo}:{tag}")
770}
771
772fn source_version_map(source: Option<&ToolchainManifest>) -> BTreeMap<String, String> {
773    let mut out = BTreeMap::new();
774    if let Some(source) = source {
775        for package in &source.packages {
776            out.insert(package.crate_name.clone(), package.version.clone());
777        }
778    }
779    out
780}
781
782fn write_manifest(out_dir: &Path, manifest: &ToolchainManifest) -> Result<PathBuf> {
783    fs::create_dir_all(out_dir)
784        .with_context(|| format!("failed to create {}", out_dir.display()))?;
785    let path = out_dir.join(manifest_file_name(manifest));
786    let json = serde_json::to_vec_pretty(manifest).context("failed to serialize manifest")?;
787    fs::write(&path, json).with_context(|| format!("failed to write {}", path.display()))?;
788    Ok(path)
789}
790
791fn manifest_file_name(manifest: &ToolchainManifest) -> String {
792    match manifest.channel.as_deref() {
793        Some("stable") | None => format!("gtc-{}.json", manifest.version),
794        Some(channel) => format!("gtc-{channel}-{}.json", manifest.version),
795    }
796}
797
798fn created_at_now() -> Result<String> {
799    OffsetDateTime::now_utc()
800        .format(&Rfc3339)
801        .context("failed to format current time")
802}
803
804/// Outcome of resolving the research (`-rnd`) version of a toolchain crate.
805///
806/// Only `start`/`runner`/`setup` carry `-research` builds; the other ~10
807/// delegated toolchain crates have no `<name>-rnd` published. Resolving those
808/// must not be a hard error — it is an expected "no research build" signal that
809/// the caller turns into a skip, so the research channel still assembles.
810pub enum ResearchVersion {
811    /// The `<name>-rnd` crate is published; pin this exact version.
812    Pinned(String),
813    /// The `<name>-rnd` crate is not published on crates.io (HTTP 404). The
814    /// tool ships no research build and must be skipped on the research channel
815    /// rather than aborting the whole install / manifest assembly.
816    Absent,
817}
818
819pub trait CrateVersionResolver {
820    fn resolve_latest(&self, crate_name: &str) -> Result<String>;
821
822    /// Channel-aware resolution. The research (`rnd`) lane publishes base-name
823    /// crates at `X.Y.Z-research` PRERELEASES (greentic-runner's
824    /// research-publish.yml), which `resolve_latest`'s `max_stable_version`
825    /// preference silently skips — so the dev/stable behaviour returns the old
826    /// stable (e.g. `0.5.x`) instead of the current `1.2.0-research`. The
827    /// default delegates to `resolve_latest` (correct for dev/stable).
828    fn resolve_latest_for_channel(
829        &self,
830        crate_name: &str,
831        _channel: ToolchainChannel,
832    ) -> Result<String> {
833        self.resolve_latest(crate_name)
834    }
835
836    /// Resolve the research (`-rnd`) version, distinguishing an unpublished
837    /// crate (HTTP 404 → [`ResearchVersion::Absent`]) from a genuine resolution
838    /// error. The default treats every resolvable crate as
839    /// [`ResearchVersion::Pinned`]; only the crates.io resolver can observe a
840    /// 404, so it overrides this.
841    fn resolve_research_version(&self, crate_name: &str) -> Result<ResearchVersion> {
842        self.resolve_latest_for_channel(crate_name, ToolchainChannel::Rnd)
843            .map(ResearchVersion::Pinned)
844    }
845}
846
847/// Default resolver used by `generate`, `publish`, and `snapshot`. Hits the
848/// crates.io HTTP API directly — see `CratesIoApiVersionResolver` for why
849/// this is preferred over shelling out to `cargo search`.
850fn default_resolver() -> CratesIoApiVersionResolver {
851    CratesIoApiVersionResolver::default()
852}
853
854pub trait ArtifactVersionResolver {
855    fn resolve_latest(&self, package: &str) -> Result<String>;
856}
857
858const CRATES_IO_API_BASE: &str = "https://crates.io/api/v1/crates";
859const CRATES_IO_USER_AGENT: &str = concat!(
860    "greentic-dev/",
861    env!("CARGO_PKG_VERSION"),
862    " (https://github.com/greenticai/greentic-dev)"
863);
864
865/// Resolve the latest published version of a crate by hitting the crates.io
866/// HTTP API directly. Returns `max_stable_version` when present, falling back
867/// to `newest_version` and then `max_version`. Replaces an earlier
868/// `cargo search`-based resolver that ranked results by relevance and parsed
869/// stdout heuristically — both brittle for `<name>-dev` aliases that share
870/// prefixes with their stable parents.
871pub struct CratesIoApiVersionResolver {
872    base_url: String,
873    client: reqwest::blocking::Client,
874}
875
876impl Default for CratesIoApiVersionResolver {
877    fn default() -> Self {
878        Self::new(CRATES_IO_API_BASE)
879    }
880}
881
882impl CratesIoApiVersionResolver {
883    pub fn new(base_url: impl Into<String>) -> Self {
884        let client = reqwest::blocking::Client::builder()
885            .user_agent(CRATES_IO_USER_AGENT)
886            .build()
887            .expect("failed to build crates.io API client");
888        Self {
889            base_url: base_url.into(),
890            client,
891        }
892    }
893
894    /// GET the crates.io page for `crate_name`. `Ok(None)` when the crate is
895    /// absent (HTTP 404), `Ok(Some(body))` on success, `Err` on any other
896    /// status or transport failure. Lets callers treat "no such crate" as a
897    /// skip rather than a hard error.
898    fn fetch_crate_body(&self, crate_name: &str) -> Result<Option<String>> {
899        let url = format!("{}/{}", self.base_url.trim_end_matches('/'), crate_name);
900        let response = self
901            .client
902            .get(&url)
903            .send()
904            .with_context(|| format!("failed to GET {url}"))?;
905        let status = response.status();
906        let body = response
907            .text()
908            .with_context(|| format!("failed to read body of {url}"))?;
909        classify_crate_response(status, &url, body)
910    }
911}
912
913/// Classify a crates.io crate-page response by HTTP status: `Ok(None)` for a
914/// 404 (crate absent), `Ok(Some(body))` for success, `Err` otherwise. Pure so
915/// the 404-vs-error decision is unit-testable without a live HTTP round-trip.
916fn classify_crate_response(
917    status: reqwest::StatusCode,
918    url: &str,
919    body: String,
920) -> Result<Option<String>> {
921    if status == reqwest::StatusCode::NOT_FOUND {
922        return Ok(None);
923    }
924    if !status.is_success() {
925        bail!("crates.io API GET {url} returned {status}: {body}");
926    }
927    Ok(Some(body))
928}
929
930/// Pick the research version from a crates.io body, or fall back to the latest
931/// published version when no `-research` prerelease exists. Toolchain crates
932/// with no `-research` publish yet keep their latest build so the snapshot still
933/// assembles; the multi-provider-critical crates (runner/setup/start) carry a
934/// `-research` build. The fallback is logged so silent staleness stays visible.
935fn research_or_fallback(crate_name: &str, body: &str) -> Result<String> {
936    match parse_crates_io_research_version(crate_name, body) {
937        Ok(version) => Ok(version),
938        Err(_) => {
939            let fallback = pick_highest_crates_io_version(crate_name, body, false)?;
940            eprintln!(
941                "note: `{crate_name}` has no -research publish; the research \
942                 toolchain falls back to latest `{fallback}`"
943            );
944            Ok(fallback)
945        }
946    }
947}
948
949struct ReleaseArtifactVersionResolver<'a> {
950    release: &'a str,
951}
952
953impl ArtifactVersionResolver for ReleaseArtifactVersionResolver<'_> {
954    fn resolve_latest(&self, _package: &str) -> Result<String> {
955        Ok(self.release.to_string())
956    }
957}
958
959struct GhcrArtifactVersionResolver {
960    client: reqwest::blocking::Client,
961    registry: String,
962    namespace: String,
963    basic_token: Option<String>,
964}
965
966impl GhcrArtifactVersionResolver {
967    fn new(raw_token: Option<&str>) -> Result<Self> {
968        Ok(Self {
969            client: reqwest::blocking::Client::builder()
970                .build()
971                .context("failed to build GHCR HTTP client")?,
972            registry: "ghcr.io".to_string(),
973            namespace: "greenticai".to_string(),
974            basic_token: resolve_registry_token(raw_token)?
975                .or_else(|| std::env::var("GHCR_TOKEN").ok())
976                .or_else(|| std::env::var("GITHUB_TOKEN").ok()),
977        })
978    }
979
980    fn bearer_token(&self, repository: &str) -> Result<String> {
981        let scope = format!("repository:{repository}:pull");
982        let mut request = self
983            .client
984            .get(format!("https://{}/token", self.registry))
985            .query(&[
986                ("service", self.registry.as_str()),
987                ("scope", scope.as_str()),
988            ]);
989        if let Some(token) = &self.basic_token {
990            request = request.basic_auth(DEFAULT_OAUTH_USER, Some(token));
991        }
992        let response = request
993            .send()
994            .with_context(|| format!("failed to request GHCR token for `{repository}`"))?
995            .error_for_status()
996            .with_context(|| format!("GHCR token request failed for `{repository}`"))?;
997        let body: GhcrTokenResponse = response
998            .json()
999            .with_context(|| format!("failed to parse GHCR token response for `{repository}`"))?;
1000        Ok(body.token)
1001    }
1002
1003    fn tags(&self, repository: &str) -> Result<Vec<String>> {
1004        let token = self.bearer_token(repository)?;
1005        let response = self
1006            .client
1007            .get(format!(
1008                "https://{}/v2/{repository}/tags/list",
1009                self.registry
1010            ))
1011            .bearer_auth(token)
1012            .send()
1013            .with_context(|| format!("failed to list GHCR tags for `{repository}`"))?
1014            .error_for_status()
1015            .with_context(|| format!("GHCR tag list request failed for `{repository}`"))?;
1016        let body: GhcrTagsResponse = response
1017            .json()
1018            .with_context(|| format!("failed to parse GHCR tags for `{repository}`"))?;
1019        Ok(body.tags)
1020    }
1021}
1022
1023impl ArtifactVersionResolver for GhcrArtifactVersionResolver {
1024    fn resolve_latest(&self, package: &str) -> Result<String> {
1025        let repository = format!("{}/{}", self.namespace, package);
1026        let tags = self.tags(&repository)?;
1027        select_latest_artifact_tag(&tags)
1028            .with_context(|| format!("no usable tags found for GHCR package `{repository}`"))
1029    }
1030}
1031
1032#[derive(Deserialize)]
1033struct GhcrTokenResponse {
1034    token: String,
1035}
1036
1037#[derive(Deserialize)]
1038struct GhcrTagsResponse {
1039    #[serde(default)]
1040    tags: Vec<String>,
1041}
1042
1043fn select_latest_artifact_tag(tags: &[String]) -> Result<String> {
1044    tags.iter()
1045        .filter_map(|tag| Version::parse(tag).ok().map(|version| (version, tag)))
1046        .max_by(|(left, _), (right, _)| left.cmp(right))
1047        .map(|(_, tag)| tag.clone())
1048        .or_else(|| tags.iter().find(|tag| tag.as_str() == "latest").cloned())
1049        .context("no semver or latest tags found")
1050}
1051
1052impl CrateVersionResolver for CratesIoApiVersionResolver {
1053    fn resolve_latest(&self, crate_name: &str) -> Result<String> {
1054        let url = format!("{}/{}", self.base_url.trim_end_matches('/'), crate_name);
1055        let response = self
1056            .client
1057            .get(&url)
1058            .send()
1059            .with_context(|| format!("failed to GET {url}"))?;
1060        let status = response.status();
1061        let body = response
1062            .text()
1063            .with_context(|| format!("failed to read body of {url}"))?;
1064        if !status.is_success() {
1065            bail!("crates.io API GET {url} returned {status}: {body}");
1066        }
1067        parse_crates_io_version(crate_name, &body)
1068    }
1069
1070    fn resolve_latest_for_channel(
1071        &self,
1072        crate_name: &str,
1073        channel: ToolchainChannel,
1074    ) -> Result<String> {
1075        if channel != ToolchainChannel::Rnd {
1076            return self.resolve_latest(crate_name);
1077        }
1078        let url = format!("{}/{}", self.base_url.trim_end_matches('/'), crate_name);
1079        let body = self.fetch_crate_body(crate_name)?.ok_or_else(|| {
1080            anyhow!("crates.io API GET {url} returned 404 Not Found (no published `{crate_name}`)")
1081        })?;
1082        research_or_fallback(crate_name, &body)
1083    }
1084
1085    fn resolve_research_version(&self, crate_name: &str) -> Result<ResearchVersion> {
1086        // A 404 means the `<name>-rnd` crate is simply not published — the tool
1087        // ships no research build. Map it to `Absent` (a skip signal) instead of
1088        // aborting, so `gtc-research install` / manifest assembly survives the
1089        // ~10 of 13 toolchain crates that have no research line.
1090        match self.fetch_crate_body(crate_name)? {
1091            None => Ok(ResearchVersion::Absent),
1092            Some(body) => research_or_fallback(crate_name, &body).map(ResearchVersion::Pinned),
1093        }
1094    }
1095}
1096
1097/// Pick the highest non-yanked version from the crates.io `/crates/<name>`
1098/// response's top-level `versions` array. When `research_only`, restricts to
1099/// `-research` prereleases (the research lane publishes `X.Y.Z-research`, which
1100/// `max_stable_version` skips). Otherwise picks the highest semver of ANY
1101/// channel — the fallback for toolchain crates with no `-research` build, which
1102/// keeps them at their latest dev build instead of regressing to old stable.
1103fn pick_highest_crates_io_version(
1104    crate_name: &str,
1105    body: &str,
1106    research_only: bool,
1107) -> Result<String> {
1108    let payload: serde_json::Value = serde_json::from_str(body)
1109        .with_context(|| format!("crates.io API for `{crate_name}` returned invalid JSON"))?;
1110    let versions = payload
1111        .get("versions")
1112        .and_then(|v| v.as_array())
1113        .ok_or_else(|| {
1114            anyhow!("crates.io API for `{crate_name}` is missing the `versions` array")
1115        })?;
1116    let mut best: Option<Version> = None;
1117    for entry in versions {
1118        if entry
1119            .get("yanked")
1120            .and_then(serde_json::Value::as_bool)
1121            .unwrap_or(false)
1122        {
1123            continue;
1124        }
1125        let Some(num) = entry.get("num").and_then(|n| n.as_str()) else {
1126            continue;
1127        };
1128        let Ok(parsed) = Version::parse(num) else {
1129            continue;
1130        };
1131        if research_only && !parsed.pre.as_str().starts_with("research") {
1132            continue;
1133        }
1134        if best.as_ref().is_none_or(|current| parsed > *current) {
1135            best = Some(parsed);
1136        }
1137    }
1138    best.map(|v| v.to_string()).ok_or_else(|| {
1139        let what = if research_only {
1140            "no non-yanked `-research` version"
1141        } else {
1142            "no non-yanked versions"
1143        };
1144        anyhow!("crates.io API for `{crate_name}` exposes {what}")
1145    })
1146}
1147
1148fn parse_crates_io_research_version(crate_name: &str, body: &str) -> Result<String> {
1149    pick_highest_crates_io_version(crate_name, body, true)
1150}
1151
1152fn parse_crates_io_version(crate_name: &str, body: &str) -> Result<String> {
1153    let payload: serde_json::Value = serde_json::from_str(body)
1154        .with_context(|| format!("crates.io API for `{crate_name}` returned invalid JSON"))?;
1155    let crate_obj = payload.get("crate").ok_or_else(|| {
1156        anyhow!("crates.io API for `{crate_name}` is missing the top-level `crate` object")
1157    })?;
1158    let version = crate_obj
1159        .get("max_stable_version")
1160        .and_then(|v| v.as_str())
1161        .or_else(|| crate_obj.get("newest_version").and_then(|v| v.as_str()))
1162        .or_else(|| crate_obj.get("max_version").and_then(|v| v.as_str()))
1163        .ok_or_else(|| {
1164            anyhow!(
1165                "crates.io API for `{crate_name}` does not expose max_stable_version, \
1166                 newest_version, or max_version"
1167            )
1168        })?;
1169    Version::parse(version).with_context(|| {
1170        format!("crates.io returned an unparseable version `{version}` for `{crate_name}`")
1171    })?;
1172    Ok(version.to_string())
1173}
1174
1175#[async_trait]
1176trait ToolchainManifestSource {
1177    async fn load_manifest(
1178        &self,
1179        repo: &str,
1180        tag: &str,
1181        token: Option<&str>,
1182    ) -> Result<Option<ToolchainManifest>>;
1183}
1184
1185struct OciToolchainManifestSource;
1186
1187#[async_trait]
1188impl ToolchainManifestSource for OciToolchainManifestSource {
1189    async fn load_manifest(
1190        &self,
1191        repo: &str,
1192        tag: &str,
1193        token: Option<&str>,
1194    ) -> Result<Option<ToolchainManifest>> {
1195        let auth = optional_registry_auth(token)?;
1196        let client = oci_client();
1197        let reference = parse_reference(repo, tag)?;
1198        let image = match client
1199            .pull(&reference, &auth, vec![TOOLCHAIN_LAYER_MEDIA_TYPE])
1200            .await
1201        {
1202            Ok(image) => image,
1203            Err(err) if is_missing_manifest_error(&err) || is_unauthorized_error(&err) => {
1204                return Ok(None);
1205            }
1206            Err(err) => {
1207                return Err(err)
1208                    .with_context(|| format!("failed to pull {}", toolchain_ref(repo, tag)));
1209            }
1210        };
1211        let Some(layer) = image
1212            .layers
1213            .into_iter()
1214            .find(|layer| layer.media_type == TOOLCHAIN_LAYER_MEDIA_TYPE)
1215        else {
1216            return Ok(None);
1217        };
1218        let manifest = serde_json::from_slice::<ToolchainManifest>(&layer.data)
1219            .with_context(|| format!("failed to parse {}", toolchain_ref(repo, tag)))?;
1220        validate_manifest(&manifest)?;
1221        Ok(Some(manifest))
1222    }
1223}
1224
1225async fn load_source_manifest(
1226    repo: &str,
1227    tag: &str,
1228    token: Option<&str>,
1229) -> Result<Option<ToolchainManifest>> {
1230    OciToolchainManifestSource
1231        .load_manifest(repo, tag, token)
1232        .await
1233}
1234
1235fn oci_client() -> Client {
1236    Client::new(ClientConfig {
1237        protocol: ClientProtocol::Https,
1238        ..Default::default()
1239    })
1240}
1241
1242fn registry_auth(raw_token: Option<&str>) -> Result<RegistryAuth> {
1243    let token = resolve_registry_token(raw_token)?
1244        .or_else(|| std::env::var("GHCR_TOKEN").ok())
1245        .or_else(|| std::env::var("GITHUB_TOKEN").ok())
1246        .context("GHCR token is required; pass --token or set GHCR_TOKEN/GITHUB_TOKEN")?;
1247    if token.trim().is_empty() {
1248        bail!("GHCR token is empty");
1249    }
1250    Ok(RegistryAuth::Basic(DEFAULT_OAUTH_USER.to_string(), token))
1251}
1252
1253fn optional_registry_auth(raw_token: Option<&str>) -> Result<RegistryAuth> {
1254    match registry_auth(raw_token) {
1255        Ok(auth) => Ok(auth),
1256        Err(_) if raw_token.is_none() => Ok(RegistryAuth::Anonymous),
1257        Err(err) => Err(err),
1258    }
1259}
1260
1261fn resolve_registry_token(raw_token: Option<&str>) -> Result<Option<String>> {
1262    let Some(raw_token) = raw_token else {
1263        return Ok(None);
1264    };
1265    if let Some(var) = raw_token.strip_prefix("env:") {
1266        let token =
1267            std::env::var(var).with_context(|| format!("failed to resolve env var {var}"))?;
1268        if token.trim().is_empty() {
1269            bail!("env var {var} resolved to an empty token");
1270        }
1271        return Ok(Some(token));
1272    }
1273    if raw_token.trim().is_empty() {
1274        bail!("GHCR token is empty");
1275    }
1276    Ok(Some(raw_token.to_string()))
1277}
1278
1279fn parse_reference(repo: &str, tag: &str) -> Result<Reference> {
1280    Reference::from_str(&toolchain_ref(repo, tag))
1281        .with_context(|| format!("invalid OCI reference `{}`", toolchain_ref(repo, tag)))
1282}
1283
1284async fn manifest_exists(
1285    client: &Client,
1286    reference: &Reference,
1287    auth: &RegistryAuth,
1288) -> Result<bool> {
1289    match client.pull_manifest(reference, auth).await {
1290        Ok(_) => Ok(true),
1291        Err(err) if is_missing_manifest_error(&err) => Ok(false),
1292        Err(err) => Err(err).context("failed to check whether release tag exists"),
1293    }
1294}
1295
1296fn is_missing_manifest_error(err: &oci_distribution::errors::OciDistributionError) -> bool {
1297    let msg = err.to_string().to_ascii_lowercase();
1298    msg.contains("manifest unknown")
1299        || msg.contains("name unknown")
1300        || msg.contains("not found")
1301        || msg.contains("404")
1302}
1303
1304fn is_unauthorized_error(err: &oci_distribution::errors::OciDistributionError) -> bool {
1305    let msg = err.to_string().to_ascii_lowercase();
1306    msg.contains("not authorized") || msg.contains("unauthorized") || msg.contains("401")
1307}
1308
1309async fn push_manifest_layer(
1310    client: &Client,
1311    reference: &Reference,
1312    auth: &RegistryAuth,
1313    manifest: &ToolchainManifest,
1314) -> Result<()> {
1315    let data = serde_json::to_vec_pretty(manifest).context("failed to serialize manifest")?;
1316    let layer = ImageLayer::new(data, TOOLCHAIN_LAYER_MEDIA_TYPE.to_string(), None);
1317    let config = Config::new(
1318        br#"{"toolchain":"gtc"}"#.to_vec(),
1319        TOOLCHAIN_CONFIG_MEDIA_TYPE.to_string(),
1320        None,
1321    );
1322    client
1323        .push(reference, &[layer], config, auth, None)
1324        .await
1325        .context("failed to push toolchain manifest")?;
1326    Ok(())
1327}
1328
1329#[cfg(test)]
1330mod tests {
1331    use super::*;
1332    use once_cell::sync::Lazy;
1333    use std::sync::Mutex;
1334
1335    static ENV_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
1336
1337    struct FixedResolver;
1338
1339    impl CrateVersionResolver for FixedResolver {
1340        fn resolve_latest(&self, crate_name: &str) -> Result<String> {
1341            Ok(match crate_name {
1342                "greentic-runner" => "0.5.10",
1343                _ => "1.2.3",
1344            }
1345            .to_string())
1346        }
1347    }
1348
1349    struct FixedArtifactResolver;
1350
1351    impl ArtifactVersionResolver for FixedArtifactResolver {
1352        fn resolve_latest(&self, package: &str) -> Result<String> {
1353            Ok(match package {
1354                "packs/messaging/messaging-webchat-gui" => "0.4.93",
1355                "components/component-adaptive-card" => "0.5.8",
1356                _ => "0.1.0",
1357            }
1358            .to_string())
1359        }
1360    }
1361
1362    #[test]
1363    fn parses_crates_io_max_stable_version() {
1364        let body = r#"{"crate":{"id":"greentic-operator-dev","max_stable_version":"0.5.123"}}"#;
1365        let version = parse_crates_io_version("greentic-operator-dev", body).unwrap();
1366        assert_eq!(version, "0.5.123");
1367    }
1368
1369    #[test]
1370    fn research_resolver_picks_highest_non_yanked_research_prerelease() {
1371        // The research lane must ignore the stable `max_stable_version` (0.5.48)
1372        // and pick the highest non-yanked `-research` prerelease.
1373        let body = r#"{"crate":{"id":"greentic-runner","max_stable_version":"0.5.48"},
1374            "versions":[
1375                {"num":"0.5.48","yanked":false},
1376                {"num":"1.2.0-research.0","yanked":false},
1377                {"num":"1.2.0-research.1","yanked":false},
1378                {"num":"1.2.0-research.2","yanked":true}
1379            ]}"#;
1380        let version = parse_crates_io_research_version("greentic-runner", body).unwrap();
1381        assert_eq!(version, "1.2.0-research.1");
1382    }
1383
1384    #[test]
1385    fn research_resolver_errors_when_no_research_version() {
1386        let body = r#"{"crate":{"id":"greentic-setup"},
1387            "versions":[{"num":"1.2.0-dev.123","yanked":false},{"num":"0.5.25","yanked":false}]}"#;
1388        let err = parse_crates_io_research_version("greentic-setup", body).unwrap_err();
1389        assert!(err.to_string().contains("no non-yanked `-research`"));
1390    }
1391
1392    /// Mirrors the real fleet: only start/runner/setup ship `-research` crates,
1393    /// so any `operator` crate resolves to `Absent` (a skip), everything else to
1394    /// a pinned research version.
1395    struct ResearchSkipResolver;
1396
1397    impl CrateVersionResolver for ResearchSkipResolver {
1398        fn resolve_latest(&self, _crate_name: &str) -> Result<String> {
1399            Ok("1.2.0-research.4".to_string())
1400        }
1401
1402        fn resolve_research_version(&self, crate_name: &str) -> Result<ResearchVersion> {
1403            if crate_name.contains("operator") {
1404                Ok(ResearchVersion::Absent)
1405            } else {
1406                Ok(ResearchVersion::Pinned("1.2.0-research.4".to_string()))
1407            }
1408        }
1409    }
1410
1411    #[test]
1412    fn classify_crate_response_maps_404_to_absent() {
1413        let outcome =
1414            classify_crate_response(reqwest::StatusCode::NOT_FOUND, "url", "missing".to_string())
1415                .unwrap();
1416        assert!(outcome.is_none(), "404 must classify as absent (None)");
1417    }
1418
1419    #[test]
1420    fn classify_crate_response_returns_body_on_success() {
1421        let outcome =
1422            classify_crate_response(reqwest::StatusCode::OK, "url", "payload".to_string()).unwrap();
1423        assert_eq!(outcome.as_deref(), Some("payload"));
1424    }
1425
1426    #[test]
1427    fn classify_crate_response_errors_on_other_status() {
1428        let err = classify_crate_response(
1429            reqwest::StatusCode::INTERNAL_SERVER_ERROR,
1430            "url",
1431            "boom".to_string(),
1432        )
1433        .unwrap_err();
1434        assert!(err.to_string().contains("500"));
1435    }
1436
1437    #[test]
1438    fn snapshot_manifest_skips_crates_without_research_build() {
1439        // The absent `operator` crate must be omitted, not abort the whole
1440        // research manifest — the regression behind .github#212's install hang.
1441        let manifest = snapshot_manifest(
1442            "1.2.0-research.4",
1443            ToolchainChannel::Rnd,
1444            &ResearchSkipResolver,
1445            None,
1446        )
1447        .unwrap();
1448        assert!(
1449            !manifest.packages.is_empty(),
1450            "research-built tools must remain in the manifest"
1451        );
1452        assert!(
1453            manifest.packages.len() < GREENTIC_TOOLCHAIN_PACKAGES.len(),
1454            "at least one tool without a research build must be skipped"
1455        );
1456        assert!(
1457            manifest
1458                .packages
1459                .iter()
1460                .all(|package| !package.crate_name.contains("operator")),
1461            "the absent `operator` crate must be omitted"
1462        );
1463        assert!(
1464            manifest
1465                .packages
1466                .iter()
1467                .all(|package| package.version == "1.2.0-research.4"),
1468            "remaining research tools pin their resolved -research version"
1469        );
1470    }
1471
1472    #[test]
1473    fn parses_crates_io_falls_back_to_newest_version() {
1474        let body = r#"{"crate":{"id":"greentic-flow-dev","newest_version":"0.6.7"}}"#;
1475        let version = parse_crates_io_version("greentic-flow-dev", body).unwrap();
1476        assert_eq!(version, "0.6.7");
1477    }
1478
1479    #[test]
1480    fn parses_crates_io_falls_back_to_max_version() {
1481        let body = r#"{"crate":{"id":"greentic-runner-dev","max_version":"0.4.99"}}"#;
1482        let version = parse_crates_io_version("greentic-runner-dev", body).unwrap();
1483        assert_eq!(version, "0.4.99");
1484    }
1485
1486    #[test]
1487    fn rejects_crates_io_payload_without_versions() {
1488        let body = r#"{"crate":{"id":"greentic-dev"}}"#;
1489        let err = parse_crates_io_version("greentic-dev", body).unwrap_err();
1490        assert!(
1491            err.to_string()
1492                .contains("does not expose max_stable_version")
1493        );
1494    }
1495
1496    #[test]
1497    fn rejects_crates_io_payload_with_unparseable_version() {
1498        let body = r#"{"crate":{"max_stable_version":"not-a-version"}}"#;
1499        let err = parse_crates_io_version("greentic-dev", body).unwrap_err();
1500        assert!(err.to_string().contains("unparseable version"));
1501    }
1502
1503    #[test]
1504    fn rejects_crates_io_payload_without_crate_object() {
1505        let body = r#"{"errors":[{"detail":"not found"}]}"#;
1506        let err = parse_crates_io_version("greentic-dev", body).unwrap_err();
1507        assert!(
1508            err.to_string()
1509                .contains("missing the top-level `crate` object")
1510        );
1511    }
1512
1513    #[test]
1514    fn selects_latest_semver_tag() {
1515        let tags = vec![
1516            "latest".to_string(),
1517            "0.4.93".to_string(),
1518            "0.4.9".to_string(),
1519            "1.0.0-beta.1".to_string(),
1520            "1.0.0".to_string(),
1521        ];
1522
1523        assert_eq!(select_latest_artifact_tag(&tags).unwrap(), "1.0.0");
1524    }
1525
1526    #[test]
1527    fn selects_latest_tag_when_no_semver_tags_exist() {
1528        let tags = vec!["latest".to_string()];
1529
1530        assert_eq!(select_latest_artifact_tag(&tags).unwrap(), "latest");
1531    }
1532
1533    #[test]
1534    fn generates_manifest_from_catalogue() {
1535        let manifest = generate_manifest("1.0.5", "latest", None, &FixedResolver, None).unwrap();
1536        assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
1537        assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
1538        assert_eq!(manifest.version, "1.0.5");
1539        assert_eq!(manifest.channel.as_deref(), Some("latest"));
1540        assert!(
1541            manifest
1542                .packages
1543                .iter()
1544                .any(|package| package.crate_name == "greentic-bundle"
1545                    && package.bins == ["greentic-bundle"])
1546        );
1547        assert!(
1548            manifest
1549                .packages
1550                .iter()
1551                .any(|package| package.crate_name == "greentic-runner"
1552                    && package.bins == ["greentic-runner"])
1553        );
1554        assert_eq!(manifest.extension_packs.as_ref().unwrap().len(), 81);
1555        assert_eq!(manifest.components.as_ref().unwrap().len(), 10);
1556        assert!(
1557            manifest
1558                .extension_packs
1559                .as_ref()
1560                .unwrap()
1561                .iter()
1562                .all(|item| item.version == "1.0.5")
1563        );
1564        assert!(
1565            manifest
1566                .components
1567                .as_ref()
1568                .unwrap()
1569                .iter()
1570                .all(|item| item.version == "1.0.5")
1571        );
1572    }
1573
1574    #[test]
1575    fn generated_manifest_can_use_artifact_resolver_versions() {
1576        let manifest = generate_manifest_with_artifact_resolver(
1577            "1.0.17",
1578            "stable",
1579            None,
1580            &FixedResolver,
1581            &FixedArtifactResolver,
1582            None,
1583        )
1584        .unwrap();
1585
1586        assert!(
1587            manifest
1588                .extension_packs
1589                .as_ref()
1590                .unwrap()
1591                .iter()
1592                .any(|item| item.id == "packs/messaging/messaging-webchat-gui"
1593                    && item.version == "0.4.93")
1594        );
1595        assert!(
1596            manifest
1597                .components
1598                .as_ref()
1599                .unwrap()
1600                .iter()
1601                .any(|item| item.id == "components/component-adaptive-card"
1602                    && item.version == "0.5.8")
1603        );
1604    }
1605
1606    #[test]
1607    fn source_manifest_can_pin_package_versions() {
1608        let source = ToolchainManifest {
1609            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
1610            toolchain: TOOLCHAIN_NAME.to_string(),
1611            version: "latest".to_string(),
1612            channel: Some("latest".to_string()),
1613            created_at: None,
1614            packages: vec![ToolchainPackage {
1615                crate_name: "greentic-dev".to_string(),
1616                bins: vec!["greentic-dev".to_string()],
1617                version: "0.5.9".to_string(),
1618            }],
1619            extension_packs: None,
1620            components: None,
1621        };
1622        let manifest =
1623            generate_manifest("1.0.5", "latest", Some(&source), &FixedResolver, None).unwrap();
1624        let greentic_dev = manifest
1625            .packages
1626            .iter()
1627            .find(|package| package.crate_name == "greentic-dev")
1628            .unwrap();
1629        assert_eq!(greentic_dev.version, "0.5.9");
1630    }
1631
1632    #[test]
1633    fn from_argument_controls_generated_channel_over_source_manifest() {
1634        let source = ToolchainManifest {
1635            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
1636            toolchain: TOOLCHAIN_NAME.to_string(),
1637            version: "latest".to_string(),
1638            channel: Some("stable".to_string()),
1639            created_at: None,
1640            packages: Vec::new(),
1641            extension_packs: None,
1642            components: None,
1643        };
1644        let manifest =
1645            generate_manifest("1.0.16", "dev", Some(&source), &FixedResolver, None).unwrap();
1646        assert_eq!(manifest.channel.as_deref(), Some("dev"));
1647        assert_eq!(manifest_file_name(&manifest), "gtc-dev-1.0.16.json");
1648    }
1649
1650    #[test]
1651    fn generate_from_dev_uses_dev_crate_and_binary_names() {
1652        let manifest = generate_manifest("1.0.16", "dev", None, &FixedResolver, None).unwrap();
1653        assert!(
1654            manifest
1655                .packages
1656                .iter()
1657                .flat_map(|package| package.bins.iter())
1658                .all(|bin| bin.ends_with("-dev"))
1659        );
1660        assert!(
1661            manifest
1662                .packages
1663                .iter()
1664                .all(|package| package.crate_name.ends_with("-dev")),
1665            "dev manifest must pin -dev crate names so binstall resolves the dev mirror"
1666        );
1667        assert!(manifest.packages.iter().any(|package| {
1668            package.crate_name == "greentic-flow-dev" && package.bins == ["greentic-flow-dev"]
1669        }));
1670        assert!(manifest.packages.iter().any(|package| {
1671            package.crate_name == "greentic-component-dev"
1672                && package.bins == ["greentic-component-dev"]
1673        }));
1674        assert!(manifest.packages.iter().any(|package| {
1675            package.crate_name == "greentic-dev-dev" && package.bins == ["greentic-dev-dev"]
1676        }));
1677    }
1678
1679    #[test]
1680    fn snapshot_manifest_dev_channel_resolves_dev_aliases() {
1681        let manifest =
1682            snapshot_manifest("1.1.5", ToolchainChannel::Development, &FixedResolver, None)
1683                .unwrap();
1684        assert_eq!(manifest.version, "1.1.5");
1685        assert_eq!(manifest.channel.as_deref(), Some("dev"));
1686        for package in &manifest.packages {
1687            assert!(
1688                package.crate_name.ends_with("-dev"),
1689                "dev snapshot must pin -dev crate names; got {}",
1690                package.crate_name
1691            );
1692            assert!(
1693                package.bins.iter().all(|bin| bin.ends_with("-dev")),
1694                "dev snapshot must pin -dev bin names; got {:?}",
1695                package.bins
1696            );
1697            assert_ne!(
1698                package.version, "latest",
1699                "snapshot must always resolve concrete versions"
1700            );
1701        }
1702        assert!(
1703            manifest
1704                .packages
1705                .iter()
1706                .any(|package| package.crate_name == "greentic-operator-dev")
1707        );
1708    }
1709
1710    #[test]
1711    fn snapshot_manifest_stable_channel_keeps_plain_names() {
1712        let manifest =
1713            snapshot_manifest("1.0.20", ToolchainChannel::Stable, &FixedResolver, None).unwrap();
1714        assert_eq!(manifest.channel.as_deref(), Some("stable"));
1715        // The stable channel must NOT apply the `-dev` suffix transform.
1716        // Cross-check against the catalogue: every stable package must match
1717        // a catalogue entry by exact name (no transform applied).
1718        let catalogue_names: std::collections::BTreeSet<_> = GREENTIC_TOOLCHAIN_PACKAGES
1719            .iter()
1720            .map(|spec| spec.crate_name)
1721            .collect();
1722        for package in &manifest.packages {
1723            assert!(
1724                catalogue_names.contains(package.crate_name.as_str()),
1725                "stable snapshot crate `{}` was transformed; expected a verbatim catalogue entry",
1726                package.crate_name
1727            );
1728        }
1729    }
1730
1731    #[test]
1732    fn snapshot_manifest_resolves_via_resolver() {
1733        let manifest =
1734            snapshot_manifest("1.1.6", ToolchainChannel::Development, &FixedResolver, None)
1735                .unwrap();
1736        // FixedResolver returns 1.2.3 for everything except `greentic-runner`.
1737        // The dev channel queries `greentic-runner-dev`, not `greentic-runner`,
1738        // so the special case in FixedResolver does not apply and every
1739        // package should land on the default 1.2.3 — proving the resolver was
1740        // hit (rather than versions copied from somewhere).
1741        for package in &manifest.packages {
1742            assert_eq!(
1743                package.version, "1.2.3",
1744                "resolver must be hit for {}",
1745                package.crate_name
1746            );
1747        }
1748    }
1749
1750    #[test]
1751    fn parses_dev_channel_argument() {
1752        assert_eq!(parse_channel("dev").unwrap(), ToolchainChannel::Development);
1753        assert_eq!(
1754            parse_channel("development").unwrap(),
1755            ToolchainChannel::Development
1756        );
1757        assert_eq!(parse_channel("stable").unwrap(), ToolchainChannel::Stable);
1758        assert!(parse_channel("rc").is_err());
1759    }
1760
1761    #[test]
1762    fn detects_concrete_pins_for_publish_deprecation_warning() {
1763        let with_pins = ToolchainManifest {
1764            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
1765            toolchain: TOOLCHAIN_NAME.to_string(),
1766            version: "0.0.1".to_string(),
1767            channel: Some("dev".to_string()),
1768            created_at: None,
1769            packages: vec![ToolchainPackage {
1770                crate_name: "greentic-operator-dev".to_string(),
1771                bins: vec!["greentic-operator-dev".to_string()],
1772                version: "0.5.123".to_string(),
1773            }],
1774            extension_packs: None,
1775            components: None,
1776        };
1777        assert!(source_manifest_has_concrete_pins(&with_pins));
1778
1779        let only_latest = ToolchainManifest {
1780            packages: vec![ToolchainPackage {
1781                crate_name: "greentic-operator".to_string(),
1782                bins: vec!["greentic-operator".to_string()],
1783                version: "latest".to_string(),
1784            }],
1785            ..with_pins
1786        };
1787        assert!(!source_manifest_has_concrete_pins(&only_latest));
1788    }
1789
1790    #[test]
1791    fn generate_from_rnd_uses_rnd_binary_names() {
1792        let manifest = generate_manifest("1.2.0", "rnd", None, &FixedResolver, None).unwrap();
1793        assert_eq!(manifest.channel.as_deref(), Some("rnd"));
1794        assert!(
1795            manifest
1796                .packages
1797                .iter()
1798                .flat_map(|package| package.bins.iter())
1799                .all(|bin| bin.ends_with("-rnd"))
1800        );
1801        assert!(manifest.packages.iter().any(|package| {
1802            package.crate_name == "greentic-flow" && package.bins == ["greentic-flow-rnd"]
1803        }));
1804    }
1805
1806    #[test]
1807    fn bootstrap_source_manifest_uses_source_tag_identity() {
1808        let manifest = bootstrap_source_manifest("latest", &FixedResolver, None).unwrap();
1809        assert_eq!(manifest.version, "latest");
1810        assert_eq!(manifest.channel.as_deref(), Some("latest"));
1811        assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
1812        assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
1813        assert!(
1814            manifest
1815                .packages
1816                .iter()
1817                .all(|package| package.version != "latest")
1818        );
1819    }
1820
1821    #[test]
1822    fn validates_schema_and_toolchain() {
1823        let mut manifest =
1824            generate_manifest("1.0.5", "latest", None, &FixedResolver, None).unwrap();
1825        assert!(validate_manifest(&manifest).is_ok());
1826        manifest.schema = "wrong".to_string();
1827        assert!(validate_manifest(&manifest).is_err());
1828        manifest.schema = TOOLCHAIN_MANIFEST_SCHEMA.to_string();
1829        manifest.toolchain = "other".to_string();
1830        assert!(validate_manifest(&manifest).is_err());
1831    }
1832
1833    #[test]
1834    fn resolves_inline_registry_token() {
1835        assert_eq!(
1836            resolve_registry_token(Some("secret-token"))
1837                .unwrap()
1838                .as_deref(),
1839            Some("secret-token")
1840        );
1841    }
1842
1843    #[test]
1844    fn resolves_registry_token_from_environment_reference() {
1845        let _guard = ENV_LOCK.lock().unwrap();
1846        let previous = std::env::var("RELEASE_CMD_TEST_TOKEN").ok();
1847        unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", "env-secret") };
1848
1849        let resolved = resolve_registry_token(Some("env:RELEASE_CMD_TEST_TOKEN")).unwrap();
1850        assert_eq!(resolved.as_deref(), Some("env-secret"));
1851
1852        match previous {
1853            Some(value) => unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", value) },
1854            None => unsafe { std::env::remove_var("RELEASE_CMD_TEST_TOKEN") },
1855        }
1856    }
1857
1858    #[test]
1859    fn rejects_empty_registry_token_from_environment_reference() {
1860        let _guard = ENV_LOCK.lock().unwrap();
1861        let previous = std::env::var("RELEASE_CMD_TEST_TOKEN").ok();
1862        unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", "   ") };
1863
1864        let err = resolve_registry_token(Some("env:RELEASE_CMD_TEST_TOKEN")).unwrap_err();
1865        assert!(err.to_string().contains("resolved to an empty token"));
1866
1867        match previous {
1868            Some(value) => unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", value) },
1869            None => unsafe { std::env::remove_var("RELEASE_CMD_TEST_TOKEN") },
1870        }
1871    }
1872
1873    #[test]
1874    fn registry_auth_uses_environment_fallbacks() {
1875        let _guard = ENV_LOCK.lock().unwrap();
1876        let previous_ghcr = std::env::var("GHCR_TOKEN").ok();
1877        let previous_github = std::env::var("GITHUB_TOKEN").ok();
1878        unsafe { std::env::set_var("GHCR_TOKEN", "ghcr-secret") };
1879        unsafe { std::env::remove_var("GITHUB_TOKEN") };
1880
1881        let auth = registry_auth(None).unwrap();
1882        match auth {
1883            RegistryAuth::Basic(user, token) => {
1884                assert_eq!(user, DEFAULT_OAUTH_USER);
1885                assert_eq!(token, "ghcr-secret");
1886            }
1887            _ => panic!("expected basic auth"),
1888        }
1889
1890        match previous_ghcr {
1891            Some(value) => unsafe { std::env::set_var("GHCR_TOKEN", value) },
1892            None => unsafe { std::env::remove_var("GHCR_TOKEN") },
1893        }
1894        match previous_github {
1895            Some(value) => unsafe { std::env::set_var("GITHUB_TOKEN", value) },
1896            None => unsafe { std::env::remove_var("GITHUB_TOKEN") },
1897        }
1898    }
1899
1900    #[test]
1901    fn optional_registry_auth_allows_missing_implicit_token() {
1902        let _guard = ENV_LOCK.lock().unwrap();
1903        let previous_ghcr = std::env::var("GHCR_TOKEN").ok();
1904        let previous_github = std::env::var("GITHUB_TOKEN").ok();
1905        unsafe { std::env::remove_var("GHCR_TOKEN") };
1906        unsafe { std::env::remove_var("GITHUB_TOKEN") };
1907
1908        let auth = optional_registry_auth(None).unwrap();
1909        assert!(matches!(auth, RegistryAuth::Anonymous));
1910
1911        match previous_ghcr {
1912            Some(value) => unsafe { std::env::set_var("GHCR_TOKEN", value) },
1913            None => unsafe { std::env::remove_var("GHCR_TOKEN") },
1914        }
1915        match previous_github {
1916            Some(value) => unsafe { std::env::set_var("GITHUB_TOKEN", value) },
1917            None => unsafe { std::env::remove_var("GITHUB_TOKEN") },
1918        }
1919    }
1920
1921    #[test]
1922    fn release_view_tag_prefers_release_or_tag() {
1923        let args = ReleaseViewArgs {
1924            release: Some("1.0.5".to_string()),
1925            tag: None,
1926            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1927            token: None,
1928        };
1929        assert_eq!(release_view_tag(&args).unwrap(), "1.0.5");
1930
1931        let args = ReleaseViewArgs {
1932            release: None,
1933            tag: Some("stable".to_string()),
1934            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1935            token: None,
1936        };
1937        assert_eq!(release_view_tag(&args).unwrap(), "stable");
1938    }
1939
1940    #[test]
1941    fn release_view_tag_rejects_invalid_argument_combinations() {
1942        let err = release_view_tag(&ReleaseViewArgs {
1943            release: Some("1.0.5".to_string()),
1944            tag: Some("stable".to_string()),
1945            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1946            token: None,
1947        })
1948        .unwrap_err();
1949        assert!(
1950            err.to_string()
1951                .contains("pass exactly one of --release or --tag")
1952        );
1953
1954        let err = release_view_tag(&ReleaseViewArgs {
1955            release: None,
1956            tag: None,
1957            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1958            token: None,
1959        })
1960        .unwrap_err();
1961        assert!(
1962            err.to_string()
1963                .contains("pass exactly one of --release or --tag")
1964        );
1965    }
1966
1967    #[test]
1968    fn publish_manifest_input_uses_local_manifest_version() {
1969        let dir = tempfile::tempdir().unwrap();
1970        let path = dir.path().join("gtc-1.0.12.json");
1971        let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
1972        fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
1973        let args = ReleasePublishArgs {
1974            release: None,
1975            from: None,
1976            tag: Some("stable".to_string()),
1977            manifest: Some(path.clone()),
1978            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1979            token: None,
1980            out: dir.path().to_path_buf(),
1981            dry_run: true,
1982            force: true,
1983        };
1984        let (release, loaded, source_path) = publish_manifest_input(&args).unwrap();
1985        assert_eq!(release, "1.0.12");
1986        assert_eq!(loaded, manifest);
1987        assert_eq!(
1988            source_path,
1989            Some(PublishManifestSource::Local(path.clone()))
1990        );
1991    }
1992
1993    #[test]
1994    fn publish_manifest_input_allows_release_override_for_local_manifest() {
1995        let dir = tempfile::tempdir().unwrap();
1996        let path = dir.path().join("gtc-1.0.13.json");
1997        let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
1998        fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
1999        let args = ReleasePublishArgs {
2000            release: Some("1.0.13".to_string()),
2001            from: None,
2002            tag: Some("stable".to_string()),
2003            manifest: Some(path.clone()),
2004            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2005            token: None,
2006            out: dir.path().to_path_buf(),
2007            dry_run: true,
2008            force: true,
2009        };
2010        let (release, loaded, source_path) = publish_manifest_input(&args).unwrap();
2011        assert_eq!(release, "1.0.13");
2012        assert_eq!(loaded.version, "1.0.13");
2013        assert_eq!(
2014            source_path,
2015            Some(PublishManifestSource::Local(path.clone()))
2016        );
2017    }
2018
2019    #[test]
2020    fn manifest_file_name_omits_stable_channel() {
2021        let manifest = ToolchainManifest {
2022            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
2023            toolchain: TOOLCHAIN_NAME.to_string(),
2024            version: "1.0.12".to_string(),
2025            channel: Some("stable".to_string()),
2026            created_at: None,
2027            packages: Vec::new(),
2028            extension_packs: None,
2029            components: None,
2030        };
2031        assert_eq!(manifest_file_name(&manifest), "gtc-1.0.12.json");
2032    }
2033
2034    #[test]
2035    fn parses_manifest_without_extension_sections() {
2036        let manifest: ToolchainManifest = serde_json::from_str(
2037            r#"{
2038              "schema": "greentic.toolchain-manifest.v1",
2039              "toolchain": "gtc",
2040              "version": "1.0.16",
2041              "channel": "stable",
2042              "packages": []
2043            }"#,
2044        )
2045        .unwrap();
2046
2047        assert_eq!(manifest.extension_packs, None);
2048        assert_eq!(manifest.components, None);
2049    }
2050
2051    #[test]
2052    fn generated_manifest_includes_catalogue_extension_sections() {
2053        let manifest = generate_manifest("1.0.16", "stable", None, &FixedResolver, None).unwrap();
2054        let json = serde_json::to_value(&manifest).unwrap();
2055
2056        assert!(json.get("extension_packs").is_some());
2057        assert!(json.get("components").is_some());
2058        assert!(
2059            manifest
2060                .extension_packs
2061                .as_ref()
2062                .unwrap()
2063                .iter()
2064                .any(|item| item.id == "packs/messaging/messaging-webchat-gui"
2065                    && item.version == "1.0.16")
2066        );
2067        assert!(
2068            manifest
2069                .extension_packs
2070                .as_ref()
2071                .unwrap()
2072                .iter()
2073                .any(|item| item.id == "packs/deployer/greentic.deploy.aws"
2074                    && item.version == "1.0.16")
2075        );
2076        assert!(
2077            manifest
2078                .components
2079                .as_ref()
2080                .unwrap()
2081                .iter()
2082                .any(|item| item.id == "component/component-llm-openai"
2083                    && item.version == "1.0.16")
2084        );
2085    }
2086
2087    #[test]
2088    fn generated_manifest_preserves_source_versions_for_tracked_extension_sections() {
2089        let source = ToolchainManifest {
2090            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
2091            toolchain: TOOLCHAIN_NAME.to_string(),
2092            version: "dev".to_string(),
2093            channel: Some("dev".to_string()),
2094            created_at: None,
2095            packages: Vec::new(),
2096            extension_packs: Some(vec![ExtensionPackRef {
2097                id: "packs/messaging/messaging-webchat-gui".to_string(),
2098                version: "0.5.4".to_string(),
2099            }]),
2100            components: Some(vec![ComponentRef {
2101                id: "components/component-adaptive-card".to_string(),
2102                version: "0.5.8".to_string(),
2103            }]),
2104        };
2105
2106        let manifest =
2107            generate_manifest("1.0.16", "stable", Some(&source), &FixedResolver, None).unwrap();
2108
2109        assert!(
2110            manifest
2111                .extension_packs
2112                .as_ref()
2113                .unwrap()
2114                .iter()
2115                .any(|item| item.id == "packs/messaging/messaging-webchat-gui"
2116                    && item.version == "0.5.4")
2117        );
2118        assert!(
2119            manifest
2120                .components
2121                .as_ref()
2122                .unwrap()
2123                .iter()
2124                .any(|item| item.id == "components/component-adaptive-card"
2125                    && item.version == "0.5.8")
2126        );
2127    }
2128
2129    #[test]
2130    fn manifest_file_name_includes_non_stable_channel() {
2131        let mut manifest = generate_manifest("1.0.12", "dev", None, &FixedResolver, None).unwrap();
2132        assert_eq!(manifest_file_name(&manifest), "gtc-dev-1.0.12.json");
2133
2134        manifest.channel = Some("customer-a".to_string());
2135        assert_eq!(manifest_file_name(&manifest), "gtc-customer-a-1.0.12.json");
2136    }
2137
2138    #[test]
2139    fn manifest_helpers_only_apply_dev_suffix_for_dev_channel() {
2140        assert_eq!(
2141            manifest_bins_for_source("latest", &["greentic-dev", "greentic-runner"]),
2142            vec!["greentic-dev".to_string(), "greentic-runner".to_string()]
2143        );
2144        assert_eq!(
2145            manifest_bins_for_source("dev", &["greentic-dev"]),
2146            vec!["greentic-dev-dev".to_string()]
2147        );
2148        assert_eq!(
2149            manifest_crate_name_for_source("latest", "greentic-runner"),
2150            "greentic-runner"
2151        );
2152        assert_eq!(
2153            manifest_crate_name_for_source("dev", "greentic-runner"),
2154            "greentic-runner-dev"
2155        );
2156    }
2157
2158    #[test]
2159    fn source_version_map_handles_missing_and_present_sources() {
2160        assert!(source_version_map(None).is_empty());
2161
2162        let source = ToolchainManifest {
2163            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
2164            toolchain: TOOLCHAIN_NAME.to_string(),
2165            version: "latest".to_string(),
2166            channel: Some("latest".to_string()),
2167            created_at: None,
2168            packages: vec![ToolchainPackage {
2169                crate_name: "greentic-dev".to_string(),
2170                bins: vec!["greentic-dev".to_string()],
2171                version: "0.6.0".to_string(),
2172            }],
2173            extension_packs: None,
2174            components: None,
2175        };
2176
2177        let versions = source_version_map(Some(&source));
2178        assert_eq!(
2179            versions.get("greentic-dev").map(String::as_str),
2180            Some("0.6.0")
2181        );
2182    }
2183
2184    #[test]
2185    fn write_manifest_persists_json_to_expected_file_name() {
2186        let dir = tempfile::tempdir().unwrap();
2187        let manifest = generate_manifest("1.0.12", "dev", None, &FixedResolver, None).unwrap();
2188
2189        let path = write_manifest(dir.path(), &manifest).unwrap();
2190        assert_eq!(
2191            path.file_name().and_then(|name| name.to_str()),
2192            Some("gtc-dev-1.0.12.json")
2193        );
2194
2195        let roundtrip = read_manifest_file(&path).unwrap();
2196        assert_eq!(roundtrip, manifest);
2197    }
2198
2199    #[test]
2200    fn latest_manifest_uses_latest_dev_bins() {
2201        let manifest = latest_manifest(None);
2202        assert_eq!(manifest.version, "latest");
2203        assert_eq!(manifest.channel.as_deref(), Some("latest"));
2204        assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
2205        assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
2206        assert!(!manifest.packages.is_empty());
2207        assert!(
2208            manifest
2209                .packages
2210                .iter()
2211                .all(|package| package.version == "latest")
2212        );
2213        assert!(
2214            manifest
2215                .packages
2216                .iter()
2217                .flat_map(|package| package.bins.iter())
2218                .all(|bin| bin.ends_with("-dev"))
2219        );
2220        assert!(
2221            manifest
2222                .packages
2223                .iter()
2224                .all(|package| package.crate_name.ends_with("-dev")),
2225            "latest-channel manifest mirrors dev binaries, so crate names must be -dev too"
2226        );
2227        assert!(
2228            manifest
2229                .packages
2230                .iter()
2231                .any(|package| { package.crate_name == "gtc-dev" && package.bins == ["gtc-dev"] })
2232        );
2233        assert!(manifest.packages.iter().any(|package| {
2234            package.crate_name == "greentic-dev-dev" && package.bins == ["greentic-dev-dev"]
2235        }));
2236        assert!(
2237            manifest
2238                .extension_packs
2239                .as_ref()
2240                .unwrap()
2241                .iter()
2242                .all(|item| item.version == "latest")
2243        );
2244        assert!(
2245            manifest
2246                .components
2247                .as_ref()
2248                .unwrap()
2249                .iter()
2250                .all(|item| item.version == "latest")
2251        );
2252    }
2253
2254    #[test]
2255    fn publish_dry_run_with_local_manifest_succeeds() {
2256        let dir = tempfile::tempdir().unwrap();
2257        let path = dir.path().join("gtc-1.0.12.json");
2258        let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
2259        fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
2260
2261        publish(ReleasePublishArgs {
2262            release: None,
2263            from: None,
2264            tag: Some("stable".to_string()),
2265            manifest: Some(path),
2266            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2267            token: None,
2268            out: dir.path().to_path_buf(),
2269            dry_run: true,
2270            force: false,
2271        })
2272        .unwrap();
2273    }
2274
2275    #[test]
2276    fn latest_dry_run_succeeds() {
2277        latest(ReleaseLatestArgs {
2278            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2279            token: None,
2280            dry_run: true,
2281            force: false,
2282        })
2283        .unwrap();
2284    }
2285
2286    #[test]
2287    fn promote_dry_run_succeeds() {
2288        promote(ReleasePromoteArgs {
2289            release: "1.0.12".to_string(),
2290            tag: "stable".to_string(),
2291            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
2292            token: None,
2293            dry_run: true,
2294        })
2295        .unwrap();
2296    }
2297
2298    #[test]
2299    fn builds_toolchain_ref() {
2300        assert_eq!(
2301            toolchain_ref("ghcr.io/greenticai/greentic-versions/gtc", "stable"),
2302            "ghcr.io/greenticai/greentic-versions/gtc:stable"
2303        );
2304    }
2305}