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}