1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result, bail};
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6
7use super::cache;
8use super::client::{CatalogArtifactClient, DistributorCatalogClient};
9use super::registry::{CatalogEntry, load_catalog_entries, parse_catalog_bytes};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct CatalogResolveOptions {
13 pub offline: bool,
14 pub write_cache: bool,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct CatalogLockEntry {
19 pub requested_ref: String,
20 pub resolved_ref: String,
21 pub digest: String,
22 pub source: String,
23 pub item_count: usize,
24 pub item_ids: Vec<String>,
25 #[serde(skip_serializing_if = "Option::is_none")]
26 pub cache_path: Option<String>,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct CatalogResolution {
31 pub entries: Vec<CatalogLockEntry>,
32 pub cache_writes: Vec<String>,
33 pub discovered_items: Vec<CatalogEntry>,
34}
35
36impl CatalogResolution {
37 pub fn empty() -> Self {
38 Self {
39 entries: Vec::new(),
40 cache_writes: Vec::new(),
41 discovered_items: Vec::new(),
42 }
43 }
44}
45
46pub fn resolve_catalogs(
47 root: &Path,
48 references: &[String],
49 options: &CatalogResolveOptions,
50) -> Result<CatalogResolution> {
51 resolve_catalogs_with_client(root, references, options, &DistributorCatalogClient)
52}
53
54pub fn resolve_catalogs_with_client(
55 root: &Path,
56 references: &[String],
57 options: &CatalogResolveOptions,
58 client: &dyn CatalogArtifactClient,
59) -> Result<CatalogResolution> {
60 let mut entries = Vec::new();
61 let mut cache_writes = Vec::new();
62 let mut discovered_items = Vec::new();
63
64 let mut refs = references.to_vec();
65 refs.sort();
66 refs.dedup();
67
68 for reference in refs {
69 let resolved = resolve_one(root, &reference, options, client)?;
70 cache_writes.extend(resolved.1);
71 discovered_items.extend(resolved.2);
72 entries.push(resolved.0);
73 }
74
75 cache_writes.sort();
76 cache_writes.dedup();
77 discovered_items.sort_by(|left, right| {
78 left.id
79 .cmp(&right.id)
80 .then(left.reference.cmp(&right.reference))
81 });
82 discovered_items
83 .dedup_by(|left, right| left.id == right.id && left.reference == right.reference);
84
85 Ok(CatalogResolution {
86 entries,
87 cache_writes,
88 discovered_items,
89 })
90}
91
92fn resolve_one(
93 root: &Path,
94 reference: &str,
95 options: &CatalogResolveOptions,
96 client: &dyn CatalogArtifactClient,
97) -> Result<(CatalogLockEntry, Vec<String>, Vec<CatalogEntry>)> {
98 if let Some(local_path) = parse_local_reference(root, reference) {
99 let resolved_path = if local_path.is_absolute() {
100 local_path
101 } else {
102 root.join(local_path)
103 };
104 let bytes = std::fs::read(&resolved_path)
105 .with_context(|| format!("read catalog {}", resolved_path.display()))?;
106 let digest = digest_hex(&bytes);
107 let catalog_entries = load_catalog_entries(&bytes, &resolved_path.display().to_string())?;
108 let summary = parse_catalog_bytes(&bytes, &resolved_path.display().to_string())?;
109 let cache_paths = if options.write_cache {
110 cache::cache_catalog_bytes(root, reference, &digest, &bytes)?
111 } else {
112 Vec::new()
113 };
114 return Ok((
115 CatalogLockEntry {
116 requested_ref: reference.to_string(),
117 resolved_ref: resolved_path.display().to_string(),
118 digest,
119 source: "local_file".to_string(),
120 item_count: summary.item_count,
121 item_ids: summary.item_ids,
122 cache_path: cache::resolve_cached_path(root, reference)?
123 .map(|path| relative_display(root, &path)),
124 },
125 cache_paths
126 .into_iter()
127 .map(|path| relative_display(root, &path))
128 .collect(),
129 catalog_entries,
130 ));
131 }
132
133 if let Some(cached_path) = cache::resolve_cached_path(root, reference)? {
134 let bytes = std::fs::read(&cached_path)
135 .with_context(|| format!("read cached catalog {}", cached_path.display()))?;
136 let digest = digest_hex(&bytes);
137 let catalog_entries = load_catalog_entries(&bytes, &cached_path.display().to_string())?;
138 let summary = parse_catalog_bytes(&bytes, &cached_path.display().to_string())?;
139 return Ok((
140 CatalogLockEntry {
141 requested_ref: reference.to_string(),
142 resolved_ref: reference.to_string(),
143 digest,
144 source: "workspace_cache".to_string(),
145 item_count: summary.item_count,
146 item_ids: summary.item_ids,
147 cache_path: Some(relative_display(root, &cached_path)),
148 },
149 Vec::new(),
150 catalog_entries,
151 ));
152 }
153
154 if options.offline {
155 bail!(
156 "catalog {reference} is not cached in {} and offline mode is enabled; seed the workspace-local cache first or rerun without --offline",
157 root.join(super::CACHE_ROOT_DIR).display()
158 );
159 }
160
161 let fetched = client.fetch_catalog(root, reference)?;
162 let catalog_entries = load_catalog_entries(&fetched.bytes, reference)?;
163 let summary = parse_catalog_bytes(&fetched.bytes, reference)?;
164 let cache_paths = if options.write_cache {
165 cache::cache_catalog_bytes(root, reference, &fetched.digest, &fetched.bytes)?
166 } else {
167 Vec::new()
168 };
169 Ok((
170 CatalogLockEntry {
171 requested_ref: reference.to_string(),
172 resolved_ref: fetched.resolved_ref,
173 digest: fetched.digest,
174 source: "remote".to_string(),
175 item_count: summary.item_count,
176 item_ids: summary.item_ids,
177 cache_path: cache::resolve_cached_path(root, reference)?
178 .map(|path| relative_display(root, &path)),
179 },
180 cache_paths
181 .into_iter()
182 .map(|path| relative_display(root, &path))
183 .collect(),
184 catalog_entries,
185 ))
186}
187
188fn parse_local_reference(root: &Path, reference: &str) -> Option<PathBuf> {
189 if let Some(path) = reference.strip_prefix("file://") {
190 let trimmed = path.trim();
191 if trimmed.is_empty() {
192 return None;
193 }
194 return Some(PathBuf::from(trimmed));
195 }
196 if reference.contains("://") {
197 return None;
198 }
199 let candidate = PathBuf::from(reference);
200 if candidate.is_absolute() || candidate.exists() || root.join(&candidate).exists() {
201 return Some(candidate);
202 }
203 None
204}
205
206fn digest_hex(bytes: &[u8]) -> String {
207 let mut hasher = Sha256::new();
208 hasher.update(bytes);
209 let digest = hasher.finalize();
210 let mut out = String::from("sha256:");
211 for byte in digest {
212 out.push_str(&format!("{byte:02x}"));
213 }
214 out
215}
216
217fn relative_display(root: &Path, path: &Path) -> String {
218 path.strip_prefix(root)
219 .unwrap_or(path)
220 .display()
221 .to_string()
222}