Skip to main content

greentic_dev/
release_cmd.rs

1use std::collections::BTreeMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5use std::str::FromStr;
6
7use anyhow::{Context, Result, anyhow, bail};
8use async_trait::async_trait;
9use oci_distribution::Reference;
10use oci_distribution::client::{Client, ClientConfig, ClientProtocol, Config, ImageLayer};
11use oci_distribution::secrets::RegistryAuth;
12use semver::Version;
13use serde::{Deserialize, Serialize};
14use time::OffsetDateTime;
15use time::format_description::well_known::Rfc3339;
16
17use crate::cli::{
18    ReleaseGenerateArgs, ReleaseLatestArgs, ReleasePromoteArgs, ReleasePublishArgs, 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 = CargoSearchVersionResolver;
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 = CargoSearchVersionResolver;
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    let manifest = generate_manifest(
225        release,
226        from,
227        source.as_ref(),
228        &resolver,
229        Some(created_at_now()?),
230    )?;
231    let path = if args.dry_run {
232        println!("{}", serde_json::to_string_pretty(&manifest)?);
233        None
234    } else {
235        Some(PublishManifestSource::Generated(write_manifest(
236            &args.out, &manifest,
237        )?))
238    };
239    Ok((release.to_string(), manifest, path))
240}
241
242fn read_manifest_file(path: &Path) -> Result<ToolchainManifest> {
243    let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
244    serde_json::from_slice(&bytes).with_context(|| format!("failed to parse {}", path.display()))
245}
246
247pub fn promote(args: ReleasePromoteArgs) -> Result<()> {
248    if args.dry_run {
249        println!(
250            "Dry run: would promote {} to {}",
251            toolchain_ref(&args.repo, &args.release),
252            toolchain_ref(&args.repo, &args.tag)
253        );
254        return Ok(());
255    }
256
257    let auth = registry_auth(args.token.as_deref())?;
258    block_on_maybe_runtime(async {
259        let client = oci_client();
260        let source_ref = parse_reference(&args.repo, &args.release)?;
261        let target_ref = parse_reference(&args.repo, &args.tag)?;
262        let (manifest, _) = client
263            .pull_manifest(&source_ref, &auth)
264            .await
265            .with_context(|| {
266                format!(
267                    "failed to resolve source release `{}`",
268                    toolchain_ref(&args.repo, &args.release)
269                )
270            })?;
271        client
272            .push_manifest(&target_ref, &manifest)
273            .await
274            .with_context(|| {
275                format!(
276                    "failed to update tag `{}`",
277                    toolchain_ref(&args.repo, &args.tag)
278                )
279            })?;
280        Ok(())
281    })?;
282    println!(
283        "Promoted {} to {}",
284        toolchain_ref(&args.repo, &args.release),
285        toolchain_ref(&args.repo, &args.tag)
286    );
287    Ok(())
288}
289
290pub fn view(args: ReleaseViewArgs) -> Result<()> {
291    let tag = release_view_tag(&args)?;
292    let manifest = block_on_maybe_runtime(load_source_manifest(
293        &args.repo,
294        &tag,
295        args.token.as_deref(),
296    ))
297    .with_context(|| {
298        format!(
299            "failed to resolve manifest `{}`",
300            toolchain_ref(&args.repo, &tag)
301        )
302    })?
303    .with_context(|| {
304        format!(
305            "manifest `{}` was not found or is not authorized for this token",
306            toolchain_ref(&args.repo, &tag)
307        )
308    })?;
309    println!("{}", serde_json::to_string_pretty(&manifest)?);
310    Ok(())
311}
312
313pub fn latest(args: ReleaseLatestArgs) -> Result<()> {
314    let manifest = latest_manifest(Some(created_at_now()?));
315    if args.dry_run {
316        println!("{}", serde_json::to_string_pretty(&manifest)?);
317        println!(
318            "Dry run: would publish {}",
319            toolchain_ref(&args.repo, "latest")
320        );
321        return Ok(());
322    }
323
324    let auth = registry_auth(args.token.as_deref())?;
325    block_on_maybe_runtime(async {
326        let client = oci_client();
327        let latest_ref = parse_reference(&args.repo, "latest")?;
328        if !args.force && manifest_exists(&client, &latest_ref, &auth).await? {
329            bail!(
330                "latest tag `{}` already exists; pass --force to overwrite it",
331                toolchain_ref(&args.repo, "latest")
332            );
333        }
334        push_manifest_layer(&client, &latest_ref, &auth, &manifest).await
335    })?;
336    println!("Published {}", toolchain_ref(&args.repo, "latest"));
337    Ok(())
338}
339
340fn latest_manifest(created_at: Option<String>) -> ToolchainManifest {
341    ToolchainManifest {
342        schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
343        toolchain: TOOLCHAIN_NAME.to_string(),
344        version: "latest".to_string(),
345        channel: Some("latest".to_string()),
346        created_at,
347        packages: latest_manifest_packages(),
348    }
349}
350
351fn latest_manifest_packages() -> Vec<ToolchainPackage> {
352    std::iter::once(ToolchainPackage {
353        crate_name: TOOLCHAIN_NAME.to_string(),
354        bins: vec![delegated_binary_name_for_channel(
355            TOOLCHAIN_NAME,
356            ToolchainChannel::Development,
357        )],
358        version: "latest".to_string(),
359    })
360    .chain(GREENTIC_TOOLCHAIN_PACKAGES.iter().map(|package| {
361        ToolchainPackage {
362            crate_name: package.crate_name.to_string(),
363            bins: package
364                .bins
365                .iter()
366                .map(|bin| delegated_binary_name_for_channel(bin, ToolchainChannel::Development))
367                .collect(),
368            version: "latest".to_string(),
369        }
370    }))
371    .collect()
372}
373
374fn release_view_tag(args: &ReleaseViewArgs) -> Result<String> {
375    match (&args.release, &args.tag) {
376        (Some(release), None) => Ok(release.clone()),
377        (None, Some(tag)) => Ok(tag.clone()),
378        _ => bail!("pass exactly one of --release or --tag"),
379    }
380}
381
382pub fn generate_manifest<R: CrateVersionResolver>(
383    release: &str,
384    from: &str,
385    source: Option<&ToolchainManifest>,
386    resolver: &R,
387    created_at: Option<String>,
388) -> Result<ToolchainManifest> {
389    if let Some(source) = source {
390        validate_manifest(source)?;
391    }
392    let source_versions = source_version_map(source);
393    let mut packages = Vec::new();
394    for package in GREENTIC_TOOLCHAIN_PACKAGES {
395        let source_version = source_versions.get(package.crate_name);
396        let version = match source_version.map(String::as_str) {
397            Some(version) if version != "latest" => version.to_string(),
398            _ => resolver.resolve_latest(package.crate_name)?,
399        };
400        packages.push(ToolchainPackage {
401            crate_name: package.crate_name.to_string(),
402            bins: manifest_bins_for_source(from, package.bins),
403            version,
404        });
405    }
406    Ok(ToolchainManifest {
407        schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
408        toolchain: TOOLCHAIN_NAME.to_string(),
409        version: release.to_string(),
410        channel: Some(from.to_string()),
411        created_at,
412        packages,
413    })
414}
415
416fn manifest_bins_for_source(from: &str, bins: &[&str]) -> Vec<String> {
417    if from == "dev" {
418        bins.iter()
419            .map(|bin| delegated_binary_name_for_channel(bin, ToolchainChannel::Development))
420            .collect()
421    } else {
422        bins.iter().map(|bin| (*bin).to_string()).collect()
423    }
424}
425
426pub fn validate_manifest(manifest: &ToolchainManifest) -> Result<()> {
427    if manifest.schema != TOOLCHAIN_MANIFEST_SCHEMA {
428        bail!(
429            "unsupported toolchain manifest schema `{}`",
430            manifest.schema
431        );
432    }
433    if manifest.toolchain != TOOLCHAIN_NAME {
434        bail!("unsupported toolchain `{}`", manifest.toolchain);
435    }
436    Ok(())
437}
438
439pub fn toolchain_ref(repo: &str, tag: &str) -> String {
440    format!("{repo}:{tag}")
441}
442
443fn source_version_map(source: Option<&ToolchainManifest>) -> BTreeMap<String, String> {
444    let mut out = BTreeMap::new();
445    if let Some(source) = source {
446        for package in &source.packages {
447            out.insert(package.crate_name.clone(), package.version.clone());
448        }
449    }
450    out
451}
452
453fn write_manifest(out_dir: &Path, manifest: &ToolchainManifest) -> Result<PathBuf> {
454    fs::create_dir_all(out_dir)
455        .with_context(|| format!("failed to create {}", out_dir.display()))?;
456    let path = out_dir.join(manifest_file_name(manifest));
457    let json = serde_json::to_vec_pretty(manifest).context("failed to serialize manifest")?;
458    fs::write(&path, json).with_context(|| format!("failed to write {}", path.display()))?;
459    Ok(path)
460}
461
462fn manifest_file_name(manifest: &ToolchainManifest) -> String {
463    match manifest.channel.as_deref() {
464        Some("stable") | None => format!("gtc-{}.json", manifest.version),
465        Some(channel) => format!("gtc-{channel}-{}.json", manifest.version),
466    }
467}
468
469fn created_at_now() -> Result<String> {
470    OffsetDateTime::now_utc()
471        .format(&Rfc3339)
472        .context("failed to format current time")
473}
474
475pub trait CrateVersionResolver {
476    fn resolve_latest(&self, crate_name: &str) -> Result<String>;
477}
478
479struct CargoSearchVersionResolver;
480
481impl CrateVersionResolver for CargoSearchVersionResolver {
482    fn resolve_latest(&self, crate_name: &str) -> Result<String> {
483        let output = Command::new("cargo")
484            .arg("search")
485            .arg(crate_name)
486            .arg("--limit")
487            .arg("1")
488            .output()
489            .with_context(|| format!("failed to execute `cargo search {crate_name} --limit 1`"))?;
490        if !output.status.success() {
491            bail!(
492                "`cargo search {crate_name} --limit 1` failed with exit code {:?}",
493                output.status.code()
494            );
495        }
496        let stdout = String::from_utf8(output.stdout).with_context(|| {
497            format!("`cargo search {crate_name} --limit 1` returned non-UTF8 output")
498        })?;
499        parse_cargo_search_version(crate_name, &stdout)
500    }
501}
502
503fn parse_cargo_search_version(crate_name: &str, stdout: &str) -> Result<String> {
504    let first_line = stdout
505        .lines()
506        .find(|line| !line.trim().is_empty())
507        .ok_or_else(|| anyhow!("`cargo search {crate_name} --limit 1` returned no results"))?;
508    let Some((found_name, rhs)) = first_line.split_once('=') else {
509        bail!("unexpected cargo search output: {first_line}");
510    };
511    if found_name.trim() != crate_name {
512        bail!(
513            "`cargo search {crate_name} --limit 1` returned `{}` first",
514            found_name.trim()
515        );
516    }
517    let quoted = rhs
518        .split('#')
519        .next()
520        .map(str::trim)
521        .ok_or_else(|| anyhow!("unexpected cargo search output: {first_line}"))?;
522    let version = quoted.trim_matches('"');
523    Version::parse(version)
524        .with_context(|| format!("failed to parse crate version from `{first_line}`"))?;
525    Ok(version.to_string())
526}
527
528#[async_trait]
529trait ToolchainManifestSource {
530    async fn load_manifest(
531        &self,
532        repo: &str,
533        tag: &str,
534        token: Option<&str>,
535    ) -> Result<Option<ToolchainManifest>>;
536}
537
538struct OciToolchainManifestSource;
539
540#[async_trait]
541impl ToolchainManifestSource for OciToolchainManifestSource {
542    async fn load_manifest(
543        &self,
544        repo: &str,
545        tag: &str,
546        token: Option<&str>,
547    ) -> Result<Option<ToolchainManifest>> {
548        let auth = optional_registry_auth(token)?;
549        let client = oci_client();
550        let reference = parse_reference(repo, tag)?;
551        let image = match client
552            .pull(&reference, &auth, vec![TOOLCHAIN_LAYER_MEDIA_TYPE])
553            .await
554        {
555            Ok(image) => image,
556            Err(err) if is_missing_manifest_error(&err) || is_unauthorized_error(&err) => {
557                return Ok(None);
558            }
559            Err(err) => {
560                return Err(err)
561                    .with_context(|| format!("failed to pull {}", toolchain_ref(repo, tag)));
562            }
563        };
564        let Some(layer) = image
565            .layers
566            .into_iter()
567            .find(|layer| layer.media_type == TOOLCHAIN_LAYER_MEDIA_TYPE)
568        else {
569            return Ok(None);
570        };
571        let manifest = serde_json::from_slice::<ToolchainManifest>(&layer.data)
572            .with_context(|| format!("failed to parse {}", toolchain_ref(repo, tag)))?;
573        validate_manifest(&manifest)?;
574        Ok(Some(manifest))
575    }
576}
577
578async fn load_source_manifest(
579    repo: &str,
580    tag: &str,
581    token: Option<&str>,
582) -> Result<Option<ToolchainManifest>> {
583    OciToolchainManifestSource
584        .load_manifest(repo, tag, token)
585        .await
586}
587
588fn oci_client() -> Client {
589    Client::new(ClientConfig {
590        protocol: ClientProtocol::Https,
591        ..Default::default()
592    })
593}
594
595fn registry_auth(raw_token: Option<&str>) -> Result<RegistryAuth> {
596    let token = resolve_registry_token(raw_token)?
597        .or_else(|| std::env::var("GHCR_TOKEN").ok())
598        .or_else(|| std::env::var("GITHUB_TOKEN").ok())
599        .context("GHCR token is required; pass --token or set GHCR_TOKEN/GITHUB_TOKEN")?;
600    if token.trim().is_empty() {
601        bail!("GHCR token is empty");
602    }
603    Ok(RegistryAuth::Basic(DEFAULT_OAUTH_USER.to_string(), token))
604}
605
606fn optional_registry_auth(raw_token: Option<&str>) -> Result<RegistryAuth> {
607    match registry_auth(raw_token) {
608        Ok(auth) => Ok(auth),
609        Err(_) if raw_token.is_none() => Ok(RegistryAuth::Anonymous),
610        Err(err) => Err(err),
611    }
612}
613
614fn resolve_registry_token(raw_token: Option<&str>) -> Result<Option<String>> {
615    let Some(raw_token) = raw_token else {
616        return Ok(None);
617    };
618    if let Some(var) = raw_token.strip_prefix("env:") {
619        let token =
620            std::env::var(var).with_context(|| format!("failed to resolve env var {var}"))?;
621        if token.trim().is_empty() {
622            bail!("env var {var} resolved to an empty token");
623        }
624        return Ok(Some(token));
625    }
626    if raw_token.trim().is_empty() {
627        bail!("GHCR token is empty");
628    }
629    Ok(Some(raw_token.to_string()))
630}
631
632fn parse_reference(repo: &str, tag: &str) -> Result<Reference> {
633    Reference::from_str(&toolchain_ref(repo, tag))
634        .with_context(|| format!("invalid OCI reference `{}`", toolchain_ref(repo, tag)))
635}
636
637async fn manifest_exists(
638    client: &Client,
639    reference: &Reference,
640    auth: &RegistryAuth,
641) -> Result<bool> {
642    match client.pull_manifest(reference, auth).await {
643        Ok(_) => Ok(true),
644        Err(err) if is_missing_manifest_error(&err) => Ok(false),
645        Err(err) => Err(err).context("failed to check whether release tag exists"),
646    }
647}
648
649fn is_missing_manifest_error(err: &oci_distribution::errors::OciDistributionError) -> bool {
650    let msg = err.to_string().to_ascii_lowercase();
651    msg.contains("manifest unknown")
652        || msg.contains("name unknown")
653        || msg.contains("not found")
654        || msg.contains("404")
655}
656
657fn is_unauthorized_error(err: &oci_distribution::errors::OciDistributionError) -> bool {
658    let msg = err.to_string().to_ascii_lowercase();
659    msg.contains("not authorized") || msg.contains("unauthorized") || msg.contains("401")
660}
661
662async fn push_manifest_layer(
663    client: &Client,
664    reference: &Reference,
665    auth: &RegistryAuth,
666    manifest: &ToolchainManifest,
667) -> Result<()> {
668    let data = serde_json::to_vec_pretty(manifest).context("failed to serialize manifest")?;
669    let layer = ImageLayer::new(data, TOOLCHAIN_LAYER_MEDIA_TYPE.to_string(), None);
670    let config = Config::new(
671        br#"{"toolchain":"gtc"}"#.to_vec(),
672        TOOLCHAIN_CONFIG_MEDIA_TYPE.to_string(),
673        None,
674    );
675    client
676        .push(reference, &[layer], config, auth, None)
677        .await
678        .context("failed to push toolchain manifest")?;
679    Ok(())
680}
681
682#[cfg(test)]
683mod tests {
684    use super::*;
685
686    struct FixedResolver;
687
688    impl CrateVersionResolver for FixedResolver {
689        fn resolve_latest(&self, crate_name: &str) -> Result<String> {
690            Ok(match crate_name {
691                "greentic-runner" => "0.5.10",
692                _ => "1.2.3",
693            }
694            .to_string())
695        }
696    }
697
698    #[test]
699    fn parses_cargo_search_version() {
700        let version = parse_cargo_search_version(
701            "greentic-dev",
702            r#"greentic-dev = "0.5.1"    # Developer CLI"#,
703        )
704        .unwrap();
705        assert_eq!(version, "0.5.1");
706    }
707
708    #[test]
709    fn generates_manifest_from_catalogue() {
710        let manifest = generate_manifest("1.0.5", "latest", None, &FixedResolver, None).unwrap();
711        assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
712        assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
713        assert_eq!(manifest.version, "1.0.5");
714        assert_eq!(manifest.channel.as_deref(), Some("latest"));
715        assert!(
716            manifest
717                .packages
718                .iter()
719                .any(|package| package.crate_name == "greentic-bundle"
720                    && package.bins == ["greentic-bundle"])
721        );
722        assert!(
723            manifest
724                .packages
725                .iter()
726                .any(|package| package.crate_name == "greentic-runner"
727                    && package.bins == ["greentic-runner"])
728        );
729    }
730
731    #[test]
732    fn source_manifest_can_pin_package_versions() {
733        let source = ToolchainManifest {
734            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
735            toolchain: TOOLCHAIN_NAME.to_string(),
736            version: "latest".to_string(),
737            channel: Some("latest".to_string()),
738            created_at: None,
739            packages: vec![ToolchainPackage {
740                crate_name: "greentic-dev".to_string(),
741                bins: vec!["greentic-dev".to_string()],
742                version: "0.5.9".to_string(),
743            }],
744        };
745        let manifest =
746            generate_manifest("1.0.5", "latest", Some(&source), &FixedResolver, None).unwrap();
747        let greentic_dev = manifest
748            .packages
749            .iter()
750            .find(|package| package.crate_name == "greentic-dev")
751            .unwrap();
752        assert_eq!(greentic_dev.version, "0.5.9");
753    }
754
755    #[test]
756    fn from_argument_controls_generated_channel_over_source_manifest() {
757        let source = ToolchainManifest {
758            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
759            toolchain: TOOLCHAIN_NAME.to_string(),
760            version: "latest".to_string(),
761            channel: Some("stable".to_string()),
762            created_at: None,
763            packages: Vec::new(),
764        };
765        let manifest =
766            generate_manifest("1.0.16", "dev", Some(&source), &FixedResolver, None).unwrap();
767        assert_eq!(manifest.channel.as_deref(), Some("dev"));
768        assert_eq!(manifest_file_name(&manifest), "gtc-dev-1.0.16.json");
769    }
770
771    #[test]
772    fn generate_from_dev_uses_dev_binary_names() {
773        let manifest = generate_manifest("1.0.16", "dev", None, &FixedResolver, None).unwrap();
774        assert!(
775            manifest
776                .packages
777                .iter()
778                .flat_map(|package| package.bins.iter())
779                .all(|bin| bin.ends_with("-dev"))
780        );
781        assert!(manifest.packages.iter().any(|package| {
782            package.crate_name == "greentic-flow" && package.bins == ["greentic-flow-dev"]
783        }));
784        assert!(manifest.packages.iter().any(|package| {
785            package.crate_name == "greentic-component" && package.bins == ["greentic-component-dev"]
786        }));
787    }
788
789    #[test]
790    fn bootstrap_source_manifest_uses_source_tag_identity() {
791        let manifest = bootstrap_source_manifest("latest", &FixedResolver, None).unwrap();
792        assert_eq!(manifest.version, "latest");
793        assert_eq!(manifest.channel.as_deref(), Some("latest"));
794        assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
795        assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
796        assert!(
797            manifest
798                .packages
799                .iter()
800                .all(|package| package.version != "latest")
801        );
802    }
803
804    #[test]
805    fn validates_schema_and_toolchain() {
806        let mut manifest =
807            generate_manifest("1.0.5", "latest", None, &FixedResolver, None).unwrap();
808        assert!(validate_manifest(&manifest).is_ok());
809        manifest.schema = "wrong".to_string();
810        assert!(validate_manifest(&manifest).is_err());
811    }
812
813    #[test]
814    fn resolves_inline_registry_token() {
815        assert_eq!(
816            resolve_registry_token(Some("secret-token"))
817                .unwrap()
818                .as_deref(),
819            Some("secret-token")
820        );
821    }
822
823    #[test]
824    fn release_view_tag_prefers_release_or_tag() {
825        let args = ReleaseViewArgs {
826            release: Some("1.0.5".to_string()),
827            tag: None,
828            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
829            token: None,
830        };
831        assert_eq!(release_view_tag(&args).unwrap(), "1.0.5");
832
833        let args = ReleaseViewArgs {
834            release: None,
835            tag: Some("stable".to_string()),
836            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
837            token: None,
838        };
839        assert_eq!(release_view_tag(&args).unwrap(), "stable");
840    }
841
842    #[test]
843    fn publish_manifest_input_uses_local_manifest_version() {
844        let dir = tempfile::tempdir().unwrap();
845        let path = dir.path().join("gtc-1.0.12.json");
846        let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
847        fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
848        let args = ReleasePublishArgs {
849            release: None,
850            from: None,
851            tag: Some("stable".to_string()),
852            manifest: Some(path.clone()),
853            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
854            token: None,
855            out: dir.path().to_path_buf(),
856            dry_run: true,
857            force: true,
858        };
859        let (release, loaded, source_path) = publish_manifest_input(&args).unwrap();
860        assert_eq!(release, "1.0.12");
861        assert_eq!(loaded, manifest);
862        assert_eq!(
863            source_path,
864            Some(PublishManifestSource::Local(path.clone()))
865        );
866    }
867
868    #[test]
869    fn publish_manifest_input_allows_release_override_for_local_manifest() {
870        let dir = tempfile::tempdir().unwrap();
871        let path = dir.path().join("gtc-1.0.13.json");
872        let manifest = generate_manifest("1.0.12", "latest", None, &FixedResolver, None).unwrap();
873        fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
874        let args = ReleasePublishArgs {
875            release: Some("1.0.13".to_string()),
876            from: None,
877            tag: Some("stable".to_string()),
878            manifest: Some(path.clone()),
879            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
880            token: None,
881            out: dir.path().to_path_buf(),
882            dry_run: true,
883            force: true,
884        };
885        let (release, loaded, source_path) = publish_manifest_input(&args).unwrap();
886        assert_eq!(release, "1.0.13");
887        assert_eq!(loaded.version, "1.0.13");
888        assert_eq!(
889            source_path,
890            Some(PublishManifestSource::Local(path.clone()))
891        );
892    }
893
894    #[test]
895    fn manifest_file_name_omits_stable_channel() {
896        let manifest = ToolchainManifest {
897            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
898            toolchain: TOOLCHAIN_NAME.to_string(),
899            version: "1.0.12".to_string(),
900            channel: Some("stable".to_string()),
901            created_at: None,
902            packages: Vec::new(),
903        };
904        assert_eq!(manifest_file_name(&manifest), "gtc-1.0.12.json");
905    }
906
907    #[test]
908    fn manifest_file_name_includes_non_stable_channel() {
909        let mut manifest = generate_manifest("1.0.12", "dev", None, &FixedResolver, None).unwrap();
910        assert_eq!(manifest_file_name(&manifest), "gtc-dev-1.0.12.json");
911
912        manifest.channel = Some("customer-a".to_string());
913        assert_eq!(manifest_file_name(&manifest), "gtc-customer-a-1.0.12.json");
914    }
915
916    #[test]
917    fn latest_manifest_uses_latest_dev_bins() {
918        let manifest = latest_manifest(None);
919        assert_eq!(manifest.version, "latest");
920        assert_eq!(manifest.channel.as_deref(), Some("latest"));
921        assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
922        assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
923        assert!(!manifest.packages.is_empty());
924        assert!(
925            manifest
926                .packages
927                .iter()
928                .all(|package| package.version == "latest")
929        );
930        assert!(
931            manifest
932                .packages
933                .iter()
934                .flat_map(|package| package.bins.iter())
935                .all(|bin| bin.ends_with("-dev"))
936        );
937        assert!(
938            manifest
939                .packages
940                .iter()
941                .any(|package| { package.crate_name == "gtc" && package.bins == ["gtc-dev"] })
942        );
943        assert!(manifest.packages.iter().any(|package| {
944            package.crate_name == "greentic-dev" && package.bins == ["greentic-dev-dev"]
945        }));
946    }
947
948    #[test]
949    fn builds_toolchain_ref() {
950        assert_eq!(
951            toolchain_ref("ghcr.io/greenticai/greentic-versions/gtc", "stable"),
952            "ghcr.io/greenticai/greentic-versions/gtc:stable"
953        );
954    }
955}