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