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    ReleaseDevArgs, ReleaseGenerateArgs, ReleasePromoteArgs, ReleasePublishArgs, ReleaseViewArgs,
19};
20use crate::install::block_on_maybe_runtime;
21use crate::toolchain_catalogue::GREENTIC_TOOLCHAIN_PACKAGES;
22
23const DEFAULT_OAUTH_USER: &str = "oauth2";
24pub const TOOLCHAIN_MANIFEST_SCHEMA: &str = "greentic.toolchain-manifest.v1";
25pub const TOOLCHAIN_NAME: &str = "gtc";
26pub const TOOLCHAIN_LAYER_MEDIA_TYPE: &str = "application/vnd.greentic.toolchain.manifest.v1+json";
27const TOOLCHAIN_CONFIG_MEDIA_TYPE: &str = "application/vnd.greentic.toolchain.config.v1+json";
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30pub struct ToolchainManifest {
31    pub schema: String,
32    pub toolchain: String,
33    pub version: String,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub channel: Option<String>,
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub created_at: Option<String>,
38    pub packages: Vec<ToolchainPackage>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
42pub struct ToolchainPackage {
43    #[serde(rename = "crate")]
44    pub crate_name: String,
45    pub bins: Vec<String>,
46    pub version: String,
47}
48
49pub fn generate(args: ReleaseGenerateArgs) -> Result<()> {
50    let resolver = CargoSearchVersionResolver;
51    let source = block_on_maybe_runtime(load_source_manifest(
52        &args.repo,
53        &args.from,
54        args.token.as_deref(),
55    ))
56    .with_context(|| {
57        format!(
58            "failed to resolve source manifest `{}`",
59            toolchain_ref(&args.repo, &args.from)
60        )
61    })?;
62    let source = match source {
63        Some(source) => Some(source),
64        None => bootstrap_source_manifest_if_needed(
65            &args.repo,
66            &args.from,
67            args.token.as_deref(),
68            args.dry_run,
69            &resolver,
70        )?,
71    };
72    let manifest = generate_manifest(
73        &args.release,
74        &args.from,
75        source.as_ref(),
76        &resolver,
77        Some(created_at_now()?),
78    )?;
79    if args.dry_run {
80        println!("{}", serde_json::to_string_pretty(&manifest)?);
81        return Ok(());
82    }
83    let path = write_manifest(&args.out, &manifest)?;
84    println!("Wrote {}", path.display());
85    Ok(())
86}
87
88fn bootstrap_source_manifest_if_needed<R: CrateVersionResolver>(
89    repo: &str,
90    tag: &str,
91    token: Option<&str>,
92    dry_run: bool,
93    resolver: &R,
94) -> Result<Option<ToolchainManifest>> {
95    let manifest = bootstrap_source_manifest(tag, resolver, Some(created_at_now()?))?;
96    if dry_run {
97        eprintln!(
98            "Dry run: would bootstrap missing source manifest {}",
99            toolchain_ref(repo, tag)
100        );
101        return Ok(Some(manifest));
102    }
103
104    let auth = match optional_registry_auth(token)? {
105        RegistryAuth::Anonymous => {
106            eprintln!(
107                "Source manifest {} is missing; no GHCR token is available, so only the local release manifest will be generated.",
108                toolchain_ref(repo, tag)
109            );
110            return Ok(Some(manifest));
111        }
112        auth => auth,
113    };
114    block_on_maybe_runtime(async {
115        let client = oci_client();
116        let source_ref = parse_reference(repo, tag)?;
117        push_manifest_layer(&client, &source_ref, &auth, &manifest).await
118    })
119    .with_context(|| format!("failed to bootstrap {}", toolchain_ref(repo, tag)))?;
120    println!("Bootstrapped {}", toolchain_ref(repo, tag));
121    Ok(Some(manifest))
122}
123
124fn bootstrap_source_manifest<R: CrateVersionResolver>(
125    tag: &str,
126    resolver: &R,
127    created_at: Option<String>,
128) -> Result<ToolchainManifest> {
129    generate_manifest(tag, tag, None, resolver, created_at)
130}
131
132pub fn publish(args: ReleasePublishArgs) -> Result<()> {
133    let (release, manifest, source) = publish_manifest_input(&args)?;
134
135    if args.dry_run {
136        println!(
137            "Dry run: would publish {}",
138            toolchain_ref(&args.repo, &release)
139        );
140        if let Some(tag) = &args.tag {
141            println!(
142                "Dry run: would tag {} as {}",
143                toolchain_ref(&args.repo, &release),
144                toolchain_ref(&args.repo, tag)
145            );
146        }
147        return Ok(());
148    }
149
150    let auth = registry_auth(args.token.as_deref())?;
151    block_on_maybe_runtime(async {
152        let client = oci_client();
153        let release_ref = parse_reference(&args.repo, &release)?;
154        if !args.force && manifest_exists(&client, &release_ref, &auth).await? {
155            bail!(
156                "release tag `{}` already exists; pass --force to overwrite it",
157                toolchain_ref(&args.repo, &release)
158            );
159        }
160        push_manifest_layer(&client, &release_ref, &auth, &manifest).await?;
161        if let Some(tag) = &args.tag {
162            let tag_ref = parse_reference(&args.repo, tag)?;
163            push_manifest_layer(&client, &tag_ref, &auth, &manifest).await?;
164        }
165        Ok(())
166    })?;
167
168    if let Some(source) = source {
169        match source {
170            PublishManifestSource::Generated(path) => println!("Wrote {}", path.display()),
171            PublishManifestSource::Local(path) => println!("Read {}", path.display()),
172        }
173    }
174    println!("Published {}", toolchain_ref(&args.repo, &release));
175    if let Some(tag) = &args.tag {
176        println!("Updated {}", toolchain_ref(&args.repo, tag));
177    }
178    Ok(())
179}
180
181#[derive(Debug, Clone, PartialEq, Eq)]
182enum PublishManifestSource {
183    Generated(PathBuf),
184    Local(PathBuf),
185}
186
187fn publish_manifest_input(
188    args: &ReleasePublishArgs,
189) -> Result<(String, ToolchainManifest, Option<PublishManifestSource>)> {
190    if let Some(path) = &args.manifest {
191        let mut manifest = read_manifest_file(path)?;
192        validate_manifest(&manifest)?;
193        let release = if let Some(release) = &args.release {
194            manifest.version = release.clone();
195            release.clone()
196        } else {
197            manifest.version.clone()
198        };
199        return Ok((
200            release,
201            manifest,
202            Some(PublishManifestSource::Local(path.clone())),
203        ));
204    }
205
206    let release = args
207        .release
208        .as_deref()
209        .context("pass --release or --manifest")?;
210    let from = args.from.as_deref().unwrap_or("dev");
211    let resolver = CargoSearchVersionResolver;
212    let source = block_on_maybe_runtime(load_source_manifest(
213        &args.repo,
214        from,
215        args.token.as_deref(),
216    ))
217    .with_context(|| {
218        format!(
219            "failed to resolve source manifest `{}`",
220            toolchain_ref(&args.repo, from)
221        )
222    })?;
223    let manifest = generate_manifest(
224        release,
225        from,
226        source.as_ref(),
227        &resolver,
228        Some(created_at_now()?),
229    )?;
230    let path = if args.dry_run {
231        println!("{}", serde_json::to_string_pretty(&manifest)?);
232        None
233    } else {
234        Some(PublishManifestSource::Generated(write_manifest(
235            &args.out, &manifest,
236        )?))
237    };
238    Ok((release.to_string(), manifest, path))
239}
240
241fn read_manifest_file(path: &Path) -> Result<ToolchainManifest> {
242    let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
243    serde_json::from_slice(&bytes).with_context(|| format!("failed to parse {}", path.display()))
244}
245
246pub fn promote(args: ReleasePromoteArgs) -> Result<()> {
247    if args.dry_run {
248        println!(
249            "Dry run: would promote {} to {}",
250            toolchain_ref(&args.repo, &args.release),
251            toolchain_ref(&args.repo, &args.tag)
252        );
253        return Ok(());
254    }
255
256    let auth = registry_auth(args.token.as_deref())?;
257    block_on_maybe_runtime(async {
258        let client = oci_client();
259        let source_ref = parse_reference(&args.repo, &args.release)?;
260        let target_ref = parse_reference(&args.repo, &args.tag)?;
261        let (manifest, _) = client
262            .pull_manifest(&source_ref, &auth)
263            .await
264            .with_context(|| {
265                format!(
266                    "failed to resolve source release `{}`",
267                    toolchain_ref(&args.repo, &args.release)
268                )
269            })?;
270        client
271            .push_manifest(&target_ref, &manifest)
272            .await
273            .with_context(|| {
274                format!(
275                    "failed to update tag `{}`",
276                    toolchain_ref(&args.repo, &args.tag)
277                )
278            })?;
279        Ok(())
280    })?;
281    println!(
282        "Promoted {} to {}",
283        toolchain_ref(&args.repo, &args.release),
284        toolchain_ref(&args.repo, &args.tag)
285    );
286    Ok(())
287}
288
289pub fn view(args: ReleaseViewArgs) -> Result<()> {
290    let tag = release_view_tag(&args)?;
291    let manifest = block_on_maybe_runtime(load_source_manifest(
292        &args.repo,
293        &tag,
294        args.token.as_deref(),
295    ))
296    .with_context(|| {
297        format!(
298            "failed to resolve manifest `{}`",
299            toolchain_ref(&args.repo, &tag)
300        )
301    })?
302    .with_context(|| {
303        format!(
304            "manifest `{}` was not found or is not authorized for this token",
305            toolchain_ref(&args.repo, &tag)
306        )
307    })?;
308    println!("{}", serde_json::to_string_pretty(&manifest)?);
309    Ok(())
310}
311
312pub fn dev(args: ReleaseDevArgs) -> Result<()> {
313    let manifest = latest_dev_manifest(Some(created_at_now()?));
314    if args.dry_run {
315        println!("{}", serde_json::to_string_pretty(&manifest)?);
316        println!(
317            "Dry run: would publish {}",
318            toolchain_ref(&args.repo, "dev")
319        );
320        return Ok(());
321    }
322
323    let auth = registry_auth(args.token.as_deref())?;
324    block_on_maybe_runtime(async {
325        let client = oci_client();
326        let dev_ref = parse_reference(&args.repo, "dev")?;
327        if !args.force && manifest_exists(&client, &dev_ref, &auth).await? {
328            bail!(
329                "dev tag `{}` already exists; pass --force to overwrite it",
330                toolchain_ref(&args.repo, "dev")
331            );
332        }
333        push_manifest_layer(&client, &dev_ref, &auth, &manifest).await
334    })?;
335    println!("Published {}", toolchain_ref(&args.repo, "dev"));
336    Ok(())
337}
338
339fn latest_dev_manifest(created_at: Option<String>) -> ToolchainManifest {
340    ToolchainManifest {
341        schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
342        toolchain: TOOLCHAIN_NAME.to_string(),
343        version: "dev".to_string(),
344        channel: Some("dev".to_string()),
345        created_at,
346        packages: GREENTIC_TOOLCHAIN_PACKAGES
347            .iter()
348            .map(|package| ToolchainPackage {
349                crate_name: package.crate_name.to_string(),
350                bins: package.bins.iter().map(|bin| (*bin).to_string()).collect(),
351                version: "latest".to_string(),
352            })
353            .collect(),
354    }
355}
356
357fn release_view_tag(args: &ReleaseViewArgs) -> Result<String> {
358    match (&args.release, &args.tag) {
359        (Some(release), None) => Ok(release.clone()),
360        (None, Some(tag)) => Ok(tag.clone()),
361        _ => bail!("pass exactly one of --release or --tag"),
362    }
363}
364
365pub fn generate_manifest<R: CrateVersionResolver>(
366    release: &str,
367    from: &str,
368    source: Option<&ToolchainManifest>,
369    resolver: &R,
370    created_at: Option<String>,
371) -> Result<ToolchainManifest> {
372    if let Some(source) = source {
373        validate_manifest(source)?;
374    }
375    let source_versions = source_version_map(source);
376    let mut packages = Vec::new();
377    for package in GREENTIC_TOOLCHAIN_PACKAGES {
378        let source_version = source_versions.get(package.crate_name);
379        let version = match source_version.map(String::as_str) {
380            Some(version) if version != "latest" => version.to_string(),
381            _ => resolver.resolve_latest(package.crate_name)?,
382        };
383        packages.push(ToolchainPackage {
384            crate_name: package.crate_name.to_string(),
385            bins: package.bins.iter().map(|bin| (*bin).to_string()).collect(),
386            version,
387        });
388    }
389    Ok(ToolchainManifest {
390        schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
391        toolchain: TOOLCHAIN_NAME.to_string(),
392        version: release.to_string(),
393        channel: source
394            .and_then(|manifest| manifest.channel.clone())
395            .or_else(|| Some(from.to_string())),
396        created_at,
397        packages,
398    })
399}
400
401pub fn validate_manifest(manifest: &ToolchainManifest) -> Result<()> {
402    if manifest.schema != TOOLCHAIN_MANIFEST_SCHEMA {
403        bail!(
404            "unsupported toolchain manifest schema `{}`",
405            manifest.schema
406        );
407    }
408    if manifest.toolchain != TOOLCHAIN_NAME {
409        bail!("unsupported toolchain `{}`", manifest.toolchain);
410    }
411    Ok(())
412}
413
414pub fn toolchain_ref(repo: &str, tag: &str) -> String {
415    format!("{repo}:{tag}")
416}
417
418fn source_version_map(source: Option<&ToolchainManifest>) -> BTreeMap<String, String> {
419    let mut out = BTreeMap::new();
420    if let Some(source) = source {
421        for package in &source.packages {
422            out.insert(package.crate_name.clone(), package.version.clone());
423        }
424    }
425    out
426}
427
428fn write_manifest(out_dir: &Path, manifest: &ToolchainManifest) -> Result<PathBuf> {
429    fs::create_dir_all(out_dir)
430        .with_context(|| format!("failed to create {}", out_dir.display()))?;
431    let path = out_dir.join(format!("gtc-{}.json", manifest.version));
432    let json = serde_json::to_vec_pretty(manifest).context("failed to serialize manifest")?;
433    fs::write(&path, json).with_context(|| format!("failed to write {}", path.display()))?;
434    Ok(path)
435}
436
437fn created_at_now() -> Result<String> {
438    OffsetDateTime::now_utc()
439        .format(&Rfc3339)
440        .context("failed to format current time")
441}
442
443pub trait CrateVersionResolver {
444    fn resolve_latest(&self, crate_name: &str) -> Result<String>;
445}
446
447struct CargoSearchVersionResolver;
448
449impl CrateVersionResolver for CargoSearchVersionResolver {
450    fn resolve_latest(&self, crate_name: &str) -> Result<String> {
451        let output = Command::new("cargo")
452            .arg("search")
453            .arg(crate_name)
454            .arg("--limit")
455            .arg("1")
456            .output()
457            .with_context(|| format!("failed to execute `cargo search {crate_name} --limit 1`"))?;
458        if !output.status.success() {
459            bail!(
460                "`cargo search {crate_name} --limit 1` failed with exit code {:?}",
461                output.status.code()
462            );
463        }
464        let stdout = String::from_utf8(output.stdout).with_context(|| {
465            format!("`cargo search {crate_name} --limit 1` returned non-UTF8 output")
466        })?;
467        parse_cargo_search_version(crate_name, &stdout)
468    }
469}
470
471fn parse_cargo_search_version(crate_name: &str, stdout: &str) -> Result<String> {
472    let first_line = stdout
473        .lines()
474        .find(|line| !line.trim().is_empty())
475        .ok_or_else(|| anyhow!("`cargo search {crate_name} --limit 1` returned no results"))?;
476    let Some((found_name, rhs)) = first_line.split_once('=') else {
477        bail!("unexpected cargo search output: {first_line}");
478    };
479    if found_name.trim() != crate_name {
480        bail!(
481            "`cargo search {crate_name} --limit 1` returned `{}` first",
482            found_name.trim()
483        );
484    }
485    let quoted = rhs
486        .split('#')
487        .next()
488        .map(str::trim)
489        .ok_or_else(|| anyhow!("unexpected cargo search output: {first_line}"))?;
490    let version = quoted.trim_matches('"');
491    Version::parse(version)
492        .with_context(|| format!("failed to parse crate version from `{first_line}`"))?;
493    Ok(version.to_string())
494}
495
496#[async_trait]
497trait ToolchainManifestSource {
498    async fn load_manifest(
499        &self,
500        repo: &str,
501        tag: &str,
502        token: Option<&str>,
503    ) -> Result<Option<ToolchainManifest>>;
504}
505
506struct OciToolchainManifestSource;
507
508#[async_trait]
509impl ToolchainManifestSource for OciToolchainManifestSource {
510    async fn load_manifest(
511        &self,
512        repo: &str,
513        tag: &str,
514        token: Option<&str>,
515    ) -> Result<Option<ToolchainManifest>> {
516        let auth = optional_registry_auth(token)?;
517        let client = oci_client();
518        let reference = parse_reference(repo, tag)?;
519        let image = match client
520            .pull(&reference, &auth, vec![TOOLCHAIN_LAYER_MEDIA_TYPE])
521            .await
522        {
523            Ok(image) => image,
524            Err(err) if is_missing_manifest_error(&err) || is_unauthorized_error(&err) => {
525                return Ok(None);
526            }
527            Err(err) => {
528                return Err(err)
529                    .with_context(|| format!("failed to pull {}", toolchain_ref(repo, tag)));
530            }
531        };
532        let Some(layer) = image
533            .layers
534            .into_iter()
535            .find(|layer| layer.media_type == TOOLCHAIN_LAYER_MEDIA_TYPE)
536        else {
537            return Ok(None);
538        };
539        let manifest = serde_json::from_slice::<ToolchainManifest>(&layer.data)
540            .with_context(|| format!("failed to parse {}", toolchain_ref(repo, tag)))?;
541        validate_manifest(&manifest)?;
542        Ok(Some(manifest))
543    }
544}
545
546async fn load_source_manifest(
547    repo: &str,
548    tag: &str,
549    token: Option<&str>,
550) -> Result<Option<ToolchainManifest>> {
551    OciToolchainManifestSource
552        .load_manifest(repo, tag, token)
553        .await
554}
555
556fn oci_client() -> Client {
557    Client::new(ClientConfig {
558        protocol: ClientProtocol::Https,
559        ..Default::default()
560    })
561}
562
563fn registry_auth(raw_token: Option<&str>) -> Result<RegistryAuth> {
564    let token = resolve_registry_token(raw_token)?
565        .or_else(|| std::env::var("GHCR_TOKEN").ok())
566        .or_else(|| std::env::var("GITHUB_TOKEN").ok())
567        .context("GHCR token is required; pass --token or set GHCR_TOKEN/GITHUB_TOKEN")?;
568    if token.trim().is_empty() {
569        bail!("GHCR token is empty");
570    }
571    Ok(RegistryAuth::Basic(DEFAULT_OAUTH_USER.to_string(), token))
572}
573
574fn optional_registry_auth(raw_token: Option<&str>) -> Result<RegistryAuth> {
575    match registry_auth(raw_token) {
576        Ok(auth) => Ok(auth),
577        Err(_) if raw_token.is_none() => Ok(RegistryAuth::Anonymous),
578        Err(err) => Err(err),
579    }
580}
581
582fn resolve_registry_token(raw_token: Option<&str>) -> Result<Option<String>> {
583    let Some(raw_token) = raw_token else {
584        return Ok(None);
585    };
586    if let Some(var) = raw_token.strip_prefix("env:") {
587        let token =
588            std::env::var(var).with_context(|| format!("failed to resolve env var {var}"))?;
589        if token.trim().is_empty() {
590            bail!("env var {var} resolved to an empty token");
591        }
592        return Ok(Some(token));
593    }
594    if raw_token.trim().is_empty() {
595        bail!("GHCR token is empty");
596    }
597    Ok(Some(raw_token.to_string()))
598}
599
600fn parse_reference(repo: &str, tag: &str) -> Result<Reference> {
601    Reference::from_str(&toolchain_ref(repo, tag))
602        .with_context(|| format!("invalid OCI reference `{}`", toolchain_ref(repo, tag)))
603}
604
605async fn manifest_exists(
606    client: &Client,
607    reference: &Reference,
608    auth: &RegistryAuth,
609) -> Result<bool> {
610    match client.pull_manifest(reference, auth).await {
611        Ok(_) => Ok(true),
612        Err(err) if is_missing_manifest_error(&err) => Ok(false),
613        Err(err) => Err(err).context("failed to check whether release tag exists"),
614    }
615}
616
617fn is_missing_manifest_error(err: &oci_distribution::errors::OciDistributionError) -> bool {
618    let msg = err.to_string().to_ascii_lowercase();
619    msg.contains("manifest unknown")
620        || msg.contains("name unknown")
621        || msg.contains("not found")
622        || msg.contains("404")
623}
624
625fn is_unauthorized_error(err: &oci_distribution::errors::OciDistributionError) -> bool {
626    let msg = err.to_string().to_ascii_lowercase();
627    msg.contains("not authorized") || msg.contains("unauthorized") || msg.contains("401")
628}
629
630async fn push_manifest_layer(
631    client: &Client,
632    reference: &Reference,
633    auth: &RegistryAuth,
634    manifest: &ToolchainManifest,
635) -> Result<()> {
636    let data = serde_json::to_vec_pretty(manifest).context("failed to serialize manifest")?;
637    let layer = ImageLayer::new(data, TOOLCHAIN_LAYER_MEDIA_TYPE.to_string(), None);
638    let config = Config::new(
639        br#"{"toolchain":"gtc"}"#.to_vec(),
640        TOOLCHAIN_CONFIG_MEDIA_TYPE.to_string(),
641        None,
642    );
643    client
644        .push(reference, &[layer], config, auth, None)
645        .await
646        .context("failed to push toolchain manifest")?;
647    Ok(())
648}
649
650#[cfg(test)]
651mod tests {
652    use super::*;
653
654    struct FixedResolver;
655
656    impl CrateVersionResolver for FixedResolver {
657        fn resolve_latest(&self, crate_name: &str) -> Result<String> {
658            Ok(match crate_name {
659                "greentic-runner" => "0.5.10",
660                _ => "1.2.3",
661            }
662            .to_string())
663        }
664    }
665
666    #[test]
667    fn parses_cargo_search_version() {
668        let version = parse_cargo_search_version(
669            "greentic-dev",
670            r#"greentic-dev = "0.5.1"    # Developer CLI"#,
671        )
672        .unwrap();
673        assert_eq!(version, "0.5.1");
674    }
675
676    #[test]
677    fn generates_manifest_from_catalogue() {
678        let manifest = generate_manifest("1.0.5", "dev", None, &FixedResolver, None).unwrap();
679        assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
680        assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
681        assert_eq!(manifest.version, "1.0.5");
682        assert_eq!(manifest.channel.as_deref(), Some("dev"));
683        assert!(
684            manifest
685                .packages
686                .iter()
687                .any(|package| package.crate_name == "greentic-bundle"
688                    && package.bins == ["greentic-bundle"])
689        );
690        assert!(
691            manifest
692                .packages
693                .iter()
694                .any(|package| package.crate_name == "greentic-runner"
695                    && package.bins == ["greentic-runner"])
696        );
697    }
698
699    #[test]
700    fn source_manifest_can_pin_package_versions() {
701        let source = ToolchainManifest {
702            schema: TOOLCHAIN_MANIFEST_SCHEMA.to_string(),
703            toolchain: TOOLCHAIN_NAME.to_string(),
704            version: "dev".to_string(),
705            channel: Some("dev".to_string()),
706            created_at: None,
707            packages: vec![ToolchainPackage {
708                crate_name: "greentic-dev".to_string(),
709                bins: vec!["greentic-dev".to_string()],
710                version: "0.5.9".to_string(),
711            }],
712        };
713        let manifest =
714            generate_manifest("1.0.5", "dev", Some(&source), &FixedResolver, None).unwrap();
715        let greentic_dev = manifest
716            .packages
717            .iter()
718            .find(|package| package.crate_name == "greentic-dev")
719            .unwrap();
720        assert_eq!(greentic_dev.version, "0.5.9");
721    }
722
723    #[test]
724    fn bootstrap_source_manifest_uses_source_tag_identity() {
725        let manifest = bootstrap_source_manifest("dev", &FixedResolver, None).unwrap();
726        assert_eq!(manifest.version, "dev");
727        assert_eq!(manifest.channel.as_deref(), Some("dev"));
728        assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
729        assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
730        assert!(
731            manifest
732                .packages
733                .iter()
734                .all(|package| package.version != "latest")
735        );
736    }
737
738    #[test]
739    fn validates_schema_and_toolchain() {
740        let mut manifest = generate_manifest("1.0.5", "dev", None, &FixedResolver, None).unwrap();
741        assert!(validate_manifest(&manifest).is_ok());
742        manifest.schema = "wrong".to_string();
743        assert!(validate_manifest(&manifest).is_err());
744    }
745
746    #[test]
747    fn resolves_inline_registry_token() {
748        assert_eq!(
749            resolve_registry_token(Some("secret-token"))
750                .unwrap()
751                .as_deref(),
752            Some("secret-token")
753        );
754    }
755
756    #[test]
757    fn release_view_tag_prefers_release_or_tag() {
758        let args = ReleaseViewArgs {
759            release: Some("1.0.5".to_string()),
760            tag: None,
761            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
762            token: None,
763        };
764        assert_eq!(release_view_tag(&args).unwrap(), "1.0.5");
765
766        let args = ReleaseViewArgs {
767            release: None,
768            tag: Some("stable".to_string()),
769            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
770            token: None,
771        };
772        assert_eq!(release_view_tag(&args).unwrap(), "stable");
773    }
774
775    #[test]
776    fn publish_manifest_input_uses_local_manifest_version() {
777        let dir = tempfile::tempdir().unwrap();
778        let path = dir.path().join("gtc-1.0.12.json");
779        let manifest = generate_manifest("1.0.12", "dev", None, &FixedResolver, None).unwrap();
780        fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
781        let args = ReleasePublishArgs {
782            release: None,
783            from: None,
784            tag: Some("stable".to_string()),
785            manifest: Some(path.clone()),
786            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
787            token: None,
788            out: dir.path().to_path_buf(),
789            dry_run: true,
790            force: true,
791        };
792        let (release, loaded, source_path) = publish_manifest_input(&args).unwrap();
793        assert_eq!(release, "1.0.12");
794        assert_eq!(loaded, manifest);
795        assert_eq!(
796            source_path,
797            Some(PublishManifestSource::Local(path.clone()))
798        );
799    }
800
801    #[test]
802    fn publish_manifest_input_allows_release_override_for_local_manifest() {
803        let dir = tempfile::tempdir().unwrap();
804        let path = dir.path().join("gtc-1.0.13.json");
805        let manifest = generate_manifest("1.0.12", "dev", None, &FixedResolver, None).unwrap();
806        fs::write(&path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
807        let args = ReleasePublishArgs {
808            release: Some("1.0.13".to_string()),
809            from: None,
810            tag: Some("stable".to_string()),
811            manifest: Some(path.clone()),
812            repo: "ghcr.io/greenticai/greentic-versions/gtc".to_string(),
813            token: None,
814            out: dir.path().to_path_buf(),
815            dry_run: true,
816            force: true,
817        };
818        let (release, loaded, source_path) = publish_manifest_input(&args).unwrap();
819        assert_eq!(release, "1.0.13");
820        assert_eq!(loaded.version, "1.0.13");
821        assert_eq!(
822            source_path,
823            Some(PublishManifestSource::Local(path.clone()))
824        );
825    }
826
827    #[test]
828    fn latest_dev_manifest_uses_latest_versions() {
829        let manifest = latest_dev_manifest(None);
830        assert_eq!(manifest.version, "dev");
831        assert_eq!(manifest.channel.as_deref(), Some("dev"));
832        assert_eq!(manifest.schema, TOOLCHAIN_MANIFEST_SCHEMA);
833        assert_eq!(manifest.toolchain, TOOLCHAIN_NAME);
834        assert!(!manifest.packages.is_empty());
835        assert!(
836            manifest
837                .packages
838                .iter()
839                .all(|package| package.version == "latest")
840        );
841    }
842
843    #[test]
844    fn builds_toolchain_ref() {
845        assert_eq!(
846            toolchain_ref("ghcr.io/greenticai/greentic-versions/gtc", "stable"),
847            "ghcr.io/greenticai/greentic-versions/gtc:stable"
848        );
849    }
850}