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
410pub fn snapshot_manifest<R: CrateVersionResolver>(
411    release: &str,
412    channel: ToolchainChannel,
413    resolver: &R,
414    created_at: Option<String>,
415) -> Result<ToolchainManifest> {
416    let from = channel_tag(channel);
417    let mut packages = Vec::new();
418    for package in GREENTIC_TOOLCHAIN_PACKAGES {
419        let crate_in_manifest = manifest_crate_name_for_source(from, package.crate_name);
420        let version = resolver
421            .resolve_latest(&crate_in_manifest)
422            .with_context(|| {
423                format!("failed to resolve latest version for `{crate_in_manifest}`")
424            })?;
425        packages.push(ToolchainPackage {
426            crate_name: crate_in_manifest,
427            bins: manifest_bins_for_source(from, package.bins),
428            version,
429        });
430    }
431    Ok(ToolchainManifest {
432        schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
433        toolchain: TOOLCHAIN_NAME.to_string(),
434        version: release.to_string(),
435        channel: Some(from.to_string()),
436        created_at,
437        packages,
438        extension_packs: None,
439        components: None,
440    })
441}
442
443pub fn view(args: ReleaseViewArgs) -> Result<()> {
444    let tag = release_view_tag(&args)?;
445    let manifest = block_on_maybe_runtime(load_source_manifest(
446        &args.repo,
447        &tag,
448        args.token.as_deref(),
449    ))
450    .with_context(|| {
451        format!(
452            "failed to resolve manifest `{}`",
453            toolchain_ref(&args.repo, &tag)
454        )
455    })?
456    .with_context(|| {
457        format!(
458            "manifest `{}` was not found or is not authorized for this token",
459            toolchain_ref(&args.repo, &tag)
460        )
461    })?;
462    println!("{}", serde_json::to_string_pretty(&manifest)?);
463    Ok(())
464}
465
466pub fn latest(args: ReleaseLatestArgs) -> Result<()> {
467    let manifest = latest_manifest(Some(created_at_now()?));
468    if args.dry_run {
469        println!("{}", serde_json::to_string_pretty(&manifest)?);
470        println!(
471            "Dry run: would publish {}",
472            toolchain_ref(&args.repo, "latest")
473        );
474        return Ok(());
475    }
476
477    let auth = registry_auth(args.token.as_deref())?;
478    block_on_maybe_runtime(async {
479        let client = oci_client();
480        let latest_ref = parse_reference(&args.repo, "latest")?;
481        if !args.force && manifest_exists(&client, &latest_ref, &auth).await? {
482            bail!(
483                "latest tag `{}` already exists; pass --force to overwrite it",
484                toolchain_ref(&args.repo, "latest")
485            );
486        }
487        push_manifest_layer(&client, &latest_ref, &auth, &manifest).await
488    })?;
489    println!("Published {}", toolchain_ref(&args.repo, "latest"));
490    Ok(())
491}
492
493fn latest_manifest(created_at: Option<String>) -> ToolchainManifest {
494    ToolchainManifest {
495        schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
496        toolchain: TOOLCHAIN_NAME.to_string(),
497        version: "latest".to_string(),
498        channel: Some("latest".to_string()),
499        created_at,
500        packages: latest_manifest_packages(),
501        extension_packs: Some(
502            GREENTIC_EXTENSION_PACK_PACKAGES
503                .iter()
504                .map(|package| ExtensionPackRef {
505                    id: package.package.to_string(),
506                    version: "latest".to_string(),
507                })
508                .collect(),
509        ),
510        components: Some(
511            GREENTIC_COMPONENT_PACKAGES
512                .iter()
513                .map(|package| ComponentRef {
514                    id: package.package.to_string(),
515                    version: "latest".to_string(),
516                })
517                .collect(),
518        ),
519    }
520}
521
522fn latest_manifest_packages() -> Vec<ToolchainPackage> {
523    std::iter::once(ToolchainPackage {
524        crate_name: delegated_binary_name_for_channel(
525            TOOLCHAIN_NAME,
526            ToolchainChannel::Development,
527        ),
528        bins: vec![delegated_binary_name_for_channel(
529            TOOLCHAIN_NAME,
530            ToolchainChannel::Development,
531        )],
532        version: "latest".to_string(),
533    })
534    .chain(GREENTIC_TOOLCHAIN_PACKAGES.iter().map(|package| {
535        ToolchainPackage {
536            crate_name: delegated_binary_name_for_channel(
537                package.crate_name,
538                ToolchainChannel::Development,
539            ),
540            bins: package
541                .bins
542                .iter()
543                .map(|bin| delegated_binary_name_for_channel(bin, ToolchainChannel::Development))
544                .collect(),
545            version: "latest".to_string(),
546        }
547    }))
548    .collect()
549}
550
551fn release_view_tag(args: &ReleaseViewArgs) -> Result<String> {
552    match (&args.release, &args.tag) {
553        (Some(release), None) => Ok(release.clone()),
554        (None, Some(tag)) => Ok(tag.clone()),
555        _ => bail!("pass exactly one of --release or --tag"),
556    }
557}
558
559pub fn generate_manifest<R: CrateVersionResolver>(
560    release: &str,
561    from: &str,
562    source: Option<&ToolchainManifest>,
563    resolver: &R,
564    created_at: Option<String>,
565) -> Result<ToolchainManifest> {
566    let artifact_resolver = ReleaseArtifactVersionResolver { release };
567    generate_manifest_with_artifact_resolver(
568        release,
569        from,
570        source,
571        resolver,
572        &artifact_resolver,
573        created_at,
574    )
575}
576
577pub fn generate_manifest_with_artifact_resolver<R, A>(
578    release: &str,
579    from: &str,
580    source: Option<&ToolchainManifest>,
581    resolver: &R,
582    artifact_resolver: &A,
583    created_at: Option<String>,
584) -> Result<ToolchainManifest>
585where
586    R: CrateVersionResolver,
587    A: ArtifactVersionResolver,
588{
589    if let Some(source) = source {
590        validate_manifest(source)?;
591    }
592    let source_versions = source_version_map(source);
593    let mut packages = Vec::new();
594    for package in GREENTIC_TOOLCHAIN_PACKAGES {
595        let crate_in_manifest = manifest_crate_name_for_source(from, package.crate_name);
596        let source_version = source_versions.get(&crate_in_manifest);
597        let version = match source_version.map(String::as_str) {
598            Some(version) if version != "latest" => version.to_string(),
599            _ => resolver.resolve_latest(&crate_in_manifest)?,
600        };
601        packages.push(ToolchainPackage {
602            crate_name: crate_in_manifest,
603            bins: manifest_bins_for_source(from, package.bins),
604            version,
605        });
606    }
607    Ok(ToolchainManifest {
608        schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
609        toolchain: TOOLCHAIN_NAME.to_string(),
610        version: release.to_string(),
611        channel: Some(from.to_string()),
612        created_at,
613        packages,
614        extension_packs: Some(extension_pack_refs_for_release(source, artifact_resolver)?),
615        components: Some(component_refs_for_release(source, artifact_resolver)?),
616    })
617}
618
619fn manifest_bins_for_source(from: &str, bins: &[&str]) -> Vec<String> {
620    let channel = match from {
621        "dev" => ToolchainChannel::Development,
622        "rnd" => ToolchainChannel::Rnd,
623        _ => ToolchainChannel::Stable,
624    };
625    bins.iter()
626        .map(|bin| delegated_binary_name_for_channel(bin, channel))
627        .collect()
628}
629
630fn extension_pack_refs_for_release<A: ArtifactVersionResolver>(
631    source: Option<&ToolchainManifest>,
632    artifact_resolver: &A,
633) -> Result<Vec<ExtensionPackRef>> {
634    let source_versions = source_ref_version_map(source.and_then(|manifest| {
635        manifest
636            .extension_packs
637            .as_ref()
638            .map(|refs| refs.iter().map(|item| (&item.id, &item.version)))
639    }));
640    GREENTIC_EXTENSION_PACK_PACKAGES
641        .iter()
642        .map(|package| {
643            Ok(ExtensionPackRef {
644                id: package.package.to_string(),
645                version: ref_version_for_package(package, &source_versions, artifact_resolver)?,
646            })
647        })
648        .collect()
649}
650
651fn component_refs_for_release<A: ArtifactVersionResolver>(
652    source: Option<&ToolchainManifest>,
653    artifact_resolver: &A,
654) -> Result<Vec<ComponentRef>> {
655    let source_versions = source_ref_version_map(source.and_then(|manifest| {
656        manifest
657            .components
658            .as_ref()
659            .map(|refs| refs.iter().map(|item| (&item.id, &item.version)))
660    }));
661    GREENTIC_COMPONENT_PACKAGES
662        .iter()
663        .map(|package| {
664            Ok(ComponentRef {
665                id: package.package.to_string(),
666                version: ref_version_for_package(package, &source_versions, artifact_resolver)?,
667            })
668        })
669        .collect()
670}
671
672fn source_ref_version_map<'a, I>(refs: Option<I>) -> BTreeMap<String, String>
673where
674    I: Iterator<Item = (&'a String, &'a String)>,
675{
676    let mut out = BTreeMap::new();
677    if let Some(refs) = refs {
678        for (id, version) in refs {
679            out.insert(id.clone(), version.clone());
680        }
681    }
682    out
683}
684
685fn ref_version_for_package(
686    package: &OciPackageSpec,
687    source_versions: &BTreeMap<String, String>,
688    artifact_resolver: &impl ArtifactVersionResolver,
689) -> Result<String> {
690    match source_versions.get(package.package).map(String::as_str) {
691        Some(version) if version != "latest" => Ok(version.to_string()),
692        _ => artifact_resolver
693            .resolve_latest(package.package)
694            .with_context(|| format!("failed to resolve GHCR version for `{}`", package.package)),
695    }
696}
697
698/// Apply the dev-channel `-dev` suffix to a crate name when the manifest
699/// channel is `"dev"`. The dev-publish lane mirrors every binary crate as
700/// `<crate>-dev` (binary bifurcation); the toolchain manifest must pin the
701/// mirrored crate so `cargo binstall` resolves the dev artifact instead of
702/// the stable one. Reuses `delegated_binary_name_for_channel` because the
703/// rule is identical for crates and binaries (`-dev` suffix, with the
704/// special carve-out that `greentic-dev` itself becomes `greentic-dev-dev`).
705fn manifest_crate_name_for_source(from: &str, crate_name: &str) -> String {
706    if from == "dev" {
707        delegated_binary_name_for_channel(crate_name, ToolchainChannel::Development)
708    } else {
709        crate_name.to_string()
710    }
711}
712
713pub fn validate_manifest(manifest: &ToolchainManifest) -> Result<()> {
714    if manifest.schema != TOOLCHAIN_MANIFEST_SCHEMA {
715        bail!(
716            "unsupported toolchain manifest schema `{}`",
717            manifest.schema
718        );
719    }
720    if manifest.toolchain != TOOLCHAIN_NAME {
721        bail!("unsupported toolchain `{}`", manifest.toolchain);
722    }
723    Ok(())
724}
725
726pub fn toolchain_ref(repo: &str, tag: &str) -> String {
727    format!("{repo}:{tag}")
728}
729
730fn source_version_map(source: Option<&ToolchainManifest>) -> BTreeMap<String, String> {
731    let mut out = BTreeMap::new();
732    if let Some(source) = source {
733        for package in &source.packages {
734            out.insert(package.crate_name.clone(), package.version.clone());
735        }
736    }
737    out
738}
739
740fn write_manifest(out_dir: &Path, manifest: &ToolchainManifest) -> Result<PathBuf> {
741    fs::create_dir_all(out_dir)
742        .with_context(|| format!("failed to create {}", out_dir.display()))?;
743    let path = out_dir.join(manifest_file_name(manifest));
744    let json = serde_json::to_vec_pretty(manifest).context("failed to serialize manifest")?;
745    fs::write(&path, json).with_context(|| format!("failed to write {}", path.display()))?;
746    Ok(path)
747}
748
749fn manifest_file_name(manifest: &ToolchainManifest) -> String {
750    match manifest.channel.as_deref() {
751        Some("stable") | None => format!("gtc-{}.json", manifest.version),
752        Some(channel) => format!("gtc-{channel}-{}.json", manifest.version),
753    }
754}
755
756fn created_at_now() -> Result<String> {
757    OffsetDateTime::now_utc()
758        .format(&Rfc3339)
759        .context("failed to format current time")
760}
761
762pub trait CrateVersionResolver {
763    fn resolve_latest(&self, crate_name: &str) -> Result<String>;
764}
765
766/// Default resolver used by `generate`, `publish`, and `snapshot`. Hits the
767/// crates.io HTTP API directly — see `CratesIoApiVersionResolver` for why
768/// this is preferred over shelling out to `cargo search`.
769fn default_resolver() -> CratesIoApiVersionResolver {
770    CratesIoApiVersionResolver::default()
771}
772
773pub trait ArtifactVersionResolver {
774    fn resolve_latest(&self, package: &str) -> Result<String>;
775}
776
777const CRATES_IO_API_BASE: &str = "https://crates.io/api/v1/crates";
778const CRATES_IO_USER_AGENT: &str = concat!(
779    "greentic-dev/",
780    env!("CARGO_PKG_VERSION"),
781    " (https://github.com/greenticai/greentic-dev)"
782);
783
784/// Resolve the latest published version of a crate by hitting the crates.io
785/// HTTP API directly. Returns `max_stable_version` when present, falling back
786/// to `newest_version` and then `max_version`. Replaces an earlier
787/// `cargo search`-based resolver that ranked results by relevance and parsed
788/// stdout heuristically — both brittle for `<name>-dev` aliases that share
789/// prefixes with their stable parents.
790pub struct CratesIoApiVersionResolver {
791    base_url: String,
792    client: reqwest::blocking::Client,
793}
794
795impl Default for CratesIoApiVersionResolver {
796    fn default() -> Self {
797        Self::new(CRATES_IO_API_BASE)
798    }
799}
800
801impl CratesIoApiVersionResolver {
802    pub fn new(base_url: impl Into<String>) -> Self {
803        let client = reqwest::blocking::Client::builder()
804            .user_agent(CRATES_IO_USER_AGENT)
805            .build()
806            .expect("failed to build crates.io API client");
807        Self {
808            base_url: base_url.into(),
809            client,
810        }
811    }
812}
813
814struct ReleaseArtifactVersionResolver<'a> {
815    release: &'a str,
816}
817
818impl ArtifactVersionResolver for ReleaseArtifactVersionResolver<'_> {
819    fn resolve_latest(&self, _package: &str) -> Result<String> {
820        Ok(self.release.to_string())
821    }
822}
823
824struct GhcrArtifactVersionResolver {
825    client: reqwest::blocking::Client,
826    registry: String,
827    namespace: String,
828    basic_token: Option<String>,
829}
830
831impl GhcrArtifactVersionResolver {
832    fn new(raw_token: Option<&str>) -> Result<Self> {
833        Ok(Self {
834            client: reqwest::blocking::Client::builder()
835                .build()
836                .context("failed to build GHCR HTTP client")?,
837            registry: "ghcr.io".to_string(),
838            namespace: "greenticai".to_string(),
839            basic_token: resolve_registry_token(raw_token)?
840                .or_else(|| std::env::var("GHCR_TOKEN").ok())
841                .or_else(|| std::env::var("GITHUB_TOKEN").ok()),
842        })
843    }
844
845    fn bearer_token(&self, repository: &str) -> Result<String> {
846        let scope = format!("repository:{repository}:pull");
847        let mut request = self
848            .client
849            .get(format!("https://{}/token", self.registry))
850            .query(&[
851                ("service", self.registry.as_str()),
852                ("scope", scope.as_str()),
853            ]);
854        if let Some(token) = &self.basic_token {
855            request = request.basic_auth(DEFAULT_OAUTH_USER, Some(token));
856        }
857        let response = request
858            .send()
859            .with_context(|| format!("failed to request GHCR token for `{repository}`"))?
860            .error_for_status()
861            .with_context(|| format!("GHCR token request failed for `{repository}`"))?;
862        let body: GhcrTokenResponse = response
863            .json()
864            .with_context(|| format!("failed to parse GHCR token response for `{repository}`"))?;
865        Ok(body.token)
866    }
867
868    fn tags(&self, repository: &str) -> Result<Vec<String>> {
869        let token = self.bearer_token(repository)?;
870        let response = self
871            .client
872            .get(format!(
873                "https://{}/v2/{repository}/tags/list",
874                self.registry
875            ))
876            .bearer_auth(token)
877            .send()
878            .with_context(|| format!("failed to list GHCR tags for `{repository}`"))?
879            .error_for_status()
880            .with_context(|| format!("GHCR tag list request failed for `{repository}`"))?;
881        let body: GhcrTagsResponse = response
882            .json()
883            .with_context(|| format!("failed to parse GHCR tags for `{repository}`"))?;
884        Ok(body.tags)
885    }
886}
887
888impl ArtifactVersionResolver for GhcrArtifactVersionResolver {
889    fn resolve_latest(&self, package: &str) -> Result<String> {
890        let repository = format!("{}/{}", self.namespace, package);
891        let tags = self.tags(&repository)?;
892        select_latest_artifact_tag(&tags)
893            .with_context(|| format!("no usable tags found for GHCR package `{repository}`"))
894    }
895}
896
897#[derive(Deserialize)]
898struct GhcrTokenResponse {
899    token: String,
900}
901
902#[derive(Deserialize)]
903struct GhcrTagsResponse {
904    #[serde(default)]
905    tags: Vec<String>,
906}
907
908fn select_latest_artifact_tag(tags: &[String]) -> Result<String> {
909    tags.iter()
910        .filter_map(|tag| Version::parse(tag).ok().map(|version| (version, tag)))
911        .max_by(|(left, _), (right, _)| left.cmp(right))
912        .map(|(_, tag)| tag.clone())
913        .or_else(|| tags.iter().find(|tag| tag.as_str() == "latest").cloned())
914        .context("no semver or latest tags found")
915}
916
917impl CrateVersionResolver for CratesIoApiVersionResolver {
918    fn resolve_latest(&self, crate_name: &str) -> Result<String> {
919        let url = format!("{}/{}", self.base_url.trim_end_matches('/'), crate_name);
920        let response = self
921            .client
922            .get(&url)
923            .send()
924            .with_context(|| format!("failed to GET {url}"))?;
925        let status = response.status();
926        let body = response
927            .text()
928            .with_context(|| format!("failed to read body of {url}"))?;
929        if !status.is_success() {
930            bail!("crates.io API GET {url} returned {status}: {body}");
931        }
932        parse_crates_io_version(crate_name, &body)
933    }
934}
935
936fn parse_crates_io_version(crate_name: &str, body: &str) -> Result<String> {
937    let payload: serde_json::Value = serde_json::from_str(body)
938        .with_context(|| format!("crates.io API for `{crate_name}` returned invalid JSON"))?;
939    let crate_obj = payload.get("crate").ok_or_else(|| {
940        anyhow!("crates.io API for `{crate_name}` is missing the top-level `crate` object")
941    })?;
942    let version = crate_obj
943        .get("max_stable_version")
944        .and_then(|v| v.as_str())
945        .or_else(|| crate_obj.get("newest_version").and_then(|v| v.as_str()))
946        .or_else(|| crate_obj.get("max_version").and_then(|v| v.as_str()))
947        .ok_or_else(|| {
948            anyhow!(
949                "crates.io API for `{crate_name}` does not expose max_stable_version, \
950                 newest_version, or max_version"
951            )
952        })?;
953    Version::parse(version).with_context(|| {
954        format!("crates.io returned an unparseable version `{version}` for `{crate_name}`")
955    })?;
956    Ok(version.to_string())
957}
958
959#[async_trait]
960trait ToolchainManifestSource {
961    async fn load_manifest(
962        &self,
963        repo: &str,
964        tag: &str,
965        token: Option<&str>,
966    ) -> Result<Option<ToolchainManifest>>;
967}
968
969struct OciToolchainManifestSource;
970
971#[async_trait]
972impl ToolchainManifestSource for OciToolchainManifestSource {
973    async fn load_manifest(
974        &self,
975        repo: &str,
976        tag: &str,
977        token: Option<&str>,
978    ) -> Result<Option<ToolchainManifest>> {
979        let auth = optional_registry_auth(token)?;
980        let client = oci_client();
981        let reference = parse_reference(repo, tag)?;
982        let image = match client
983            .pull(&reference, &auth, vec![TOOLCHAIN_LAYER_MEDIA_TYPE])
984            .await
985        {
986            Ok(image) => image,
987            Err(err) if is_missing_manifest_error(&err) || is_unauthorized_error(&err) => {
988                return Ok(None);
989            }
990            Err(err) => {
991                return Err(err)
992                    .with_context(|| format!("failed to pull {}", toolchain_ref(repo, tag)));
993            }
994        };
995        let Some(layer) = image
996            .layers
997            .into_iter()
998            .find(|layer| layer.media_type == TOOLCHAIN_LAYER_MEDIA_TYPE)
999        else {
1000            return Ok(None);
1001        };
1002        let manifest = serde_json::from_slice::<ToolchainManifest>(&layer.data)
1003            .with_context(|| format!("failed to parse {}", toolchain_ref(repo, tag)))?;
1004        validate_manifest(&manifest)?;
1005        Ok(Some(manifest))
1006    }
1007}
1008
1009async fn load_source_manifest(
1010    repo: &str,
1011    tag: &str,
1012    token: Option<&str>,
1013) -> Result<Option<ToolchainManifest>> {
1014    OciToolchainManifestSource
1015        .load_manifest(repo, tag, token)
1016        .await
1017}
1018
1019fn oci_client() -> Client {
1020    Client::new(ClientConfig {
1021        protocol: ClientProtocol::Https,
1022        ..Default::default()
1023    })
1024}
1025
1026fn registry_auth(raw_token: Option<&str>) -> Result<RegistryAuth> {
1027    let token = resolve_registry_token(raw_token)?
1028        .or_else(|| std::env::var("GHCR_TOKEN").ok())
1029        .or_else(|| std::env::var("GITHUB_TOKEN").ok())
1030        .context("GHCR token is required; pass --token or set GHCR_TOKEN/GITHUB_TOKEN")?;
1031    if token.trim().is_empty() {
1032        bail!("GHCR token is empty");
1033    }
1034    Ok(RegistryAuth::Basic(DEFAULT_OAUTH_USER.to_string(), token))
1035}
1036
1037fn optional_registry_auth(raw_token: Option<&str>) -> Result<RegistryAuth> {
1038    match registry_auth(raw_token) {
1039        Ok(auth) => Ok(auth),
1040        Err(_) if raw_token.is_none() => Ok(RegistryAuth::Anonymous),
1041        Err(err) => Err(err),
1042    }
1043}
1044
1045fn resolve_registry_token(raw_token: Option<&str>) -> Result<Option<String>> {
1046    let Some(raw_token) = raw_token else {
1047        return Ok(None);
1048    };
1049    if let Some(var) = raw_token.strip_prefix("env:") {
1050        let token =
1051            std::env::var(var).with_context(|| format!("failed to resolve env var {var}"))?;
1052        if token.trim().is_empty() {
1053            bail!("env var {var} resolved to an empty token");
1054        }
1055        return Ok(Some(token));
1056    }
1057    if raw_token.trim().is_empty() {
1058        bail!("GHCR token is empty");
1059    }
1060    Ok(Some(raw_token.to_string()))
1061}
1062
1063fn parse_reference(repo: &str, tag: &str) -> Result<Reference> {
1064    Reference::from_str(&toolchain_ref(repo, tag))
1065        .with_context(|| format!("invalid OCI reference `{}`", toolchain_ref(repo, tag)))
1066}
1067
1068async fn manifest_exists(
1069    client: &Client,
1070    reference: &Reference,
1071    auth: &RegistryAuth,
1072) -> Result<bool> {
1073    match client.pull_manifest(reference, auth).await {
1074        Ok(_) => Ok(true),
1075        Err(err) if is_missing_manifest_error(&err) => Ok(false),
1076        Err(err) => Err(err).context("failed to check whether release tag exists"),
1077    }
1078}
1079
1080fn is_missing_manifest_error(err: &oci_distribution::errors::OciDistributionError) -> bool {
1081    let msg = err.to_string().to_ascii_lowercase();
1082    msg.contains("manifest unknown")
1083        || msg.contains("name unknown")
1084        || msg.contains("not found")
1085        || msg.contains("404")
1086}
1087
1088fn is_unauthorized_error(err: &oci_distribution::errors::OciDistributionError) -> bool {
1089    let msg = err.to_string().to_ascii_lowercase();
1090    msg.contains("not authorized") || msg.contains("unauthorized") || msg.contains("401")
1091}
1092
1093async fn push_manifest_layer(
1094    client: &Client,
1095    reference: &Reference,
1096    auth: &RegistryAuth,
1097    manifest: &ToolchainManifest,
1098) -> Result<()> {
1099    let data = serde_json::to_vec_pretty(manifest).context("failed to serialize manifest")?;
1100    let layer = ImageLayer::new(data, TOOLCHAIN_LAYER_MEDIA_TYPE.to_string(), None);
1101    let config = Config::new(
1102        br#"{"toolchain":"gtc"}"#.to_vec(),
1103        TOOLCHAIN_CONFIG_MEDIA_TYPE.to_string(),
1104        None,
1105    );
1106    client
1107        .push(reference, &[layer], config, auth, None)
1108        .await
1109        .context("failed to push toolchain manifest")?;
1110    Ok(())
1111}
1112
1113#[cfg(test)]
1114mod tests {
1115    use super::*;
1116    use once_cell::sync::Lazy;
1117    use std::sync::Mutex;
1118
1119    static ENV_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
1120
1121    struct FixedResolver;
1122
1123    impl CrateVersionResolver for FixedResolver {
1124        fn resolve_latest(&self, crate_name: &str) -> Result<String> {
1125            Ok(match crate_name {
1126                "greentic-runner" => "0.5.10",
1127                _ => "1.2.3",
1128            }
1129            .to_string())
1130        }
1131    }
1132
1133    struct FixedArtifactResolver;
1134
1135    impl ArtifactVersionResolver for FixedArtifactResolver {
1136        fn resolve_latest(&self, package: &str) -> Result<String> {
1137            Ok(match package {
1138                "packs/messaging/messaging-webchat-gui" => "0.4.93",
1139                "components/component-adaptive-card" => "0.5.8",
1140                _ => "0.1.0",
1141            }
1142            .to_string())
1143        }
1144    }
1145
1146    #[test]
1147    fn parses_crates_io_max_stable_version() {
1148        let body = r#"{"crate":{"id":"greentic-operator-dev","max_stable_version":"0.5.123"}}"#;
1149        let version = parse_crates_io_version("greentic-operator-dev", body).unwrap();
1150        assert_eq!(version, "0.5.123");
1151    }
1152
1153    #[test]
1154    fn parses_crates_io_falls_back_to_newest_version() {
1155        let body = r#"{"crate":{"id":"greentic-flow-dev","newest_version":"0.6.7"}}"#;
1156        let version = parse_crates_io_version("greentic-flow-dev", body).unwrap();
1157        assert_eq!(version, "0.6.7");
1158    }
1159
1160    #[test]
1161    fn parses_crates_io_falls_back_to_max_version() {
1162        let body = r#"{"crate":{"id":"greentic-runner-dev","max_version":"0.4.99"}}"#;
1163        let version = parse_crates_io_version("greentic-runner-dev", body).unwrap();
1164        assert_eq!(version, "0.4.99");
1165    }
1166
1167    #[test]
1168    fn rejects_crates_io_payload_without_versions() {
1169        let body = r#"{"crate":{"id":"greentic-dev"}}"#;
1170        let err = parse_crates_io_version("greentic-dev", body).unwrap_err();
1171        assert!(
1172            err.to_string()
1173                .contains("does not expose max_stable_version")
1174        );
1175    }
1176
1177    #[test]
1178    fn rejects_crates_io_payload_with_unparseable_version() {
1179        let body = r#"{"crate":{"max_stable_version":"not-a-version"}}"#;
1180        let err = parse_crates_io_version("greentic-dev", body).unwrap_err();
1181        assert!(err.to_string().contains("unparseable version"));
1182    }
1183
1184    #[test]
1185    fn rejects_crates_io_payload_without_crate_object() {
1186        let body = r#"{"errors":[{"detail":"not found"}]}"#;
1187        let err = parse_crates_io_version("greentic-dev", body).unwrap_err();
1188        assert!(
1189            err.to_string()
1190                .contains("missing the top-level `crate` object")
1191        );
1192    }
1193
1194    #[test]
1195    fn selects_latest_semver_tag() {
1196        let tags = vec![
1197            "latest".to_string(),
1198            "0.4.93".to_string(),
1199            "0.4.9".to_string(),
1200            "1.0.0-beta.1".to_string(),
1201            "1.0.0".to_string(),
1202        ];
1203
1204        assert_eq!(select_latest_artifact_tag(&tags).unwrap(), "1.0.0");
1205    }
1206
1207    #[test]
1208    fn selects_latest_tag_when_no_semver_tags_exist() {
1209        let tags = vec!["latest".to_string()];
1210
1211        assert_eq!(select_latest_artifact_tag(&tags).unwrap(), "latest");
1212    }
1213
1214    #[test]
1215    fn generates_manifest_from_catalogue() {
1216        let manifest = generate_manifest("1.0.5", "latest", None, &FixedResolver, None).unwrap();
1217        assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
1218        assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
1219        assert_eq!(manifest.version, "1.0.5");
1220        assert_eq!(manifest.channel.as_deref(), Some("latest"));
1221        assert!(
1222            manifest
1223                .packages
1224                .iter()
1225                .any(|package| package.crate_name == "greentic-bundle"
1226                    && package.bins == ["greentic-bundle"])
1227        );
1228        assert!(
1229            manifest
1230                .packages
1231                .iter()
1232                .any(|package| package.crate_name == "greentic-runner"
1233                    && package.bins == ["greentic-runner"])
1234        );
1235        assert_eq!(manifest.extension_packs.as_ref().unwrap().len(), 81);
1236        assert_eq!(manifest.components.as_ref().unwrap().len(), 10);
1237        assert!(
1238            manifest
1239                .extension_packs
1240                .as_ref()
1241                .unwrap()
1242                .iter()
1243                .all(|item| item.version == "1.0.5")
1244        );
1245        assert!(
1246            manifest
1247                .components
1248                .as_ref()
1249                .unwrap()
1250                .iter()
1251                .all(|item| item.version == "1.0.5")
1252        );
1253    }
1254
1255    #[test]
1256    fn generated_manifest_can_use_artifact_resolver_versions() {
1257        let manifest = generate_manifest_with_artifact_resolver(
1258            "1.0.17",
1259            "stable",
1260            None,
1261            &FixedResolver,
1262            &FixedArtifactResolver,
1263            None,
1264        )
1265        .unwrap();
1266
1267        assert!(
1268            manifest
1269                .extension_packs
1270                .as_ref()
1271                .unwrap()
1272                .iter()
1273                .any(|item| item.id == "packs/messaging/messaging-webchat-gui"
1274                    && item.version == "0.4.93")
1275        );
1276        assert!(
1277            manifest
1278                .components
1279                .as_ref()
1280                .unwrap()
1281                .iter()
1282                .any(|item| item.id == "components/component-adaptive-card"
1283                    && item.version == "0.5.8")
1284        );
1285    }
1286
1287    #[test]
1288    fn source_manifest_can_pin_package_versions() {
1289        let source = ToolchainManifest {
1290            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
1291            toolchain: TOOLCHAIN_NAME.to_string(),
1292            version: "latest".to_string(),
1293            channel: Some("latest".to_string()),
1294            created_at: None,
1295            packages: vec![ToolchainPackage {
1296                crate_name: "greentic-dev".to_string(),
1297                bins: vec!["greentic-dev".to_string()],
1298                version: "0.5.9".to_string(),
1299            }],
1300            extension_packs: None,
1301            components: None,
1302        };
1303        let manifest =
1304            generate_manifest("1.0.5", "latest", Some(&source), &FixedResolver, None).unwrap();
1305        let greentic_dev = manifest
1306            .packages
1307            .iter()
1308            .find(|package| package.crate_name == "greentic-dev")
1309            .unwrap();
1310        assert_eq!(greentic_dev.version, "0.5.9");
1311    }
1312
1313    #[test]
1314    fn from_argument_controls_generated_channel_over_source_manifest() {
1315        let source = ToolchainManifest {
1316            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
1317            toolchain: TOOLCHAIN_NAME.to_string(),
1318            version: "latest".to_string(),
1319            channel: Some("stable".to_string()),
1320            created_at: None,
1321            packages: Vec::new(),
1322            extension_packs: None,
1323            components: None,
1324        };
1325        let manifest =
1326            generate_manifest("1.0.16", "dev", Some(&source), &FixedResolver, None).unwrap();
1327        assert_eq!(manifest.channel.as_deref(), Some("dev"));
1328        assert_eq!(manifest_file_name(&manifest), "gtc-dev-1.0.16.json");
1329    }
1330
1331    #[test]
1332    fn generate_from_dev_uses_dev_crate_and_binary_names() {
1333        let manifest = generate_manifest("1.0.16", "dev", None, &FixedResolver, None).unwrap();
1334        assert!(
1335            manifest
1336                .packages
1337                .iter()
1338                .flat_map(|package| package.bins.iter())
1339                .all(|bin| bin.ends_with("-dev"))
1340        );
1341        assert!(
1342            manifest
1343                .packages
1344                .iter()
1345                .all(|package| package.crate_name.ends_with("-dev")),
1346            "dev manifest must pin -dev crate names so binstall resolves the dev mirror"
1347        );
1348        assert!(manifest.packages.iter().any(|package| {
1349            package.crate_name == "greentic-flow-dev" && package.bins == ["greentic-flow-dev"]
1350        }));
1351        assert!(manifest.packages.iter().any(|package| {
1352            package.crate_name == "greentic-component-dev"
1353                && package.bins == ["greentic-component-dev"]
1354        }));
1355        assert!(manifest.packages.iter().any(|package| {
1356            package.crate_name == "greentic-dev-dev" && package.bins == ["greentic-dev-dev"]
1357        }));
1358    }
1359
1360    #[test]
1361    fn snapshot_manifest_dev_channel_resolves_dev_aliases() {
1362        let manifest =
1363            snapshot_manifest("1.1.5", ToolchainChannel::Development, &FixedResolver, None)
1364                .unwrap();
1365        assert_eq!(manifest.version, "1.1.5");
1366        assert_eq!(manifest.channel.as_deref(), Some("dev"));
1367        for package in &manifest.packages {
1368            assert!(
1369                package.crate_name.ends_with("-dev"),
1370                "dev snapshot must pin -dev crate names; got {}",
1371                package.crate_name
1372            );
1373            assert!(
1374                package.bins.iter().all(|bin| bin.ends_with("-dev")),
1375                "dev snapshot must pin -dev bin names; got {:?}",
1376                package.bins
1377            );
1378            assert_ne!(
1379                package.version, "latest",
1380                "snapshot must always resolve concrete versions"
1381            );
1382        }
1383        assert!(
1384            manifest
1385                .packages
1386                .iter()
1387                .any(|package| package.crate_name == "greentic-operator-dev")
1388        );
1389    }
1390
1391    #[test]
1392    fn snapshot_manifest_stable_channel_keeps_plain_names() {
1393        let manifest =
1394            snapshot_manifest("1.0.20", ToolchainChannel::Stable, &FixedResolver, None).unwrap();
1395        assert_eq!(manifest.channel.as_deref(), Some("stable"));
1396        // The stable channel must NOT apply the `-dev` suffix transform.
1397        // Cross-check against the catalogue: every stable package must match
1398        // a catalogue entry by exact name (no transform applied).
1399        let catalogue_names: std::collections::BTreeSet<_> = GREENTIC_TOOLCHAIN_PACKAGES
1400            .iter()
1401            .map(|spec| spec.crate_name)
1402            .collect();
1403        for package in &manifest.packages {
1404            assert!(
1405                catalogue_names.contains(package.crate_name.as_str()),
1406                "stable snapshot crate `{}` was transformed; expected a verbatim catalogue entry",
1407                package.crate_name
1408            );
1409        }
1410    }
1411
1412    #[test]
1413    fn snapshot_manifest_resolves_via_resolver() {
1414        let manifest =
1415            snapshot_manifest("1.1.6", ToolchainChannel::Development, &FixedResolver, None)
1416                .unwrap();
1417        // FixedResolver returns 1.2.3 for everything except `greentic-runner`.
1418        // The dev channel queries `greentic-runner-dev`, not `greentic-runner`,
1419        // so the special case in FixedResolver does not apply and every
1420        // package should land on the default 1.2.3 — proving the resolver was
1421        // hit (rather than versions copied from somewhere).
1422        for package in &manifest.packages {
1423            assert_eq!(
1424                package.version, "1.2.3",
1425                "resolver must be hit for {}",
1426                package.crate_name
1427            );
1428        }
1429    }
1430
1431    #[test]
1432    fn parses_dev_channel_argument() {
1433        assert_eq!(parse_channel("dev").unwrap(), ToolchainChannel::Development);
1434        assert_eq!(
1435            parse_channel("development").unwrap(),
1436            ToolchainChannel::Development
1437        );
1438        assert_eq!(parse_channel("stable").unwrap(), ToolchainChannel::Stable);
1439        assert!(parse_channel("rc").is_err());
1440    }
1441
1442    #[test]
1443    fn detects_concrete_pins_for_publish_deprecation_warning() {
1444        let with_pins = ToolchainManifest {
1445            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
1446            toolchain: TOOLCHAIN_NAME.to_string(),
1447            version: "0.0.1".to_string(),
1448            channel: Some("dev".to_string()),
1449            created_at: None,
1450            packages: vec![ToolchainPackage {
1451                crate_name: "greentic-operator-dev".to_string(),
1452                bins: vec!["greentic-operator-dev".to_string()],
1453                version: "0.5.123".to_string(),
1454            }],
1455            extension_packs: None,
1456            components: None,
1457        };
1458        assert!(source_manifest_has_concrete_pins(&with_pins));
1459
1460        let only_latest = ToolchainManifest {
1461            packages: vec![ToolchainPackage {
1462                crate_name: "greentic-operator".to_string(),
1463                bins: vec!["greentic-operator".to_string()],
1464                version: "latest".to_string(),
1465            }],
1466            ..with_pins
1467        };
1468        assert!(!source_manifest_has_concrete_pins(&only_latest));
1469    }
1470
1471    #[test]
1472    fn generate_from_rnd_uses_rnd_binary_names() {
1473        let manifest = generate_manifest("1.2.0", "rnd", None, &FixedResolver, None).unwrap();
1474        assert_eq!(manifest.channel.as_deref(), Some("rnd"));
1475        assert!(
1476            manifest
1477                .packages
1478                .iter()
1479                .flat_map(|package| package.bins.iter())
1480                .all(|bin| bin.ends_with("-rnd"))
1481        );
1482        assert!(manifest.packages.iter().any(|package| {
1483            package.crate_name == "greentic-flow" && package.bins == ["greentic-flow-rnd"]
1484        }));
1485    }
1486
1487    #[test]
1488    fn bootstrap_source_manifest_uses_source_tag_identity() {
1489        let manifest = bootstrap_source_manifest("latest", &FixedResolver, None).unwrap();
1490        assert_eq!(manifest.version, "latest");
1491        assert_eq!(manifest.channel.as_deref(), Some("latest"));
1492        assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
1493        assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
1494        assert!(
1495            manifest
1496                .packages
1497                .iter()
1498                .all(|package| package.version != "latest")
1499        );
1500    }
1501
1502    #[test]
1503    fn validates_schema_and_toolchain() {
1504        let mut manifest =
1505            generate_manifest("1.0.5", "latest", None, &FixedResolver, None).unwrap();
1506        assert!(validate_manifest(&manifest).is_ok());
1507        manifest.schema = "wrong".to_string();
1508        assert!(validate_manifest(&manifest).is_err());
1509        manifest.schema = TOOLCHAIN_MANIFEST_SCHEMA.to_string();
1510        manifest.toolchain = "other".to_string();
1511        assert!(validate_manifest(&manifest).is_err());
1512    }
1513
1514    #[test]
1515    fn resolves_inline_registry_token() {
1516        assert_eq!(
1517            resolve_registry_token(Some("secret-token"))
1518                .unwrap()
1519                .as_deref(),
1520            Some("secret-token")
1521        );
1522    }
1523
1524    #[test]
1525    fn resolves_registry_token_from_environment_reference() {
1526        let _guard = ENV_LOCK.lock().unwrap();
1527        let previous = std::env::var("RELEASE_CMD_TEST_TOKEN").ok();
1528        unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", "env-secret") };
1529
1530        let resolved = resolve_registry_token(Some("env:RELEASE_CMD_TEST_TOKEN")).unwrap();
1531        assert_eq!(resolved.as_deref(), Some("env-secret"));
1532
1533        match previous {
1534            Some(value) => unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", value) },
1535            None => unsafe { std::env::remove_var("RELEASE_CMD_TEST_TOKEN") },
1536        }
1537    }
1538
1539    #[test]
1540    fn rejects_empty_registry_token_from_environment_reference() {
1541        let _guard = ENV_LOCK.lock().unwrap();
1542        let previous = std::env::var("RELEASE_CMD_TEST_TOKEN").ok();
1543        unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", "   ") };
1544
1545        let err = resolve_registry_token(Some("env:RELEASE_CMD_TEST_TOKEN")).unwrap_err();
1546        assert!(err.to_string().contains("resolved to an empty token"));
1547
1548        match previous {
1549            Some(value) => unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", value) },
1550            None => unsafe { std::env::remove_var("RELEASE_CMD_TEST_TOKEN") },
1551        }
1552    }
1553
1554    #[test]
1555    fn registry_auth_uses_environment_fallbacks() {
1556        let _guard = ENV_LOCK.lock().unwrap();
1557        let previous_ghcr = std::env::var("GHCR_TOKEN").ok();
1558        let previous_github = std::env::var("GITHUB_TOKEN").ok();
1559        unsafe { std::env::set_var("GHCR_TOKEN", "ghcr-secret") };
1560        unsafe { std::env::remove_var("GITHUB_TOKEN") };
1561
1562        let auth = registry_auth(None).unwrap();
1563        match auth {
1564            RegistryAuth::Basic(user, token) => {
1565                assert_eq!(user, DEFAULT_OAUTH_USER);
1566                assert_eq!(token, "ghcr-secret");
1567            }
1568            _ => panic!("expected basic auth"),
1569        }
1570
1571        match previous_ghcr {
1572            Some(value) => unsafe { std::env::set_var("GHCR_TOKEN", value) },
1573            None => unsafe { std::env::remove_var("GHCR_TOKEN") },
1574        }
1575        match previous_github {
1576            Some(value) => unsafe { std::env::set_var("GITHUB_TOKEN", value) },
1577            None => unsafe { std::env::remove_var("GITHUB_TOKEN") },
1578        }
1579    }
1580
1581    #[test]
1582    fn optional_registry_auth_allows_missing_implicit_token() {
1583        let _guard = ENV_LOCK.lock().unwrap();
1584        let previous_ghcr = std::env::var("GHCR_TOKEN").ok();
1585        let previous_github = std::env::var("GITHUB_TOKEN").ok();
1586        unsafe { std::env::remove_var("GHCR_TOKEN") };
1587        unsafe { std::env::remove_var("GITHUB_TOKEN") };
1588
1589        let auth = optional_registry_auth(None).unwrap();
1590        assert!(matches!(auth, RegistryAuth::Anonymous));
1591
1592        match previous_ghcr {
1593            Some(value) => unsafe { std::env::set_var("GHCR_TOKEN", value) },
1594            None => unsafe { std::env::remove_var("GHCR_TOKEN") },
1595        }
1596        match previous_github {
1597            Some(value) => unsafe { std::env::set_var("GITHUB_TOKEN", value) },
1598            None => unsafe { std::env::remove_var("GITHUB_TOKEN") },
1599        }
1600    }
1601
1602    #[test]
1603    fn release_view_tag_prefers_release_or_tag() {
1604        let args = ReleaseViewArgs {
1605            release: Some("1.0.5".to_string()),
1606            tag: None,
1607            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1608            token: None,
1609        };
1610        assert_eq!(release_view_tag(&args).unwrap(), "1.0.5");
1611
1612        let args = ReleaseViewArgs {
1613            release: None,
1614            tag: Some("stable".to_string()),
1615            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1616            token: None,
1617        };
1618        assert_eq!(release_view_tag(&args).unwrap(), "stable");
1619    }
1620
1621    #[test]
1622    fn release_view_tag_rejects_invalid_argument_combinations() {
1623        let err = release_view_tag(&ReleaseViewArgs {
1624            release: Some("1.0.5".to_string()),
1625            tag: Some("stable".to_string()),
1626            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1627            token: None,
1628        })
1629        .unwrap_err();
1630        assert!(
1631            err.to_string()
1632                .contains("pass exactly one of --release or --tag")
1633        );
1634
1635        let err = release_view_tag(&ReleaseViewArgs {
1636            release: None,
1637            tag: None,
1638            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1639            token: None,
1640        })
1641        .unwrap_err();
1642        assert!(
1643            err.to_string()
1644                .contains("pass exactly one of --release or --tag")
1645        );
1646    }
1647
1648    #[test]
1649    fn publish_manifest_input_uses_local_manifest_version() {
1650        let dir = tempfile::tempdir().unwrap();
1651        let path = dir.path().join("gtc-1.0.12.json");
1652        let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
1653        fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
1654        let args = ReleasePublishArgs {
1655            release: None,
1656            from: None,
1657            tag: Some("stable".to_string()),
1658            manifest: Some(path.clone()),
1659            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1660            token: None,
1661            out: dir.path().to_path_buf(),
1662            dry_run: true,
1663            force: true,
1664        };
1665        let (release, loaded, source_path) = publish_manifest_input(&args).unwrap();
1666        assert_eq!(release, "1.0.12");
1667        assert_eq!(loaded, manifest);
1668        assert_eq!(
1669            source_path,
1670            Some(PublishManifestSource::Local(path.clone()))
1671        );
1672    }
1673
1674    #[test]
1675    fn publish_manifest_input_allows_release_override_for_local_manifest() {
1676        let dir = tempfile::tempdir().unwrap();
1677        let path = dir.path().join("gtc-1.0.13.json");
1678        let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
1679        fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
1680        let args = ReleasePublishArgs {
1681            release: Some("1.0.13".to_string()),
1682            from: None,
1683            tag: Some("stable".to_string()),
1684            manifest: Some(path.clone()),
1685            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1686            token: None,
1687            out: dir.path().to_path_buf(),
1688            dry_run: true,
1689            force: true,
1690        };
1691        let (release, loaded, source_path) = publish_manifest_input(&args).unwrap();
1692        assert_eq!(release, "1.0.13");
1693        assert_eq!(loaded.version, "1.0.13");
1694        assert_eq!(
1695            source_path,
1696            Some(PublishManifestSource::Local(path.clone()))
1697        );
1698    }
1699
1700    #[test]
1701    fn manifest_file_name_omits_stable_channel() {
1702        let manifest = ToolchainManifest {
1703            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
1704            toolchain: TOOLCHAIN_NAME.to_string(),
1705            version: "1.0.12".to_string(),
1706            channel: Some("stable".to_string()),
1707            created_at: None,
1708            packages: Vec::new(),
1709            extension_packs: None,
1710            components: None,
1711        };
1712        assert_eq!(manifest_file_name(&manifest), "gtc-1.0.12.json");
1713    }
1714
1715    #[test]
1716    fn parses_manifest_without_extension_sections() {
1717        let manifest: ToolchainManifest = serde_json::from_str(
1718            r#"{
1719              "schema": "greentic.toolchain-manifest.v1",
1720              "toolchain": "gtc",
1721              "version": "1.0.16",
1722              "channel": "stable",
1723              "packages": []
1724            }"#,
1725        )
1726        .unwrap();
1727
1728        assert_eq!(manifest.extension_packs, None);
1729        assert_eq!(manifest.components, None);
1730    }
1731
1732    #[test]
1733    fn generated_manifest_includes_catalogue_extension_sections() {
1734        let manifest = generate_manifest("1.0.16", "stable", None, &FixedResolver, None).unwrap();
1735        let json = serde_json::to_value(&manifest).unwrap();
1736
1737        assert!(json.get("extension_packs").is_some());
1738        assert!(json.get("components").is_some());
1739        assert!(
1740            manifest
1741                .extension_packs
1742                .as_ref()
1743                .unwrap()
1744                .iter()
1745                .any(|item| item.id == "packs/messaging/messaging-webchat-gui"
1746                    && item.version == "1.0.16")
1747        );
1748        assert!(
1749            manifest
1750                .extension_packs
1751                .as_ref()
1752                .unwrap()
1753                .iter()
1754                .any(|item| item.id == "packs/deployer/greentic.deploy.aws"
1755                    && item.version == "1.0.16")
1756        );
1757        assert!(
1758            manifest
1759                .components
1760                .as_ref()
1761                .unwrap()
1762                .iter()
1763                .any(|item| item.id == "component/component-llm-openai"
1764                    && item.version == "1.0.16")
1765        );
1766    }
1767
1768    #[test]
1769    fn generated_manifest_preserves_source_versions_for_tracked_extension_sections() {
1770        let source = ToolchainManifest {
1771            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
1772            toolchain: TOOLCHAIN_NAME.to_string(),
1773            version: "dev".to_string(),
1774            channel: Some("dev".to_string()),
1775            created_at: None,
1776            packages: Vec::new(),
1777            extension_packs: Some(vec![ExtensionPackRef {
1778                id: "packs/messaging/messaging-webchat-gui".to_string(),
1779                version: "0.5.4".to_string(),
1780            }]),
1781            components: Some(vec![ComponentRef {
1782                id: "components/component-adaptive-card".to_string(),
1783                version: "0.5.8".to_string(),
1784            }]),
1785        };
1786
1787        let manifest =
1788            generate_manifest("1.0.16", "stable", Some(&source), &FixedResolver, None).unwrap();
1789
1790        assert!(
1791            manifest
1792                .extension_packs
1793                .as_ref()
1794                .unwrap()
1795                .iter()
1796                .any(|item| item.id == "packs/messaging/messaging-webchat-gui"
1797                    && item.version == "0.5.4")
1798        );
1799        assert!(
1800            manifest
1801                .components
1802                .as_ref()
1803                .unwrap()
1804                .iter()
1805                .any(|item| item.id == "components/component-adaptive-card"
1806                    && item.version == "0.5.8")
1807        );
1808    }
1809
1810    #[test]
1811    fn manifest_file_name_includes_non_stable_channel() {
1812        let mut manifest = generate_manifest("1.0.12", "dev", None, &FixedResolver, None).unwrap();
1813        assert_eq!(manifest_file_name(&manifest), "gtc-dev-1.0.12.json");
1814
1815        manifest.channel = Some("customer-a".to_string());
1816        assert_eq!(manifest_file_name(&manifest), "gtc-customer-a-1.0.12.json");
1817    }
1818
1819    #[test]
1820    fn manifest_helpers_only_apply_dev_suffix_for_dev_channel() {
1821        assert_eq!(
1822            manifest_bins_for_source("latest", &["greentic-dev", "greentic-runner"]),
1823            vec!["greentic-dev".to_string(), "greentic-runner".to_string()]
1824        );
1825        assert_eq!(
1826            manifest_bins_for_source("dev", &["greentic-dev"]),
1827            vec!["greentic-dev-dev".to_string()]
1828        );
1829        assert_eq!(
1830            manifest_crate_name_for_source("latest", "greentic-runner"),
1831            "greentic-runner"
1832        );
1833        assert_eq!(
1834            manifest_crate_name_for_source("dev", "greentic-runner"),
1835            "greentic-runner-dev"
1836        );
1837    }
1838
1839    #[test]
1840    fn source_version_map_handles_missing_and_present_sources() {
1841        assert!(source_version_map(None).is_empty());
1842
1843        let source = ToolchainManifest {
1844            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
1845            toolchain: TOOLCHAIN_NAME.to_string(),
1846            version: "latest".to_string(),
1847            channel: Some("latest".to_string()),
1848            created_at: None,
1849            packages: vec![ToolchainPackage {
1850                crate_name: "greentic-dev".to_string(),
1851                bins: vec!["greentic-dev".to_string()],
1852                version: "0.6.0".to_string(),
1853            }],
1854            extension_packs: None,
1855            components: None,
1856        };
1857
1858        let versions = source_version_map(Some(&source));
1859        assert_eq!(
1860            versions.get("greentic-dev").map(String::as_str),
1861            Some("0.6.0")
1862        );
1863    }
1864
1865    #[test]
1866    fn write_manifest_persists_json_to_expected_file_name() {
1867        let dir = tempfile::tempdir().unwrap();
1868        let manifest = generate_manifest("1.0.12", "dev", None, &FixedResolver, None).unwrap();
1869
1870        let path = write_manifest(dir.path(), &manifest).unwrap();
1871        assert_eq!(
1872            path.file_name().and_then(|name| name.to_str()),
1873            Some("gtc-dev-1.0.12.json")
1874        );
1875
1876        let roundtrip = read_manifest_file(&path).unwrap();
1877        assert_eq!(roundtrip, manifest);
1878    }
1879
1880    #[test]
1881    fn latest_manifest_uses_latest_dev_bins() {
1882        let manifest = latest_manifest(None);
1883        assert_eq!(manifest.version, "latest");
1884        assert_eq!(manifest.channel.as_deref(), Some("latest"));
1885        assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
1886        assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
1887        assert!(!manifest.packages.is_empty());
1888        assert!(
1889            manifest
1890                .packages
1891                .iter()
1892                .all(|package| package.version == "latest")
1893        );
1894        assert!(
1895            manifest
1896                .packages
1897                .iter()
1898                .flat_map(|package| package.bins.iter())
1899                .all(|bin| bin.ends_with("-dev"))
1900        );
1901        assert!(
1902            manifest
1903                .packages
1904                .iter()
1905                .all(|package| package.crate_name.ends_with("-dev")),
1906            "latest-channel manifest mirrors dev binaries, so crate names must be -dev too"
1907        );
1908        assert!(
1909            manifest
1910                .packages
1911                .iter()
1912                .any(|package| { package.crate_name == "gtc-dev" && package.bins == ["gtc-dev"] })
1913        );
1914        assert!(manifest.packages.iter().any(|package| {
1915            package.crate_name == "greentic-dev-dev" && package.bins == ["greentic-dev-dev"]
1916        }));
1917        assert!(
1918            manifest
1919                .extension_packs
1920                .as_ref()
1921                .unwrap()
1922                .iter()
1923                .all(|item| item.version == "latest")
1924        );
1925        assert!(
1926            manifest
1927                .components
1928                .as_ref()
1929                .unwrap()
1930                .iter()
1931                .all(|item| item.version == "latest")
1932        );
1933    }
1934
1935    #[test]
1936    fn publish_dry_run_with_local_manifest_succeeds() {
1937        let dir = tempfile::tempdir().unwrap();
1938        let path = dir.path().join("gtc-1.0.12.json");
1939        let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
1940        fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
1941
1942        publish(ReleasePublishArgs {
1943            release: None,
1944            from: None,
1945            tag: Some("stable".to_string()),
1946            manifest: Some(path),
1947            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1948            token: None,
1949            out: dir.path().to_path_buf(),
1950            dry_run: true,
1951            force: false,
1952        })
1953        .unwrap();
1954    }
1955
1956    #[test]
1957    fn latest_dry_run_succeeds() {
1958        latest(ReleaseLatestArgs {
1959            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1960            token: None,
1961            dry_run: true,
1962            force: false,
1963        })
1964        .unwrap();
1965    }
1966
1967    #[test]
1968    fn promote_dry_run_succeeds() {
1969        promote(ReleasePromoteArgs {
1970            release: "1.0.12".to_string(),
1971            tag: "stable".to_string(),
1972            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1973            token: None,
1974            dry_run: true,
1975        })
1976        .unwrap();
1977    }
1978
1979    #[test]
1980    fn builds_toolchain_ref() {
1981        assert_eq!(
1982            toolchain_ref("ghcr.io/greenticai/greentic-versions/gtc", "stable"),
1983            "ghcr.io/greenticai/greentic-versions/gtc:stable"
1984        );
1985    }
1986}