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::GREENTIC_TOOLCHAIN_PACKAGES;
23
24const DEFAULT_OAUTH_USER: &str = "oauth2";
25pub const TOOLCHAIN_MANIFEST_SCHEMA: &str = "greentic.toolchain-manifest.v1";
26pub const TOOLCHAIN_NAME: &str = "gtc";
27pub const TOOLCHAIN_LAYER_MEDIA_TYPE: &str = "application/vnd.greentic.toolchain.manifest.v1+json";
28const TOOLCHAIN_CONFIG_MEDIA_TYPE: &str = "application/vnd.greentic.toolchain.config.v1+json";
29
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
31pub struct ToolchainManifest {
32    pub schema: String,
33    pub toolchain: String,
34    pub version: String,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub channel: Option<String>,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub created_at: Option<String>,
39    pub packages: Vec<ToolchainPackage>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
43pub struct ToolchainPackage {
44    #[serde(rename = "crate")]
45    pub crate_name: String,
46    pub bins: Vec<String>,
47    pub version: String,
48}
49
50pub fn generate(args: ReleaseGenerateArgs) -> Result<()> {
51    let resolver = default_resolver();
52    let source = block_on_maybe_runtime(load_source_manifest(
53        &args.repo,
54        &args.from,
55        args.token.as_deref(),
56    ))
57    .with_context(|| {
58        format!(
59            "failed to resolve source manifest `{}`",
60            toolchain_ref(&args.repo, &args.from)
61        )
62    })?;
63    let source = match source {
64        Some(source) => Some(source),
65        None => bootstrap_source_manifest_if_needed(
66            &args.repo,
67            &args.from,
68            args.token.as_deref(),
69            args.dry_run,
70            &resolver,
71        )?,
72    };
73    let manifest = generate_manifest(
74        &args.release,
75        &args.from,
76        source.as_ref(),
77        &resolver,
78        Some(created_at_now()?),
79    )?;
80    if args.dry_run {
81        println!("{}", serde_json::to_string_pretty(&manifest)?);
82        return Ok(());
83    }
84    let path = write_manifest(&args.out, &manifest)?;
85    println!("Wrote {}", path.display());
86    Ok(())
87}
88
89fn bootstrap_source_manifest_if_needed<R: CrateVersionResolver>(
90    repo: &str,
91    tag: &str,
92    token: Option<&str>,
93    dry_run: bool,
94    resolver: &R,
95) -> Result<Option<ToolchainManifest>> {
96    let manifest = bootstrap_source_manifest(tag, resolver, Some(created_at_now()?))?;
97    if dry_run {
98        eprintln!(
99            "Dry run: would bootstrap missing source manifest {}",
100            toolchain_ref(repo, tag)
101        );
102        return Ok(Some(manifest));
103    }
104
105    let auth = match optional_registry_auth(token)? {
106        RegistryAuth::Anonymous => {
107            eprintln!(
108                "Source manifest {} is missing; no GHCR token is available, so only the local release manifest will be generated.",
109                toolchain_ref(repo, tag)
110            );
111            return Ok(Some(manifest));
112        }
113        auth => auth,
114    };
115    block_on_maybe_runtime(async {
116        let client = oci_client();
117        let source_ref = parse_reference(repo, tag)?;
118        push_manifest_layer(&client, &source_ref, &auth, &manifest).await
119    })
120    .with_context(|| format!("failed to bootstrap {}", toolchain_ref(repo, tag)))?;
121    println!("Bootstrapped {}", toolchain_ref(repo, tag));
122    Ok(Some(manifest))
123}
124
125fn bootstrap_source_manifest<R: CrateVersionResolver>(
126    tag: &str,
127    resolver: &R,
128    created_at: Option<String>,
129) -> Result<ToolchainManifest> {
130    generate_manifest(tag, tag, None, resolver, created_at)
131}
132
133pub fn publish(args: ReleasePublishArgs) -> Result<()> {
134    let (release, manifest, source) = publish_manifest_input(&args)?;
135
136    if args.dry_run {
137        println!(
138            "Dry run: would publish {}",
139            toolchain_ref(&args.repo, &release)
140        );
141        if let Some(tag) = &args.tag {
142            println!(
143                "Dry run: would tag {} as {}",
144                toolchain_ref(&args.repo, &release),
145                toolchain_ref(&args.repo, tag)
146            );
147        }
148        return Ok(());
149    }
150
151    let auth = registry_auth(args.token.as_deref())?;
152    block_on_maybe_runtime(async {
153        let client = oci_client();
154        let release_ref = parse_reference(&args.repo, &release)?;
155        if !args.force && manifest_exists(&client, &release_ref, &auth).await? {
156            bail!(
157                "release tag `{}` already exists; pass --force to overwrite it",
158                toolchain_ref(&args.repo, &release)
159            );
160        }
161        push_manifest_layer(&client, &release_ref, &auth, &manifest).await?;
162        if let Some(tag) = &args.tag {
163            let tag_ref = parse_reference(&args.repo, tag)?;
164            push_manifest_layer(&client, &tag_ref, &auth, &manifest).await?;
165        }
166        Ok(())
167    })?;
168
169    if let Some(source) = source {
170        match source {
171            PublishManifestSource::Generated(path) => println!("Wrote {}", path.display()),
172            PublishManifestSource::Local(path) => println!("Read {}", path.display()),
173        }
174    }
175    println!("Published {}", toolchain_ref(&args.repo, &release));
176    if let Some(tag) = &args.tag {
177        println!("Updated {}", toolchain_ref(&args.repo, tag));
178    }
179    Ok(())
180}
181
182#[derive(Debug, Clone, PartialEq, Eq)]
183enum PublishManifestSource {
184    Generated(PathBuf),
185    Local(PathBuf),
186}
187
188fn publish_manifest_input(
189    args: &ReleasePublishArgs,
190) -> Result<(String, ToolchainManifest, Option<PublishManifestSource>)> {
191    if let Some(path) = &args.manifest {
192        let mut manifest = read_manifest_file(path)?;
193        validate_manifest(&manifest)?;
194        let release = if let Some(release) = &args.release {
195            manifest.version = release.clone();
196            release.clone()
197        } else {
198            manifest.version.clone()
199        };
200        return Ok((
201            release,
202            manifest,
203            Some(PublishManifestSource::Local(path.clone())),
204        ));
205    }
206
207    let release = args
208        .release
209        .as_deref()
210        .context("pass --release or --manifest")?;
211    let from = args.from.as_deref().unwrap_or("latest");
212    let resolver = default_resolver();
213    let source = block_on_maybe_runtime(load_source_manifest(
214        &args.repo,
215        from,
216        args.token.as_deref(),
217    ))
218    .with_context(|| {
219        format!(
220            "failed to resolve source manifest `{}`",
221            toolchain_ref(&args.repo, from)
222        )
223    })?;
224    if let Some(source_manifest) = source.as_ref()
225        && source_manifest_has_concrete_pins(source_manifest)
226    {
227        eprintln!(
228            "warning: `release publish --from {from}` reuses the pinned versions in `{}` instead \
229             of querying crates.io. To refresh a channel from the latest crates.io versions, use \
230             `release snapshot --channel <dev|stable>`. To copy an existing release tag without \
231             re-resolving, use `release promote`. The conflated `--from` semantics will be \
232             removed in a future release.",
233            toolchain_ref(&args.repo, from),
234        );
235    }
236    let manifest = generate_manifest(
237        release,
238        from,
239        source.as_ref(),
240        &resolver,
241        Some(created_at_now()?),
242    )?;
243    let path = if args.dry_run {
244        println!("{}", serde_json::to_string_pretty(&manifest)?);
245        None
246    } else {
247        Some(PublishManifestSource::Generated(write_manifest(
248            &args.out, &manifest,
249        )?))
250    };
251    Ok((release.to_string(), manifest, path))
252}
253
254/// True when the source manifest has at least one package pinned to a concrete
255/// (non-`"latest"`) version. Used to detect the case where `release publish
256/// --from <X>` would silently copy old pins instead of re-resolving — see the
257/// deprecation warning emitted from `publish_manifest_input`.
258fn source_manifest_has_concrete_pins(manifest: &ToolchainManifest) -> bool {
259    manifest
260        .packages
261        .iter()
262        .any(|package| package.version != "latest")
263}
264
265fn read_manifest_file(path: &Path) -> Result<ToolchainManifest> {
266    let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
267    serde_json::from_slice(&bytes).with_context(|| format!("failed to parse {}", path.display()))
268}
269
270pub fn promote(args: ReleasePromoteArgs) -> Result<()> {
271    if args.dry_run {
272        println!(
273            "Dry run: would promote {} to {}",
274            toolchain_ref(&args.repo, &args.release),
275            toolchain_ref(&args.repo, &args.tag)
276        );
277        return Ok(());
278    }
279
280    let auth = registry_auth(args.token.as_deref())?;
281    block_on_maybe_runtime(async {
282        let client = oci_client();
283        let source_ref = parse_reference(&args.repo, &args.release)?;
284        let target_ref = parse_reference(&args.repo, &args.tag)?;
285        let (manifest, _) = client
286            .pull_manifest(&source_ref, &auth)
287            .await
288            .with_context(|| {
289                format!(
290                    "failed to resolve source release `{}`",
291                    toolchain_ref(&args.repo, &args.release)
292                )
293            })?;
294        client
295            .push_manifest(&target_ref, &manifest)
296            .await
297            .with_context(|| {
298                format!(
299                    "failed to update tag `{}`",
300                    toolchain_ref(&args.repo, &args.tag)
301                )
302            })?;
303        Ok(())
304    })?;
305    println!(
306        "Promoted {} to {}",
307        toolchain_ref(&args.repo, &args.release),
308        toolchain_ref(&args.repo, &args.tag)
309    );
310    Ok(())
311}
312
313/// Snapshot the current crates.io state into a new toolchain manifest.
314///
315/// Unlike `publish --from <X>`, snapshot **never** reads an existing manifest
316/// and **always** queries the resolver. That makes it safe to call repeatedly
317/// to refresh a channel — `:dev` after each nightly publish, `:stable` after
318/// a weekly release — without the promote-vs-snapshot conflation that bit
319/// callers of `publish --from dev`.
320pub fn snapshot(args: ReleaseSnapshotArgs) -> Result<()> {
321    let channel = parse_channel(&args.channel)?;
322    let resolver = CratesIoApiVersionResolver::default();
323    let manifest = snapshot_manifest(&args.release, channel, &resolver, Some(created_at_now()?))?;
324
325    if args.dry_run {
326        println!("{}", serde_json::to_string_pretty(&manifest)?);
327        println!(
328            "Dry run: would publish {}",
329            toolchain_ref(&args.repo, &args.release)
330        );
331        if let Some(tag) = &args.tag {
332            println!(
333                "Dry run: would tag {} as {}",
334                toolchain_ref(&args.repo, &args.release),
335                toolchain_ref(&args.repo, tag)
336            );
337        }
338        return Ok(());
339    }
340
341    let path = write_manifest(&args.out, &manifest)?;
342    println!("Wrote {}", path.display());
343
344    let auth = registry_auth(args.token.as_deref())?;
345    block_on_maybe_runtime(async {
346        let client = oci_client();
347        let release_ref = parse_reference(&args.repo, &args.release)?;
348        if !args.force && manifest_exists(&client, &release_ref, &auth).await? {
349            bail!(
350                "release tag `{}` already exists; pass --force to overwrite it",
351                toolchain_ref(&args.repo, &args.release)
352            );
353        }
354        push_manifest_layer(&client, &release_ref, &auth, &manifest).await?;
355        if let Some(tag) = &args.tag {
356            let tag_ref = parse_reference(&args.repo, tag)?;
357            push_manifest_layer(&client, &tag_ref, &auth, &manifest).await?;
358        }
359        Ok(())
360    })?;
361    println!("Published {}", toolchain_ref(&args.repo, &args.release));
362    if let Some(tag) = &args.tag {
363        println!("Updated {}", toolchain_ref(&args.repo, tag));
364    }
365    Ok(())
366}
367
368fn parse_channel(channel: &str) -> Result<ToolchainChannel> {
369    match channel {
370        "dev" | "development" => Ok(ToolchainChannel::Development),
371        "stable" => Ok(ToolchainChannel::Stable),
372        other => bail!(
373            "unknown channel `{other}` (expected `dev` or `stable`); pass --channel dev for the \
374             dev lane or --channel stable for the stable lane"
375        ),
376    }
377}
378
379fn channel_tag(channel: ToolchainChannel) -> &'static str {
380    match channel {
381        ToolchainChannel::Stable => "stable",
382        ToolchainChannel::Development => "dev",
383    }
384}
385
386pub fn snapshot_manifest<R: CrateVersionResolver>(
387    release: &str,
388    channel: ToolchainChannel,
389    resolver: &R,
390    created_at: Option<String>,
391) -> Result<ToolchainManifest> {
392    let from = channel_tag(channel);
393    let mut packages = Vec::new();
394    for package in GREENTIC_TOOLCHAIN_PACKAGES {
395        let crate_in_manifest = manifest_crate_name_for_source(from, package.crate_name);
396        let version = resolver
397            .resolve_latest(&crate_in_manifest)
398            .with_context(|| {
399                format!("failed to resolve latest version for `{crate_in_manifest}`")
400            })?;
401        packages.push(ToolchainPackage {
402            crate_name: crate_in_manifest,
403            bins: manifest_bins_for_source(from, package.bins),
404            version,
405        });
406    }
407    Ok(ToolchainManifest {
408        schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
409        toolchain: TOOLCHAIN_NAME.to_string(),
410        version: release.to_string(),
411        channel: Some(from.to_string()),
412        created_at,
413        packages,
414    })
415}
416
417pub fn view(args: ReleaseViewArgs) -> Result<()> {
418    let tag = release_view_tag(&args)?;
419    let manifest = block_on_maybe_runtime(load_source_manifest(
420        &args.repo,
421        &tag,
422        args.token.as_deref(),
423    ))
424    .with_context(|| {
425        format!(
426            "failed to resolve manifest `{}`",
427            toolchain_ref(&args.repo, &tag)
428        )
429    })?
430    .with_context(|| {
431        format!(
432            "manifest `{}` was not found or is not authorized for this token",
433            toolchain_ref(&args.repo, &tag)
434        )
435    })?;
436    println!("{}", serde_json::to_string_pretty(&manifest)?);
437    Ok(())
438}
439
440pub fn latest(args: ReleaseLatestArgs) -> Result<()> {
441    let manifest = latest_manifest(Some(created_at_now()?));
442    if args.dry_run {
443        println!("{}", serde_json::to_string_pretty(&manifest)?);
444        println!(
445            "Dry run: would publish {}",
446            toolchain_ref(&args.repo, "latest")
447        );
448        return Ok(());
449    }
450
451    let auth = registry_auth(args.token.as_deref())?;
452    block_on_maybe_runtime(async {
453        let client = oci_client();
454        let latest_ref = parse_reference(&args.repo, "latest")?;
455        if !args.force && manifest_exists(&client, &latest_ref, &auth).await? {
456            bail!(
457                "latest tag `{}` already exists; pass --force to overwrite it",
458                toolchain_ref(&args.repo, "latest")
459            );
460        }
461        push_manifest_layer(&client, &latest_ref, &auth, &manifest).await
462    })?;
463    println!("Published {}", toolchain_ref(&args.repo, "latest"));
464    Ok(())
465}
466
467fn latest_manifest(created_at: Option<String>) -> ToolchainManifest {
468    ToolchainManifest {
469        schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
470        toolchain: TOOLCHAIN_NAME.to_string(),
471        version: "latest".to_string(),
472        channel: Some("latest".to_string()),
473        created_at,
474        packages: latest_manifest_packages(),
475    }
476}
477
478fn latest_manifest_packages() -> Vec<ToolchainPackage> {
479    std::iter::once(ToolchainPackage {
480        crate_name: delegated_binary_name_for_channel(
481            TOOLCHAIN_NAME,
482            ToolchainChannel::Development,
483        ),
484        bins: vec![delegated_binary_name_for_channel(
485            TOOLCHAIN_NAME,
486            ToolchainChannel::Development,
487        )],
488        version: "latest".to_string(),
489    })
490    .chain(GREENTIC_TOOLCHAIN_PACKAGES.iter().map(|package| {
491        ToolchainPackage {
492            crate_name: delegated_binary_name_for_channel(
493                package.crate_name,
494                ToolchainChannel::Development,
495            ),
496            bins: package
497                .bins
498                .iter()
499                .map(|bin| delegated_binary_name_for_channel(bin, ToolchainChannel::Development))
500                .collect(),
501            version: "latest".to_string(),
502        }
503    }))
504    .collect()
505}
506
507fn release_view_tag(args: &ReleaseViewArgs) -> Result<String> {
508    match (&args.release, &args.tag) {
509        (Some(release), None) => Ok(release.clone()),
510        (None, Some(tag)) => Ok(tag.clone()),
511        _ => bail!("pass exactly one of --release or --tag"),
512    }
513}
514
515pub fn generate_manifest<R: CrateVersionResolver>(
516    release: &str,
517    from: &str,
518    source: Option<&ToolchainManifest>,
519    resolver: &R,
520    created_at: Option<String>,
521) -> Result<ToolchainManifest> {
522    if let Some(source) = source {
523        validate_manifest(source)?;
524    }
525    let source_versions = source_version_map(source);
526    let mut packages = Vec::new();
527    for package in GREENTIC_TOOLCHAIN_PACKAGES {
528        let crate_in_manifest = manifest_crate_name_for_source(from, package.crate_name);
529        let source_version = source_versions.get(&crate_in_manifest);
530        let version = match source_version.map(String::as_str) {
531            Some(version) if version != "latest" => version.to_string(),
532            _ => resolver.resolve_latest(&crate_in_manifest)?,
533        };
534        packages.push(ToolchainPackage {
535            crate_name: crate_in_manifest,
536            bins: manifest_bins_for_source(from, package.bins),
537            version,
538        });
539    }
540    Ok(ToolchainManifest {
541        schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
542        toolchain: TOOLCHAIN_NAME.to_string(),
543        version: release.to_string(),
544        channel: Some(from.to_string()),
545        created_at,
546        packages,
547    })
548}
549
550fn manifest_bins_for_source(from: &str, bins: &[&str]) -> Vec<String> {
551    if from == "dev" {
552        bins.iter()
553            .map(|bin| delegated_binary_name_for_channel(bin, ToolchainChannel::Development))
554            .collect()
555    } else {
556        bins.iter().map(|bin| (*bin).to_string()).collect()
557    }
558}
559
560/// Apply the dev-channel `-dev` suffix to a crate name when the manifest
561/// channel is `"dev"`. The dev-publish lane mirrors every binary crate as
562/// `<crate>-dev` (binary bifurcation); the toolchain manifest must pin the
563/// mirrored crate so `cargo binstall` resolves the dev artifact instead of
564/// the stable one. Reuses `delegated_binary_name_for_channel` because the
565/// rule is identical for crates and binaries (`-dev` suffix, with the
566/// special carve-out that `greentic-dev` itself becomes `greentic-dev-dev`).
567fn manifest_crate_name_for_source(from: &str, crate_name: &str) -> String {
568    if from == "dev" {
569        delegated_binary_name_for_channel(crate_name, ToolchainChannel::Development)
570    } else {
571        crate_name.to_string()
572    }
573}
574
575pub fn validate_manifest(manifest: &ToolchainManifest) -> Result<()> {
576    if manifest.schema != TOOLCHAIN_MANIFEST_SCHEMA {
577        bail!(
578            "unsupported toolchain manifest schema `{}`",
579            manifest.schema
580        );
581    }
582    if manifest.toolchain != TOOLCHAIN_NAME {
583        bail!("unsupported toolchain `{}`", manifest.toolchain);
584    }
585    Ok(())
586}
587
588pub fn toolchain_ref(repo: &str, tag: &str) -> String {
589    format!("{repo}:{tag}")
590}
591
592fn source_version_map(source: Option<&ToolchainManifest>) -> BTreeMap<String, String> {
593    let mut out = BTreeMap::new();
594    if let Some(source) = source {
595        for package in &source.packages {
596            out.insert(package.crate_name.clone(), package.version.clone());
597        }
598    }
599    out
600}
601
602fn write_manifest(out_dir: &Path, manifest: &ToolchainManifest) -> Result<PathBuf> {
603    fs::create_dir_all(out_dir)
604        .with_context(|| format!("failed to create {}", out_dir.display()))?;
605    let path = out_dir.join(manifest_file_name(manifest));
606    let json = serde_json::to_vec_pretty(manifest).context("failed to serialize manifest")?;
607    fs::write(&path, json).with_context(|| format!("failed to write {}", path.display()))?;
608    Ok(path)
609}
610
611fn manifest_file_name(manifest: &ToolchainManifest) -> String {
612    match manifest.channel.as_deref() {
613        Some("stable") | None => format!("gtc-{}.json", manifest.version),
614        Some(channel) => format!("gtc-{channel}-{}.json", manifest.version),
615    }
616}
617
618fn created_at_now() -> Result<String> {
619    OffsetDateTime::now_utc()
620        .format(&Rfc3339)
621        .context("failed to format current time")
622}
623
624pub trait CrateVersionResolver {
625    fn resolve_latest(&self, crate_name: &str) -> Result<String>;
626}
627
628/// Default resolver used by `generate`, `publish`, and `snapshot`. Hits the
629/// crates.io HTTP API directly — see `CratesIoApiVersionResolver` for why
630/// this is preferred over shelling out to `cargo search`.
631fn default_resolver() -> CratesIoApiVersionResolver {
632    CratesIoApiVersionResolver::default()
633}
634
635const CRATES_IO_API_BASE: &str = "https://crates.io/api/v1/crates";
636const CRATES_IO_USER_AGENT: &str = concat!(
637    "greentic-dev/",
638    env!("CARGO_PKG_VERSION"),
639    " (https://github.com/greenticai/greentic-dev)"
640);
641
642/// Resolve the latest published version of a crate by hitting the crates.io
643/// HTTP API directly. Returns `max_stable_version` when present, falling back
644/// to `newest_version` and then `max_version`. Replaces an earlier
645/// `cargo search`-based resolver that ranked results by relevance and parsed
646/// stdout heuristically — both brittle for `<name>-dev` aliases that share
647/// prefixes with their stable parents.
648pub struct CratesIoApiVersionResolver {
649    base_url: String,
650    client: reqwest::blocking::Client,
651}
652
653impl Default for CratesIoApiVersionResolver {
654    fn default() -> Self {
655        Self::new(CRATES_IO_API_BASE)
656    }
657}
658
659impl CratesIoApiVersionResolver {
660    pub fn new(base_url: impl Into<String>) -> Self {
661        let client = reqwest::blocking::Client::builder()
662            .user_agent(CRATES_IO_USER_AGENT)
663            .build()
664            .expect("failed to build crates.io API client");
665        Self {
666            base_url: base_url.into(),
667            client,
668        }
669    }
670}
671
672impl CrateVersionResolver for CratesIoApiVersionResolver {
673    fn resolve_latest(&self, crate_name: &str) -> Result<String> {
674        let url = format!("{}/{}", self.base_url.trim_end_matches('/'), crate_name);
675        let response = self
676            .client
677            .get(&url)
678            .send()
679            .with_context(|| format!("failed to GET {url}"))?;
680        let status = response.status();
681        let body = response
682            .text()
683            .with_context(|| format!("failed to read body of {url}"))?;
684        if !status.is_success() {
685            bail!("crates.io API GET {url} returned {status}: {body}");
686        }
687        parse_crates_io_version(crate_name, &body)
688    }
689}
690
691fn parse_crates_io_version(crate_name: &str, body: &str) -> Result<String> {
692    let payload: serde_json::Value = serde_json::from_str(body)
693        .with_context(|| format!("crates.io API for `{crate_name}` returned invalid JSON"))?;
694    let crate_obj = payload.get("crate").ok_or_else(|| {
695        anyhow!("crates.io API for `{crate_name}` is missing the top-level `crate` object")
696    })?;
697    let version = crate_obj
698        .get("max_stable_version")
699        .and_then(|v| v.as_str())
700        .or_else(|| crate_obj.get("newest_version").and_then(|v| v.as_str()))
701        .or_else(|| crate_obj.get("max_version").and_then(|v| v.as_str()))
702        .ok_or_else(|| {
703            anyhow!(
704                "crates.io API for `{crate_name}` does not expose max_stable_version, \
705                 newest_version, or max_version"
706            )
707        })?;
708    Version::parse(version).with_context(|| {
709        format!("crates.io returned an unparseable version `{version}` for `{crate_name}`")
710    })?;
711    Ok(version.to_string())
712}
713
714#[async_trait]
715trait ToolchainManifestSource {
716    async fn load_manifest(
717        &self,
718        repo: &str,
719        tag: &str,
720        token: Option<&str>,
721    ) -> Result<Option<ToolchainManifest>>;
722}
723
724struct OciToolchainManifestSource;
725
726#[async_trait]
727impl ToolchainManifestSource for OciToolchainManifestSource {
728    async fn load_manifest(
729        &self,
730        repo: &str,
731        tag: &str,
732        token: Option<&str>,
733    ) -> Result<Option<ToolchainManifest>> {
734        let auth = optional_registry_auth(token)?;
735        let client = oci_client();
736        let reference = parse_reference(repo, tag)?;
737        let image = match client
738            .pull(&reference, &auth, vec![TOOLCHAIN_LAYER_MEDIA_TYPE])
739            .await
740        {
741            Ok(image) => image,
742            Err(err) if is_missing_manifest_error(&err) || is_unauthorized_error(&err) => {
743                return Ok(None);
744            }
745            Err(err) => {
746                return Err(err)
747                    .with_context(|| format!("failed to pull {}", toolchain_ref(repo, tag)));
748            }
749        };
750        let Some(layer) = image
751            .layers
752            .into_iter()
753            .find(|layer| layer.media_type == TOOLCHAIN_LAYER_MEDIA_TYPE)
754        else {
755            return Ok(None);
756        };
757        let manifest = serde_json::from_slice::<ToolchainManifest>(&layer.data)
758            .with_context(|| format!("failed to parse {}", toolchain_ref(repo, tag)))?;
759        validate_manifest(&manifest)?;
760        Ok(Some(manifest))
761    }
762}
763
764async fn load_source_manifest(
765    repo: &str,
766    tag: &str,
767    token: Option<&str>,
768) -> Result<Option<ToolchainManifest>> {
769    OciToolchainManifestSource
770        .load_manifest(repo, tag, token)
771        .await
772}
773
774fn oci_client() -> Client {
775    Client::new(ClientConfig {
776        protocol: ClientProtocol::Https,
777        ..Default::default()
778    })
779}
780
781fn registry_auth(raw_token: Option<&str>) -> Result<RegistryAuth> {
782    let token = resolve_registry_token(raw_token)?
783        .or_else(|| std::env::var("GHCR_TOKEN").ok())
784        .or_else(|| std::env::var("GITHUB_TOKEN").ok())
785        .context("GHCR token is required; pass --token or set GHCR_TOKEN/GITHUB_TOKEN")?;
786    if token.trim().is_empty() {
787        bail!("GHCR token is empty");
788    }
789    Ok(RegistryAuth::Basic(DEFAULT_OAUTH_USER.to_string(), token))
790}
791
792fn optional_registry_auth(raw_token: Option<&str>) -> Result<RegistryAuth> {
793    match registry_auth(raw_token) {
794        Ok(auth) => Ok(auth),
795        Err(_) if raw_token.is_none() => Ok(RegistryAuth::Anonymous),
796        Err(err) => Err(err),
797    }
798}
799
800fn resolve_registry_token(raw_token: Option<&str>) -> Result<Option<String>> {
801    let Some(raw_token) = raw_token else {
802        return Ok(None);
803    };
804    if let Some(var) = raw_token.strip_prefix("env:") {
805        let token =
806            std::env::var(var).with_context(|| format!("failed to resolve env var {var}"))?;
807        if token.trim().is_empty() {
808            bail!("env var {var} resolved to an empty token");
809        }
810        return Ok(Some(token));
811    }
812    if raw_token.trim().is_empty() {
813        bail!("GHCR token is empty");
814    }
815    Ok(Some(raw_token.to_string()))
816}
817
818fn parse_reference(repo: &str, tag: &str) -> Result<Reference> {
819    Reference::from_str(&toolchain_ref(repo, tag))
820        .with_context(|| format!("invalid OCI reference `{}`", toolchain_ref(repo, tag)))
821}
822
823async fn manifest_exists(
824    client: &Client,
825    reference: &Reference,
826    auth: &RegistryAuth,
827) -> Result<bool> {
828    match client.pull_manifest(reference, auth).await {
829        Ok(_) => Ok(true),
830        Err(err) if is_missing_manifest_error(&err) => Ok(false),
831        Err(err) => Err(err).context("failed to check whether release tag exists"),
832    }
833}
834
835fn is_missing_manifest_error(err: &oci_distribution::errors::OciDistributionError) -> bool {
836    let msg = err.to_string().to_ascii_lowercase();
837    msg.contains("manifest unknown")
838        || msg.contains("name unknown")
839        || msg.contains("not found")
840        || msg.contains("404")
841}
842
843fn is_unauthorized_error(err: &oci_distribution::errors::OciDistributionError) -> bool {
844    let msg = err.to_string().to_ascii_lowercase();
845    msg.contains("not authorized") || msg.contains("unauthorized") || msg.contains("401")
846}
847
848async fn push_manifest_layer(
849    client: &Client,
850    reference: &Reference,
851    auth: &RegistryAuth,
852    manifest: &ToolchainManifest,
853) -> Result<()> {
854    let data = serde_json::to_vec_pretty(manifest).context("failed to serialize manifest")?;
855    let layer = ImageLayer::new(data, TOOLCHAIN_LAYER_MEDIA_TYPE.to_string(), None);
856    let config = Config::new(
857        br#"{"toolchain":"gtc"}"#.to_vec(),
858        TOOLCHAIN_CONFIG_MEDIA_TYPE.to_string(),
859        None,
860    );
861    client
862        .push(reference, &[layer], config, auth, None)
863        .await
864        .context("failed to push toolchain manifest")?;
865    Ok(())
866}
867
868#[cfg(test)]
869mod tests {
870    use super::*;
871    use once_cell::sync::Lazy;
872    use std::sync::Mutex;
873
874    static ENV_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
875
876    struct FixedResolver;
877
878    impl CrateVersionResolver for FixedResolver {
879        fn resolve_latest(&self, crate_name: &str) -> Result<String> {
880            Ok(match crate_name {
881                "greentic-runner" => "0.5.10",
882                _ => "1.2.3",
883            }
884            .to_string())
885        }
886    }
887
888    #[test]
889    fn parses_crates_io_max_stable_version() {
890        let body = r#"{"crate":{"id":"greentic-operator-dev","max_stable_version":"0.5.123"}}"#;
891        let version = parse_crates_io_version("greentic-operator-dev", body).unwrap();
892        assert_eq!(version, "0.5.123");
893    }
894
895    #[test]
896    fn parses_crates_io_falls_back_to_newest_version() {
897        let body = r#"{"crate":{"id":"greentic-flow-dev","newest_version":"0.6.7"}}"#;
898        let version = parse_crates_io_version("greentic-flow-dev", body).unwrap();
899        assert_eq!(version, "0.6.7");
900    }
901
902    #[test]
903    fn parses_crates_io_falls_back_to_max_version() {
904        let body = r#"{"crate":{"id":"greentic-runner-dev","max_version":"0.4.99"}}"#;
905        let version = parse_crates_io_version("greentic-runner-dev", body).unwrap();
906        assert_eq!(version, "0.4.99");
907    }
908
909    #[test]
910    fn rejects_crates_io_payload_without_versions() {
911        let body = r#"{"crate":{"id":"greentic-dev"}}"#;
912        let err = parse_crates_io_version("greentic-dev", body).unwrap_err();
913        assert!(
914            err.to_string()
915                .contains("does not expose max_stable_version")
916        );
917    }
918
919    #[test]
920    fn rejects_crates_io_payload_with_unparseable_version() {
921        let body = r#"{"crate":{"max_stable_version":"not-a-version"}}"#;
922        let err = parse_crates_io_version("greentic-dev", body).unwrap_err();
923        assert!(err.to_string().contains("unparseable version"));
924    }
925
926    #[test]
927    fn rejects_crates_io_payload_without_crate_object() {
928        let body = r#"{"errors":[{"detail":"not found"}]}"#;
929        let err = parse_crates_io_version("greentic-dev", body).unwrap_err();
930        assert!(
931            err.to_string()
932                .contains("missing the top-level `crate` object")
933        );
934    }
935
936    #[test]
937    fn generates_manifest_from_catalogue() {
938        let manifest = generate_manifest("1.0.5", "latest", None, &FixedResolver, None).unwrap();
939        assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
940        assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
941        assert_eq!(manifest.version, "1.0.5");
942        assert_eq!(manifest.channel.as_deref(), Some("latest"));
943        assert!(
944            manifest
945                .packages
946                .iter()
947                .any(|package| package.crate_name == "greentic-bundle"
948                    && package.bins == ["greentic-bundle"])
949        );
950        assert!(
951            manifest
952                .packages
953                .iter()
954                .any(|package| package.crate_name == "greentic-runner"
955                    && package.bins == ["greentic-runner"])
956        );
957    }
958
959    #[test]
960    fn source_manifest_can_pin_package_versions() {
961        let source = ToolchainManifest {
962            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
963            toolchain: TOOLCHAIN_NAME.to_string(),
964            version: "latest".to_string(),
965            channel: Some("latest".to_string()),
966            created_at: None,
967            packages: vec![ToolchainPackage {
968                crate_name: "greentic-dev".to_string(),
969                bins: vec!["greentic-dev".to_string()],
970                version: "0.5.9".to_string(),
971            }],
972        };
973        let manifest =
974            generate_manifest("1.0.5", "latest", Some(&source), &FixedResolver, None).unwrap();
975        let greentic_dev = manifest
976            .packages
977            .iter()
978            .find(|package| package.crate_name == "greentic-dev")
979            .unwrap();
980        assert_eq!(greentic_dev.version, "0.5.9");
981    }
982
983    #[test]
984    fn from_argument_controls_generated_channel_over_source_manifest() {
985        let source = ToolchainManifest {
986            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
987            toolchain: TOOLCHAIN_NAME.to_string(),
988            version: "latest".to_string(),
989            channel: Some("stable".to_string()),
990            created_at: None,
991            packages: Vec::new(),
992        };
993        let manifest =
994            generate_manifest("1.0.16", "dev", Some(&source), &FixedResolver, None).unwrap();
995        assert_eq!(manifest.channel.as_deref(), Some("dev"));
996        assert_eq!(manifest_file_name(&manifest), "gtc-dev-1.0.16.json");
997    }
998
999    #[test]
1000    fn generate_from_dev_uses_dev_crate_and_binary_names() {
1001        let manifest = generate_manifest("1.0.16", "dev", None, &FixedResolver, None).unwrap();
1002        assert!(
1003            manifest
1004                .packages
1005                .iter()
1006                .flat_map(|package| package.bins.iter())
1007                .all(|bin| bin.ends_with("-dev"))
1008        );
1009        assert!(
1010            manifest
1011                .packages
1012                .iter()
1013                .all(|package| package.crate_name.ends_with("-dev")),
1014            "dev manifest must pin -dev crate names so binstall resolves the dev mirror"
1015        );
1016        assert!(manifest.packages.iter().any(|package| {
1017            package.crate_name == "greentic-flow-dev" && package.bins == ["greentic-flow-dev"]
1018        }));
1019        assert!(manifest.packages.iter().any(|package| {
1020            package.crate_name == "greentic-component-dev"
1021                && package.bins == ["greentic-component-dev"]
1022        }));
1023        assert!(manifest.packages.iter().any(|package| {
1024            package.crate_name == "greentic-dev-dev" && package.bins == ["greentic-dev-dev"]
1025        }));
1026    }
1027
1028    #[test]
1029    fn snapshot_manifest_dev_channel_resolves_dev_aliases() {
1030        let manifest =
1031            snapshot_manifest("1.1.5", ToolchainChannel::Development, &FixedResolver, None)
1032                .unwrap();
1033        assert_eq!(manifest.version, "1.1.5");
1034        assert_eq!(manifest.channel.as_deref(), Some("dev"));
1035        for package in &manifest.packages {
1036            assert!(
1037                package.crate_name.ends_with("-dev"),
1038                "dev snapshot must pin -dev crate names; got {}",
1039                package.crate_name
1040            );
1041            assert!(
1042                package.bins.iter().all(|bin| bin.ends_with("-dev")),
1043                "dev snapshot must pin -dev bin names; got {:?}",
1044                package.bins
1045            );
1046            assert_ne!(
1047                package.version, "latest",
1048                "snapshot must always resolve concrete versions"
1049            );
1050        }
1051        assert!(
1052            manifest
1053                .packages
1054                .iter()
1055                .any(|package| package.crate_name == "greentic-operator-dev")
1056        );
1057    }
1058
1059    #[test]
1060    fn snapshot_manifest_stable_channel_keeps_plain_names() {
1061        let manifest =
1062            snapshot_manifest("1.0.20", ToolchainChannel::Stable, &FixedResolver, None).unwrap();
1063        assert_eq!(manifest.channel.as_deref(), Some("stable"));
1064        // The stable channel must NOT apply the `-dev` suffix transform.
1065        // Cross-check against the catalogue: every stable package must match
1066        // a catalogue entry by exact name (no transform applied).
1067        let catalogue_names: std::collections::BTreeSet<_> = GREENTIC_TOOLCHAIN_PACKAGES
1068            .iter()
1069            .map(|spec| spec.crate_name)
1070            .collect();
1071        for package in &manifest.packages {
1072            assert!(
1073                catalogue_names.contains(package.crate_name.as_str()),
1074                "stable snapshot crate `{}` was transformed; expected a verbatim catalogue entry",
1075                package.crate_name
1076            );
1077        }
1078    }
1079
1080    #[test]
1081    fn snapshot_manifest_resolves_via_resolver() {
1082        let manifest =
1083            snapshot_manifest("1.1.6", ToolchainChannel::Development, &FixedResolver, None)
1084                .unwrap();
1085        // FixedResolver returns 1.2.3 for everything except `greentic-runner`.
1086        // The dev channel queries `greentic-runner-dev`, not `greentic-runner`,
1087        // so the special case in FixedResolver does not apply and every
1088        // package should land on the default 1.2.3 — proving the resolver was
1089        // hit (rather than versions copied from somewhere).
1090        for package in &manifest.packages {
1091            assert_eq!(
1092                package.version, "1.2.3",
1093                "resolver must be hit for {}",
1094                package.crate_name
1095            );
1096        }
1097    }
1098
1099    #[test]
1100    fn parses_dev_channel_argument() {
1101        assert_eq!(parse_channel("dev").unwrap(), ToolchainChannel::Development);
1102        assert_eq!(
1103            parse_channel("development").unwrap(),
1104            ToolchainChannel::Development
1105        );
1106        assert_eq!(parse_channel("stable").unwrap(), ToolchainChannel::Stable);
1107        assert!(parse_channel("rc").is_err());
1108    }
1109
1110    #[test]
1111    fn detects_concrete_pins_for_publish_deprecation_warning() {
1112        let with_pins = ToolchainManifest {
1113            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
1114            toolchain: TOOLCHAIN_NAME.to_string(),
1115            version: "0.0.1".to_string(),
1116            channel: Some("dev".to_string()),
1117            created_at: None,
1118            packages: vec![ToolchainPackage {
1119                crate_name: "greentic-operator-dev".to_string(),
1120                bins: vec!["greentic-operator-dev".to_string()],
1121                version: "0.5.123".to_string(),
1122            }],
1123        };
1124        assert!(source_manifest_has_concrete_pins(&with_pins));
1125
1126        let only_latest = ToolchainManifest {
1127            packages: vec![ToolchainPackage {
1128                crate_name: "greentic-operator".to_string(),
1129                bins: vec!["greentic-operator".to_string()],
1130                version: "latest".to_string(),
1131            }],
1132            ..with_pins
1133        };
1134        assert!(!source_manifest_has_concrete_pins(&only_latest));
1135    }
1136
1137    #[test]
1138    fn bootstrap_source_manifest_uses_source_tag_identity() {
1139        let manifest = bootstrap_source_manifest("latest", &FixedResolver, None).unwrap();
1140        assert_eq!(manifest.version, "latest");
1141        assert_eq!(manifest.channel.as_deref(), Some("latest"));
1142        assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
1143        assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
1144        assert!(
1145            manifest
1146                .packages
1147                .iter()
1148                .all(|package| package.version != "latest")
1149        );
1150    }
1151
1152    #[test]
1153    fn validates_schema_and_toolchain() {
1154        let mut manifest =
1155            generate_manifest("1.0.5", "latest", None, &FixedResolver, None).unwrap();
1156        assert!(validate_manifest(&manifest).is_ok());
1157        manifest.schema = "wrong".to_string();
1158        assert!(validate_manifest(&manifest).is_err());
1159        manifest.schema = TOOLCHAIN_MANIFEST_SCHEMA.to_string();
1160        manifest.toolchain = "other".to_string();
1161        assert!(validate_manifest(&manifest).is_err());
1162    }
1163
1164    #[test]
1165    fn resolves_inline_registry_token() {
1166        assert_eq!(
1167            resolve_registry_token(Some("secret-token"))
1168                .unwrap()
1169                .as_deref(),
1170            Some("secret-token")
1171        );
1172    }
1173
1174    #[test]
1175    fn resolves_registry_token_from_environment_reference() {
1176        let _guard = ENV_LOCK.lock().unwrap();
1177        let previous = std::env::var("RELEASE_CMD_TEST_TOKEN").ok();
1178        unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", "env-secret") };
1179
1180        let resolved = resolve_registry_token(Some("env:RELEASE_CMD_TEST_TOKEN")).unwrap();
1181        assert_eq!(resolved.as_deref(), Some("env-secret"));
1182
1183        match previous {
1184            Some(value) => unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", value) },
1185            None => unsafe { std::env::remove_var("RELEASE_CMD_TEST_TOKEN") },
1186        }
1187    }
1188
1189    #[test]
1190    fn rejects_empty_registry_token_from_environment_reference() {
1191        let _guard = ENV_LOCK.lock().unwrap();
1192        let previous = std::env::var("RELEASE_CMD_TEST_TOKEN").ok();
1193        unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", "   ") };
1194
1195        let err = resolve_registry_token(Some("env:RELEASE_CMD_TEST_TOKEN")).unwrap_err();
1196        assert!(err.to_string().contains("resolved to an empty token"));
1197
1198        match previous {
1199            Some(value) => unsafe { std::env::set_var("RELEASE_CMD_TEST_TOKEN", value) },
1200            None => unsafe { std::env::remove_var("RELEASE_CMD_TEST_TOKEN") },
1201        }
1202    }
1203
1204    #[test]
1205    fn registry_auth_uses_environment_fallbacks() {
1206        let _guard = ENV_LOCK.lock().unwrap();
1207        let previous_ghcr = std::env::var("GHCR_TOKEN").ok();
1208        let previous_github = std::env::var("GITHUB_TOKEN").ok();
1209        unsafe { std::env::set_var("GHCR_TOKEN", "ghcr-secret") };
1210        unsafe { std::env::remove_var("GITHUB_TOKEN") };
1211
1212        let auth = registry_auth(None).unwrap();
1213        match auth {
1214            RegistryAuth::Basic(user, token) => {
1215                assert_eq!(user, DEFAULT_OAUTH_USER);
1216                assert_eq!(token, "ghcr-secret");
1217            }
1218            _ => panic!("expected basic auth"),
1219        }
1220
1221        match previous_ghcr {
1222            Some(value) => unsafe { std::env::set_var("GHCR_TOKEN", value) },
1223            None => unsafe { std::env::remove_var("GHCR_TOKEN") },
1224        }
1225        match previous_github {
1226            Some(value) => unsafe { std::env::set_var("GITHUB_TOKEN", value) },
1227            None => unsafe { std::env::remove_var("GITHUB_TOKEN") },
1228        }
1229    }
1230
1231    #[test]
1232    fn optional_registry_auth_allows_missing_implicit_token() {
1233        let _guard = ENV_LOCK.lock().unwrap();
1234        let previous_ghcr = std::env::var("GHCR_TOKEN").ok();
1235        let previous_github = std::env::var("GITHUB_TOKEN").ok();
1236        unsafe { std::env::remove_var("GHCR_TOKEN") };
1237        unsafe { std::env::remove_var("GITHUB_TOKEN") };
1238
1239        let auth = optional_registry_auth(None).unwrap();
1240        assert!(matches!(auth, RegistryAuth::Anonymous));
1241
1242        match previous_ghcr {
1243            Some(value) => unsafe { std::env::set_var("GHCR_TOKEN", value) },
1244            None => unsafe { std::env::remove_var("GHCR_TOKEN") },
1245        }
1246        match previous_github {
1247            Some(value) => unsafe { std::env::set_var("GITHUB_TOKEN", value) },
1248            None => unsafe { std::env::remove_var("GITHUB_TOKEN") },
1249        }
1250    }
1251
1252    #[test]
1253    fn release_view_tag_prefers_release_or_tag() {
1254        let args = ReleaseViewArgs {
1255            release: Some("1.0.5".to_string()),
1256            tag: None,
1257            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1258            token: None,
1259        };
1260        assert_eq!(release_view_tag(&args).unwrap(), "1.0.5");
1261
1262        let args = ReleaseViewArgs {
1263            release: None,
1264            tag: Some("stable".to_string()),
1265            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1266            token: None,
1267        };
1268        assert_eq!(release_view_tag(&args).unwrap(), "stable");
1269    }
1270
1271    #[test]
1272    fn release_view_tag_rejects_invalid_argument_combinations() {
1273        let err = release_view_tag(&ReleaseViewArgs {
1274            release: Some("1.0.5".to_string()),
1275            tag: Some("stable".to_string()),
1276            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1277            token: None,
1278        })
1279        .unwrap_err();
1280        assert!(
1281            err.to_string()
1282                .contains("pass exactly one of --release or --tag")
1283        );
1284
1285        let err = release_view_tag(&ReleaseViewArgs {
1286            release: None,
1287            tag: None,
1288            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1289            token: None,
1290        })
1291        .unwrap_err();
1292        assert!(
1293            err.to_string()
1294                .contains("pass exactly one of --release or --tag")
1295        );
1296    }
1297
1298    #[test]
1299    fn publish_manifest_input_uses_local_manifest_version() {
1300        let dir = tempfile::tempdir().unwrap();
1301        let path = dir.path().join("gtc-1.0.12.json");
1302        let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
1303        fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
1304        let args = ReleasePublishArgs {
1305            release: None,
1306            from: None,
1307            tag: Some("stable".to_string()),
1308            manifest: Some(path.clone()),
1309            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1310            token: None,
1311            out: dir.path().to_path_buf(),
1312            dry_run: true,
1313            force: true,
1314        };
1315        let (release, loaded, source_path) = publish_manifest_input(&args).unwrap();
1316        assert_eq!(release, "1.0.12");
1317        assert_eq!(loaded, manifest);
1318        assert_eq!(
1319            source_path,
1320            Some(PublishManifestSource::Local(path.clone()))
1321        );
1322    }
1323
1324    #[test]
1325    fn publish_manifest_input_allows_release_override_for_local_manifest() {
1326        let dir = tempfile::tempdir().unwrap();
1327        let path = dir.path().join("gtc-1.0.13.json");
1328        let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
1329        fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
1330        let args = ReleasePublishArgs {
1331            release: Some("1.0.13".to_string()),
1332            from: None,
1333            tag: Some("stable".to_string()),
1334            manifest: Some(path.clone()),
1335            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1336            token: None,
1337            out: dir.path().to_path_buf(),
1338            dry_run: true,
1339            force: true,
1340        };
1341        let (release, loaded, source_path) = publish_manifest_input(&args).unwrap();
1342        assert_eq!(release, "1.0.13");
1343        assert_eq!(loaded.version, "1.0.13");
1344        assert_eq!(
1345            source_path,
1346            Some(PublishManifestSource::Local(path.clone()))
1347        );
1348    }
1349
1350    #[test]
1351    fn manifest_file_name_omits_stable_channel() {
1352        let manifest = ToolchainManifest {
1353            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
1354            toolchain: TOOLCHAIN_NAME.to_string(),
1355            version: "1.0.12".to_string(),
1356            channel: Some("stable".to_string()),
1357            created_at: None,
1358            packages: Vec::new(),
1359        };
1360        assert_eq!(manifest_file_name(&manifest), "gtc-1.0.12.json");
1361    }
1362
1363    #[test]
1364    fn manifest_file_name_includes_non_stable_channel() {
1365        let mut manifest = generate_manifest("1.0.12", "dev", None, &FixedResolver, None).unwrap();
1366        assert_eq!(manifest_file_name(&manifest), "gtc-dev-1.0.12.json");
1367
1368        manifest.channel = Some("customer-a".to_string());
1369        assert_eq!(manifest_file_name(&manifest), "gtc-customer-a-1.0.12.json");
1370    }
1371
1372    #[test]
1373    fn manifest_helpers_only_apply_dev_suffix_for_dev_channel() {
1374        assert_eq!(
1375            manifest_bins_for_source("latest", &["greentic-dev", "greentic-runner"]),
1376            vec!["greentic-dev".to_string(), "greentic-runner".to_string()]
1377        );
1378        assert_eq!(
1379            manifest_bins_for_source("dev", &["greentic-dev"]),
1380            vec!["greentic-dev-dev".to_string()]
1381        );
1382        assert_eq!(
1383            manifest_crate_name_for_source("latest", "greentic-runner"),
1384            "greentic-runner"
1385        );
1386        assert_eq!(
1387            manifest_crate_name_for_source("dev", "greentic-runner"),
1388            "greentic-runner-dev"
1389        );
1390    }
1391
1392    #[test]
1393    fn source_version_map_handles_missing_and_present_sources() {
1394        assert!(source_version_map(None).is_empty());
1395
1396        let source = ToolchainManifest {
1397            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
1398            toolchain: TOOLCHAIN_NAME.to_string(),
1399            version: "latest".to_string(),
1400            channel: Some("latest".to_string()),
1401            created_at: None,
1402            packages: vec![ToolchainPackage {
1403                crate_name: "greentic-dev".to_string(),
1404                bins: vec!["greentic-dev".to_string()],
1405                version: "0.6.0".to_string(),
1406            }],
1407        };
1408
1409        let versions = source_version_map(Some(&source));
1410        assert_eq!(
1411            versions.get("greentic-dev").map(String::as_str),
1412            Some("0.6.0")
1413        );
1414    }
1415
1416    #[test]
1417    fn write_manifest_persists_json_to_expected_file_name() {
1418        let dir = tempfile::tempdir().unwrap();
1419        let manifest = generate_manifest("1.0.12", "dev", None, &FixedResolver, None).unwrap();
1420
1421        let path = write_manifest(dir.path(), &manifest).unwrap();
1422        assert_eq!(
1423            path.file_name().and_then(|name| name.to_str()),
1424            Some("gtc-dev-1.0.12.json")
1425        );
1426
1427        let roundtrip = read_manifest_file(&path).unwrap();
1428        assert_eq!(roundtrip, manifest);
1429    }
1430
1431    #[test]
1432    fn latest_manifest_uses_latest_dev_bins() {
1433        let manifest = latest_manifest(None);
1434        assert_eq!(manifest.version, "latest");
1435        assert_eq!(manifest.channel.as_deref(), Some("latest"));
1436        assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
1437        assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
1438        assert!(!manifest.packages.is_empty());
1439        assert!(
1440            manifest
1441                .packages
1442                .iter()
1443                .all(|package| package.version == "latest")
1444        );
1445        assert!(
1446            manifest
1447                .packages
1448                .iter()
1449                .flat_map(|package| package.bins.iter())
1450                .all(|bin| bin.ends_with("-dev"))
1451        );
1452        assert!(
1453            manifest
1454                .packages
1455                .iter()
1456                .all(|package| package.crate_name.ends_with("-dev")),
1457            "latest-channel manifest mirrors dev binaries, so crate names must be -dev too"
1458        );
1459        assert!(
1460            manifest
1461                .packages
1462                .iter()
1463                .any(|package| { package.crate_name == "gtc-dev" && package.bins == ["gtc-dev"] })
1464        );
1465        assert!(manifest.packages.iter().any(|package| {
1466            package.crate_name == "greentic-dev-dev" && package.bins == ["greentic-dev-dev"]
1467        }));
1468    }
1469
1470    #[test]
1471    fn publish_dry_run_with_local_manifest_succeeds() {
1472        let dir = tempfile::tempdir().unwrap();
1473        let path = dir.path().join("gtc-1.0.12.json");
1474        let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
1475        fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
1476
1477        publish(ReleasePublishArgs {
1478            release: None,
1479            from: None,
1480            tag: Some("stable".to_string()),
1481            manifest: Some(path),
1482            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1483            token: None,
1484            out: dir.path().to_path_buf(),
1485            dry_run: true,
1486            force: false,
1487        })
1488        .unwrap();
1489    }
1490
1491    #[test]
1492    fn latest_dry_run_succeeds() {
1493        latest(ReleaseLatestArgs {
1494            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1495            token: None,
1496            dry_run: true,
1497            force: false,
1498        })
1499        .unwrap();
1500    }
1501
1502    #[test]
1503    fn promote_dry_run_succeeds() {
1504        promote(ReleasePromoteArgs {
1505            release: "1.0.12".to_string(),
1506            tag: "stable".to_string(),
1507            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
1508            token: None,
1509            dry_run: true,
1510        })
1511        .unwrap();
1512    }
1513
1514    #[test]
1515    fn builds_toolchain_ref() {
1516        assert_eq!(
1517            toolchain_ref("ghcr.io/greenticai/greentic-versions/gtc", "stable"),
1518            "ghcr.io/greenticai/greentic-versions/gtc:stable"
1519        );
1520    }
1521}