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