1use super::errors::PackageError;
2use super::*;
3use semver::{Version, VersionReq};
4
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6pub(crate) struct PackageCacheMetadata {
7 version: u32,
8 source: String,
9 commit: String,
10 content_hash: String,
11 cached_at_unix_ms: u128,
12}
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub(crate) struct PackageRegistryIndex {
16 version: u32,
17 #[serde(default, rename = "package")]
18 packages: Vec<RegistryPackage>,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub(crate) struct RegistryPackage {
23 name: String,
24 #[serde(default)]
25 description: Option<String>,
26 repository: String,
27 #[serde(default)]
28 license: Option<String>,
29 #[serde(default, alias = "harn_version", alias = "harn_version_range")]
30 harn: Option<String>,
31 #[serde(default)]
32 exports: Vec<String>,
33 #[serde(default, alias = "connector-contract")]
34 connector_contract: Option<String>,
35 #[serde(default)]
36 docs_url: Option<String>,
37 #[serde(default)]
38 checksum: Option<String>,
39 #[serde(default)]
40 provenance: Option<String>,
41 #[serde(default, rename = "version")]
42 versions: Vec<RegistryPackageVersion>,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub(crate) struct RegistryPackageVersion {
47 version: String,
48 git: String,
49 #[serde(default)]
50 tag: Option<String>,
51 #[serde(default)]
52 rev: Option<String>,
53 #[serde(default)]
54 sha: Option<String>,
55 #[serde(default)]
56 branch: Option<String>,
57 #[serde(default)]
58 package: Option<String>,
59 #[serde(default)]
60 checksum: Option<String>,
61 #[serde(default)]
62 provenance: Option<String>,
63 #[serde(default)]
64 yanked: bool,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
68pub(crate) struct RegistryPackageInfo {
69 package: RegistryPackage,
70 selected_version: Option<RegistryPackageVersion>,
71}
72
73pub(crate) fn manifest_has_git_dependencies(manifest: &Manifest) -> bool {
74 manifest.dependencies.values().any(Dependency::requires_git)
75}
76
77pub(crate) fn ensure_git_available() -> Result<(), PackageError> {
78 process::Command::new("git")
79 .arg("--version")
80 .env_remove("GIT_DIR")
81 .env_remove("GIT_WORK_TREE")
82 .env_remove("GIT_INDEX_FILE")
83 .output()
84 .map(|_| ())
85 .map_err(|_| {
86 PackageError::Registry(
87 "git is required for git dependencies but was not found in PATH".to_string(),
88 )
89 })
90}
91
92pub(crate) fn cache_root() -> Result<PathBuf, PackageError> {
93 PackageWorkspace::from_current_dir()?.cache_root()
94}
95
96pub(crate) fn sha256_hex(bytes: impl AsRef<[u8]>) -> String {
97 hex_bytes(Sha256::digest(bytes.as_ref()))
98}
99
100pub(crate) fn hex_bytes(bytes: impl AsRef<[u8]>) -> String {
101 const HEX: &[u8; 16] = b"0123456789abcdef";
102 let bytes = bytes.as_ref();
103 let mut out = String::with_capacity(bytes.len() * 2);
104 for &byte in bytes {
105 out.push(HEX[(byte >> 4) as usize] as char);
106 out.push(HEX[(byte & 0x0f) as usize] as char);
107 }
108 out
109}
110
111pub(crate) fn git_cache_dir_in(
112 workspace: &PackageWorkspace,
113 source: &str,
114 commit: &str,
115) -> Result<PathBuf, PackageError> {
116 Ok(workspace
117 .cache_root()?
118 .join("git")
119 .join(sha256_hex(source))
120 .join(commit))
121}
122
123pub(crate) fn git_cache_lock_path_in(
124 workspace: &PackageWorkspace,
125 source: &str,
126 commit: &str,
127) -> Result<PathBuf, PackageError> {
128 Ok(workspace
129 .cache_root()?
130 .join("locks")
131 .join(format!("{}-{commit}.lock", sha256_hex(source))))
132}
133
134pub(crate) fn acquire_git_cache_lock_in(
135 workspace: &PackageWorkspace,
136 source: &str,
137 commit: &str,
138) -> Result<File, PackageError> {
139 let path = git_cache_lock_path_in(workspace, source, commit)?;
140 if let Some(parent) = path.parent() {
141 fs::create_dir_all(parent)
142 .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
143 }
144 let file = File::create(&path)
145 .map_err(|error| format!("failed to open {}: {error}", path.display()))?;
146 file.lock_exclusive()
147 .map_err(|error| format!("failed to lock {}: {error}", path.display()))?;
148 Ok(file)
149}
150
151pub(crate) fn read_cached_content_hash(dir: &Path) -> Result<Option<String>, PackageError> {
152 let path = dir.join(CONTENT_HASH_FILE);
153 match fs::read_to_string(&path) {
154 Ok(value) => Ok(Some(value.trim().to_string())),
155 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
156 Err(error) => Err(format!("failed to read {}: {error}", path.display()).into()),
157 }
158}
159
160pub(crate) fn write_cached_content_hash(dir: &Path, hash: &str) -> Result<(), PackageError> {
161 let path = dir.join(CONTENT_HASH_FILE);
162 harn_vm::atomic_io::atomic_write(&path, format!("{hash}\n").as_bytes()).map_err(|error| {
163 PackageError::Registry(format!("failed to write {}: {error}", path.display()))
164 })
165}
166
167pub(crate) fn read_cache_metadata(
168 dir: &Path,
169) -> Result<Option<PackageCacheMetadata>, PackageError> {
170 let path = dir.join(CACHE_METADATA_FILE);
171 let content = match fs::read_to_string(&path) {
172 Ok(content) => content,
173 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
174 Err(error) => return Err(format!("failed to read {}: {error}", path.display()).into()),
175 };
176 let metadata = toml::from_str::<PackageCacheMetadata>(&content)
177 .map_err(|error| format!("failed to parse {}: {error}", path.display()))?;
178 if metadata.version != CACHE_METADATA_VERSION {
179 return Err(format!(
180 "unsupported {} version {} (expected {})",
181 path.display(),
182 metadata.version,
183 CACHE_METADATA_VERSION
184 )
185 .into());
186 }
187 Ok(Some(metadata))
188}
189
190pub(crate) fn write_cache_metadata(
191 dir: &Path,
192 source: &str,
193 commit: &str,
194 content_hash: &str,
195) -> Result<(), PackageError> {
196 let cached_at_unix_ms = SystemTime::now()
197 .duration_since(UNIX_EPOCH)
198 .map_err(|error| format!("system clock error: {error}"))?
199 .as_millis();
200 let metadata = PackageCacheMetadata {
201 version: CACHE_METADATA_VERSION,
202 source: source.to_string(),
203 commit: commit.to_string(),
204 content_hash: content_hash.to_string(),
205 cached_at_unix_ms,
206 };
207 let body = toml::to_string_pretty(&metadata)
208 .map_err(|error| format!("failed to encode cache metadata: {error}"))?;
209 let path = dir.join(CACHE_METADATA_FILE);
210 harn_vm::atomic_io::atomic_write(&path, body.as_bytes()).map_err(|error| {
211 PackageError::Registry(format!("failed to write {}: {error}", path.display()))
212 })
213}
214
215pub(crate) fn normalized_relative_path(path: &Path) -> String {
216 path.components()
217 .map(|component| component.as_os_str().to_string_lossy())
218 .collect::<Vec<_>>()
219 .join("/")
220}
221
222pub(crate) fn collect_hashable_files(
223 root: &Path,
224 cursor: &Path,
225 out: &mut Vec<PathBuf>,
226) -> Result<(), PackageError> {
227 for entry in fs::read_dir(cursor)
228 .map_err(|error| format!("failed to read {}: {error}", cursor.display()))?
229 {
230 let entry =
231 entry.map_err(|error| format!("failed to read {} entry: {error}", cursor.display()))?;
232 let path = entry.path();
233 let file_type = entry
234 .file_type()
235 .map_err(|error| format!("failed to stat {}: {error}", path.display()))?;
236 let name = entry.file_name();
237 if name == OsStr::new(".git")
238 || name == OsStr::new(".gitignore")
239 || name == OsStr::new(CONTENT_HASH_FILE)
240 || name == OsStr::new(CACHE_METADATA_FILE)
241 {
242 continue;
243 }
244 if file_type.is_dir() {
245 collect_hashable_files(root, &path, out)?;
246 } else if file_type.is_file() {
247 let relative = path
248 .strip_prefix(root)
249 .map_err(|error| format!("failed to relativize {}: {error}", path.display()))?;
250 out.push(relative.to_path_buf());
251 }
252 }
253 Ok(())
254}
255
256pub(crate) fn compute_content_hash(dir: &Path) -> Result<String, PackageError> {
257 let mut files = Vec::new();
258 collect_hashable_files(dir, dir, &mut files)?;
259 files.sort();
260 let mut hasher = Sha256::new();
261 for relative in files {
262 let normalized = normalized_relative_path(&relative);
263 let contents = fs::read(dir.join(&relative)).map_err(|error| {
264 format!("failed to read {}: {error}", dir.join(&relative).display())
265 })?;
266 hasher.update(normalized.as_bytes());
267 hasher.update([0]);
268 hasher.update(sha256_hex(contents).as_bytes());
269 }
270 Ok(format!("sha256:{}", hex_bytes(hasher.finalize())))
271}
272
273pub(crate) fn verify_content_hash_or_compute(
274 dir: &Path,
275 expected: &str,
276) -> Result<(), PackageError> {
277 let actual = compute_content_hash(dir)?;
278 if actual != expected {
279 return Err(format!(
280 "content hash mismatch for {}: expected {}, got {}",
281 dir.display(),
282 expected,
283 actual
284 )
285 .into());
286 }
287 if read_cached_content_hash(dir)?.as_deref() != Some(expected) {
288 write_cached_content_hash(dir, expected)?;
289 }
290 Ok(())
291}
292
293pub(crate) fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), PackageError> {
294 fs::create_dir_all(dst)
295 .map_err(|error| format!("failed to create {}: {error}", dst.display()))?;
296 for entry in
297 fs::read_dir(src).map_err(|error| format!("failed to read {}: {error}", src.display()))?
298 {
299 let entry =
300 entry.map_err(|error| format!("failed to read {} entry: {error}", src.display()))?;
301 let ty = entry
302 .file_type()
303 .map_err(|error| format!("failed to stat {}: {error}", entry.path().display()))?;
304 let name = entry.file_name();
305 if name == OsStr::new(".git")
306 || name == OsStr::new(CONTENT_HASH_FILE)
307 || name == OsStr::new(CACHE_METADATA_FILE)
308 {
309 continue;
310 }
311 let dest_path = dst.join(entry.file_name());
312 if ty.is_dir() {
313 copy_dir_recursive(&entry.path(), &dest_path)?;
314 } else if ty.is_file() {
315 if let Some(parent) = dest_path.parent() {
316 fs::create_dir_all(parent)
317 .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
318 }
319 fs::copy(entry.path(), &dest_path).map_err(|error| {
320 format!(
321 "failed to copy {} to {}: {error}",
322 entry.path().display(),
323 dest_path.display()
324 )
325 })?;
326 }
327 }
328 Ok(())
329}
330
331pub(crate) fn remove_materialized_package(
332 packages_dir: &Path,
333 alias: &str,
334) -> Result<(), PackageError> {
335 remove_materialized_path(&packages_dir.join(alias))?;
336 remove_materialized_path(&packages_dir.join(format!("{alias}.harn")))?;
337 Ok(())
338}
339
340fn remove_materialized_path(path: &Path) -> Result<(), PackageError> {
341 match fs::symlink_metadata(path) {
342 Ok(metadata) if is_link_like(&metadata) => remove_link_like_path(path)
343 .map_err(|error| format!("failed to remove {}: {error}", path.display()).into()),
344 Ok(metadata) if metadata.is_file() => fs::remove_file(path)
345 .map_err(|error| format!("failed to remove {}: {error}", path.display()).into()),
346 Ok(metadata) if metadata.is_dir() => fs::remove_dir_all(path)
347 .map_err(|error| format!("failed to remove {}: {error}", path.display()).into()),
348 Ok(_) => Ok(()),
349 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
350 Err(error) => Err(format!("failed to stat {}: {error}", path.display()).into()),
351 }
352}
353
354fn is_link_like(metadata: &fs::Metadata) -> bool {
355 metadata.file_type().is_symlink() || is_windows_reparse_point(metadata)
356}
357
358#[cfg(windows)]
359fn is_windows_reparse_point(metadata: &fs::Metadata) -> bool {
360 use std::os::windows::fs::MetadataExt;
361
362 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
363 metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
364}
365
366#[cfg(not(windows))]
367fn is_windows_reparse_point(_metadata: &fs::Metadata) -> bool {
368 false
369}
370
371fn remove_link_like_path(path: &Path) -> std::io::Result<()> {
372 match fs::remove_file(path) {
373 Ok(()) => Ok(()),
374 Err(file_error) => match fs::remove_dir(path) {
375 Ok(()) => Ok(()),
376 Err(_) => Err(file_error),
377 },
378 }
379}
380
381#[cfg(unix)]
382pub(crate) fn symlink_path_dependency(source: &Path, dest: &Path) -> Result<(), PackageError> {
383 std::os::unix::fs::symlink(source, dest).map_err(|error| {
384 PackageError::Registry(format!(
385 "failed to symlink {} to {}: {error}",
386 source.display(),
387 dest.display()
388 ))
389 })
390}
391
392#[cfg(windows)]
393pub(crate) fn symlink_path_dependency(source: &Path, dest: &Path) -> Result<(), PackageError> {
394 if source.is_dir() {
395 std::os::windows::fs::symlink_dir(source, dest)
396 } else {
397 std::os::windows::fs::symlink_file(source, dest)
398 }
399 .map_err(|error| {
400 PackageError::Registry(format!(
401 "failed to symlink {} to {}: {error}",
402 source.display(),
403 dest.display()
404 ))
405 })
406}
407
408#[cfg(not(any(unix, windows)))]
409pub(crate) fn symlink_path_dependency(_source: &Path, _dest: &Path) -> Result<(), PackageError> {
410 Err("symlinks are not supported on this platform"
411 .to_string()
412 .into())
413}
414
415pub(crate) fn materialize_path_dependency(
416 source: &Path,
417 dest_root: &Path,
418 alias: &str,
419) -> Result<(), PackageError> {
420 remove_materialized_package(dest_root, alias)?;
421 if source.is_dir() {
422 let dest = dest_root.join(alias);
423 match symlink_path_dependency(source, &dest) {
424 Ok(()) => Ok(()),
425 Err(_) => copy_dir_recursive(source, &dest),
426 }
427 } else {
428 let dest = dest_root.join(format!("{alias}.harn"));
429 if let Some(parent) = dest.parent() {
430 fs::create_dir_all(parent)
431 .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
432 }
433 match symlink_path_dependency(source, &dest) {
434 Ok(()) => Ok(()),
435 Err(_) => {
436 fs::copy(source, &dest).map_err(|error| {
437 format!(
438 "failed to copy {} to {}: {error}",
439 source.display(),
440 dest.display()
441 )
442 })?;
443 Ok(())
444 }
445 }
446 }
447}
448
449pub(crate) fn materialized_hash_matches(dir: &Path, expected: &str) -> bool {
450 verify_content_hash_or_compute(dir, expected).is_ok()
451}
452
453pub(crate) fn resolve_path_dependency_source(
454 manifest_dir: &Path,
455 raw: &str,
456) -> Result<PathBuf, PackageError> {
457 let source = {
458 let candidate = PathBuf::from(raw);
459 if candidate.is_absolute() {
460 candidate
461 } else {
462 manifest_dir.join(candidate)
463 }
464 };
465 if source.exists() {
466 return source.canonicalize().map_err(|error| {
467 PackageError::Registry(format!(
468 "failed to canonicalize {}: {error}",
469 source.display()
470 ))
471 });
472 }
473 if source.extension().is_none() {
474 let with_ext = source.with_extension("harn");
475 if with_ext.exists() {
476 return with_ext.canonicalize().map_err(|error| {
477 PackageError::Registry(format!(
478 "failed to canonicalize {}: {error}",
479 with_ext.display()
480 ))
481 });
482 }
483 }
484 Err(format!("package source not found: {}", source.display()).into())
485}
486
487pub(crate) fn path_source_uri(path: &Path) -> Result<String, PackageError> {
488 let url = Url::from_file_path(path)
489 .map_err(|_| format!("failed to convert {} to file:// URL", path.display()))?;
490 Ok(format!("path+{}", url))
491}
492
493pub(crate) fn path_from_source_uri(source: &str) -> Result<PathBuf, PackageError> {
494 let raw = source
495 .strip_prefix("path+")
496 .ok_or_else(|| format!("invalid path source: {source}"))?;
497 if let Ok(url) = Url::parse(raw) {
498 return url
499 .to_file_path()
500 .map_err(|_| PackageError::Registry(format!("invalid file:// path source: {source}")));
501 }
502 Ok(PathBuf::from(raw))
503}
504
505pub(crate) fn registry_file_url_or_path(raw: &str) -> Result<Option<PathBuf>, PackageError> {
506 if let Ok(url) = Url::parse(raw) {
507 if url.scheme() == "file" {
508 return url.to_file_path().map(Some).map_err(|_| {
509 PackageError::Registry(format!("invalid file:// registry URL: {raw}"))
510 });
511 }
512 return Ok(None);
513 }
514 Ok(Some(PathBuf::from(raw)))
515}
516
517pub(crate) fn read_registry_source(source: &str) -> Result<String, PackageError> {
518 if let Some(path) = registry_file_url_or_path(source)? {
519 return fs::read_to_string(&path).map_err(|error| {
520 PackageError::Registry(format!(
521 "failed to read package registry {}: {error}",
522 path.display()
523 ))
524 });
525 }
526
527 let url = Url::parse(source)
528 .map_err(|error| format!("invalid package registry URL {source:?}: {error}"))?;
529 match url.scheme() {
530 "http" | "https" => {}
531 other => return Err(format!("unsupported package registry URL scheme: {other}").into()),
532 }
533 let source_owned = source.to_string();
539 std::thread::scope(|scope| {
540 scope
541 .spawn(move || fetch_registry_blocking(url, &source_owned))
542 .join()
543 .map_err(|_| PackageError::Registry("registry fetch thread panicked".to_string()))?
544 })
545}
546
547fn fetch_registry_blocking(url: Url, source: &str) -> Result<String, PackageError> {
548 let response = reqwest::blocking::Client::builder()
549 .timeout(Duration::from_secs(20))
550 .build()
551 .map_err(|error| format!("failed to build package registry client: {error}"))?
552 .get(url)
553 .send()
554 .map_err(|error| format!("failed to fetch package registry {source}: {error}"))?;
555 let status = response.status();
556 if !status.is_success() {
557 return Err(format!("GET {source} returned HTTP {status}").into());
558 }
559 response.text().map_err(|error| {
560 PackageError::Registry(format!("failed to read package registry response: {error}"))
561 })
562}
563
564pub(crate) fn is_valid_registry_segment(segment: &str) -> bool {
565 let mut chars = segment.chars();
566 let Some(first) = chars.next() else {
567 return false;
568 };
569 first.is_ascii_alphanumeric()
570 && chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
571}
572
573pub(crate) fn is_valid_registry_package_name(name: &str) -> bool {
574 let trimmed = name.trim();
575 if trimmed != name || trimmed.is_empty() || trimmed.contains("://") || trimmed.ends_with('/') {
576 return false;
577 }
578 if let Some(scoped) = trimmed.strip_prefix('@') {
579 let Some((scope, package)) = scoped.split_once('/') else {
580 return false;
581 };
582 return !package.contains('/')
583 && is_valid_registry_segment(scope)
584 && is_valid_registry_segment(package);
585 }
586 !trimmed.contains('/') && is_valid_registry_segment(trimmed)
587}
588
589pub(crate) fn parse_registry_package_spec(spec: &str) -> Option<(&str, Option<&str>)> {
590 let trimmed = spec.trim();
591 if !trimmed.starts_with('@') {
592 if let Some((name, version)) = trimmed.rsplit_once('@') {
593 if is_valid_registry_package_name(name) && !version.trim().is_empty() {
594 return Some((name, Some(version)));
595 }
596 }
597 if is_valid_registry_package_name(trimmed) {
598 return Some((trimmed, None));
599 }
600 return None;
601 }
602
603 if let Some((name, version)) = trimmed.rsplit_once('@') {
604 if !name.is_empty()
605 && name != trimmed
606 && is_valid_registry_package_name(name)
607 && !version.trim().is_empty()
608 {
609 return Some((name, Some(version)));
610 }
611 }
612 if is_valid_registry_package_name(trimmed) {
613 return Some((trimmed, None));
614 }
615 None
616}
617
618pub(crate) fn parse_package_registry_index(
619 source: &str,
620 content: &str,
621) -> Result<PackageRegistryIndex, PackageError> {
622 let mut index = toml::from_str::<PackageRegistryIndex>(content)
623 .map_err(|error| format!("failed to parse package registry {source}: {error}"))?;
624 if index.version != REGISTRY_INDEX_VERSION {
625 return Err(format!(
626 "unsupported package registry {source} version {} (expected {})",
627 index.version, REGISTRY_INDEX_VERSION
628 )
629 .into());
630 }
631 validate_package_registry_index(source, &mut index)?;
632 Ok(index)
633}
634
635pub(crate) fn validate_package_registry_index(
636 source: &str,
637 index: &mut PackageRegistryIndex,
638) -> Result<(), PackageError> {
639 let mut names = HashSet::new();
640 for package in &mut index.packages {
641 if !is_valid_registry_package_name(&package.name) {
642 return Err(format!(
643 "package registry {source} has invalid package name '{}'",
644 package.name
645 )
646 .into());
647 }
648 if !names.insert(package.name.clone()) {
649 return Err(format!(
650 "package registry {source} declares '{}' more than once",
651 package.name
652 )
653 .into());
654 }
655 normalize_git_url(&package.repository).map_err(|error| {
656 format!(
657 "package registry {source} has invalid repository for '{}': {error}",
658 package.name
659 )
660 })?;
661 let mut versions = HashSet::new();
662 for version in &package.versions {
663 if version.version.trim().is_empty() {
664 return Err(format!(
665 "package registry {source} has empty version for '{}'",
666 package.name
667 )
668 .into());
669 }
670 if !versions.insert(version.version.clone()) {
671 return Err(format!(
672 "package registry {source} declares '{}@{}' more than once",
673 package.name, version.version
674 )
675 .into());
676 }
677 if selected_git_ref_count(version) != 1 {
678 return Err(format!(
679 "package registry {source} entry '{}@{}' must specify tag, rev, or branch; rev may accompany tag as a resolved commit pin",
680 package.name, version.version
681 )
682 .into());
683 }
684 parse_registry_semver(&version.version).map_err(|error| {
685 format!(
686 "package registry {source} has invalid semver for '{}@{}': {error}",
687 package.name, version.version
688 )
689 })?;
690 normalize_git_url(&version.git).map_err(|error| {
691 format!(
692 "package registry {source} has invalid git source for '{}@{}': {error}",
693 package.name, version.version
694 )
695 })?;
696 }
697 }
698 index
699 .packages
700 .sort_by(|left, right| left.name.cmp(&right.name));
701 Ok(())
702}
703
704fn selected_git_ref_count(version: &RegistryPackageVersion) -> usize {
705 usize::from(version.tag.is_some())
706 + usize::from(version.tag.is_none() && version.rev.is_some())
707 + usize::from(version.branch.is_some())
708}
709
710pub(crate) fn load_package_registry_in(
711 workspace: &PackageWorkspace,
712 explicit: Option<&str>,
713) -> Result<(String, PackageRegistryIndex), PackageError> {
714 let source = workspace.resolve_registry_source(explicit)?;
715 let content = read_registry_source(&source)?;
716 let index = parse_package_registry_index(&source, &content)?;
717 Ok((source, index))
718}
719
720pub(crate) fn registry_package_matches(package: &RegistryPackage, query: &str) -> bool {
721 if query.trim().is_empty() {
722 return true;
723 }
724 let query = query.to_ascii_lowercase();
725 package.name.to_ascii_lowercase().contains(&query)
726 || package
727 .description
728 .as_deref()
729 .is_some_and(|value| value.to_ascii_lowercase().contains(&query))
730 || package.repository.to_ascii_lowercase().contains(&query)
731 || package
732 .exports
733 .iter()
734 .any(|export| export.to_ascii_lowercase().contains(&query))
735}
736
737pub(crate) fn latest_registry_version(
738 package: &RegistryPackage,
739) -> Option<&RegistryPackageVersion> {
740 package
741 .versions
742 .iter()
743 .filter(|version| !version.yanked)
744 .filter_map(|version| {
745 parse_registry_semver(&version.version)
746 .ok()
747 .map(|semver| (semver, version))
748 })
749 .max_by(|(left, _), (right, _)| left.cmp(right))
750 .map(|(_, version)| version)
751}
752
753impl PackageRegistryIndex {
754 pub(crate) fn latest_unyanked_version(&self, name: &str) -> Option<&str> {
755 self.packages
756 .iter()
757 .find(|package| package.name == name)
758 .and_then(latest_registry_version)
759 .map(|version| version.version.as_str())
760 }
761
762 pub(crate) fn is_version_yanked(&self, name: &str, version: &str) -> bool {
763 self.packages
764 .iter()
765 .find(|package| package.name == name)
766 .into_iter()
767 .flat_map(|package| package.versions.iter())
768 .any(|entry| entry.version == version && entry.yanked)
769 }
770}
771
772pub(crate) fn parse_registry_semver(raw: &str) -> Result<Version, PackageError> {
773 Version::parse(raw.trim().trim_start_matches('v'))
774 .map_err(|error| PackageError::Registry(error.to_string()))
775}
776
777pub(crate) fn parse_registry_version_req(raw: &str) -> Result<VersionReq, PackageError> {
778 VersionReq::parse(&normalize_registry_version_req(raw)).map_err(|error| {
779 PackageError::Registry(format!("invalid version requirement {raw:?}: {error}"))
780 })
781}
782
783fn normalize_registry_version_req(raw: &str) -> String {
784 raw.split(',')
785 .map(|part| normalize_version_req_part(part.trim()))
786 .collect::<Vec<_>>()
787 .join(",")
788}
789
790fn normalize_version_req_part(part: &str) -> String {
791 for op in ["<=", ">=", "!=", "=", "<", ">", "^", "~"] {
792 if let Some(rest) = part.strip_prefix(op) {
793 return format!("{op}{}", normalize_partial_version(rest.trim()));
794 }
795 }
796 normalize_partial_version(part)
797}
798
799fn normalize_partial_version(raw: &str) -> String {
800 let trimmed = raw.trim().trim_start_matches('v');
801 if trimmed == "*" || trimmed.eq_ignore_ascii_case("x") {
802 return trimmed.to_string();
803 }
804 let (core, suffix) = trimmed
805 .find(['-', '+'])
806 .map(|index| (&trimmed[..index], &trimmed[index..]))
807 .unwrap_or((trimmed, ""));
808 let mut parts = core.split('.').collect::<Vec<_>>();
809 if (1..=2).contains(&parts.len())
810 && parts
811 .iter()
812 .all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit()))
813 {
814 while parts.len() < 3 {
815 parts.push("0");
816 }
817 return format!("{}{}", parts.join("."), suffix);
818 }
819 trimmed.to_string()
820}
821
822fn lookup_registry_package<'a>(
827 index: &'a PackageRegistryIndex,
828 name: &str,
829) -> Result<&'a RegistryPackage, PackageError> {
830 if let Some(package) = index.packages.iter().find(|package| package.name == name) {
831 return Ok(package);
832 }
833 let matches: Vec<&RegistryPackage> = index
834 .packages
835 .iter()
836 .filter(|package| {
837 package
838 .versions
839 .iter()
840 .any(|entry| entry.package.as_deref() == Some(name))
841 })
842 .collect();
843 match matches.as_slice() {
844 [package] => Ok(package),
845 [] => Err(format!("package registry does not contain {name}").into()),
846 many => Err(format!(
847 "package alias {name} is ambiguous in the registry — found {} packages; use the scoped name (e.g. {})",
848 many.len(),
849 many[0].name,
850 )
851 .into()),
852 }
853}
854
855pub(crate) fn find_registry_package_version(
856 index: &PackageRegistryIndex,
857 name: &str,
858 version: Option<&str>,
859) -> Result<RegistryPackageInfo, PackageError> {
860 let package = lookup_registry_package(index, name)?;
861 let selected_version = match version {
862 Some(version) => Some(
863 package
864 .versions
865 .iter()
866 .find(|entry| entry.version == version)
867 .ok_or_else(|| format!("package registry does not contain {name}@{version}"))?
868 .clone(),
869 ),
870 None => latest_registry_version(package).cloned(),
871 };
872 Ok(RegistryPackageInfo {
873 package: package.clone(),
874 selected_version,
875 })
876}
877
878pub(crate) fn find_registry_package_version_matching(
879 index: &PackageRegistryIndex,
880 name: &str,
881 requirement: &str,
882) -> Result<RegistryPackageInfo, PackageError> {
883 let package = lookup_registry_package(index, name)?;
884 let req = parse_registry_version_req(requirement)?;
885 let selected_version = package
886 .versions
887 .iter()
888 .filter(|entry| !entry.yanked)
889 .filter_map(|entry| {
890 parse_registry_semver(&entry.version)
891 .ok()
892 .filter(|version| req.matches(version))
893 .map(|version| (version, entry.clone()))
894 })
895 .max_by(|(left, _), (right, _)| left.cmp(right))
896 .map(|(_, entry)| entry)
897 .ok_or_else(|| {
898 format!("package registry does not contain {name} matching {requirement}")
899 })?;
900 Ok(RegistryPackageInfo {
901 package: package.clone(),
902 selected_version: Some(selected_version),
903 })
904}
905
906pub(crate) fn search_package_registry_impl(
907 query: Option<&str>,
908 registry: Option<&str>,
909) -> Result<Vec<RegistryPackage>, PackageError> {
910 search_package_registry_in(&PackageWorkspace::from_current_dir()?, query, registry)
911}
912
913pub(crate) fn search_package_registry_in(
914 workspace: &PackageWorkspace,
915 query: Option<&str>,
916 registry: Option<&str>,
917) -> Result<Vec<RegistryPackage>, PackageError> {
918 let (_, index) = load_package_registry_in(workspace, registry)?;
919 Ok(index
920 .packages
921 .into_iter()
922 .filter(|package| registry_package_matches(package, query.unwrap_or("")))
923 .collect())
924}
925
926pub(crate) fn package_registry_info_impl(
927 spec: &str,
928 registry: Option<&str>,
929) -> Result<RegistryPackageInfo, PackageError> {
930 package_registry_info_in(&PackageWorkspace::from_current_dir()?, spec, registry)
931}
932
933pub(crate) fn package_registry_info_in(
934 workspace: &PackageWorkspace,
935 spec: &str,
936 registry: Option<&str>,
937) -> Result<RegistryPackageInfo, PackageError> {
938 let Some((name, version)) = parse_registry_package_spec(spec) else {
939 return Err(format!(
940 "invalid registry package name '{spec}'; use names like @burin/notion-sdk or acme-lib"
941 )
942 .into());
943 };
944 let (_, index) = load_package_registry_in(workspace, registry)?;
945 find_registry_package_version(&index, name, version)
946}
947
948pub(crate) fn registry_dependency_from_spec_in(
949 workspace: &PackageWorkspace,
950 spec: &str,
951 alias: Option<&str>,
952 registry: Option<&str>,
953) -> Result<(String, Dependency), PackageError> {
954 let Some((name, Some(version))) = parse_registry_package_spec(spec) else {
955 return Err(format!(
956 "registry dependency '{spec}' must include a version, for example {spec}@1.2.3"
957 )
958 .into());
959 };
960 let registry_source = workspace.resolve_registry_source(registry)?;
961 let (_, index) = load_package_registry_in(workspace, registry)?;
962 let info = if is_exact_semver(version) {
966 find_registry_package_version(&index, name, Some(version))?
967 } else {
968 find_registry_package_version_matching(&index, name, version)?
969 };
970 let selected = info
971 .selected_version
972 .ok_or_else(|| format!("package registry does not contain {name}@{version}"))?;
973 if selected.yanked {
974 return Err(format!("{name}@{version} is yanked in the package registry").into());
975 }
976 let git = normalize_git_url(&selected.git)?;
977 let package_name = selected
978 .package
979 .clone()
980 .map(Ok)
981 .unwrap_or_else(|| derive_repo_name_from_source(&git))?;
982 let alias = alias.unwrap_or(package_name.as_str()).to_string();
983 let tag = selected.tag;
984 let rev = if tag.is_some() { None } else { selected.rev };
985 let resolved_version = selected.version.clone();
986 Ok((
987 alias.clone(),
988 Dependency::Table(Box::new(DepTable {
989 git: Some(git),
990 tag,
991 rev,
992 branch: selected.branch,
993 package: (alias != package_name).then_some(package_name),
994 registry: Some(registry_source),
995 registry_name: Some(info.package.name.clone()),
999 registry_version: Some(resolved_version),
1000 ..DepTable::default()
1001 })),
1002 ))
1003}
1004
1005fn is_exact_semver(spec: &str) -> bool {
1006 parse_registry_semver(spec).is_ok()
1007}
1008
1009pub(crate) fn registry_dependency_from_manifest_constraint_in(
1010 workspace: &PackageWorkspace,
1011 alias: &str,
1012 table: &DepTable,
1013) -> Result<Dependency, PackageError> {
1014 let requirement = table
1015 .version
1016 .as_deref()
1017 .ok_or_else(|| format!("dependency {alias} is missing `version`"))?;
1018 let registry_source = workspace.resolve_registry_source(table.registry.as_deref())?;
1019 let registry_name = table.registry_name.as_deref().unwrap_or(alias);
1020 let (_, index) = load_package_registry_in(workspace, Some(®istry_source))?;
1021 let info = find_registry_package_version_matching(&index, registry_name, requirement)?;
1022 let selected = info.selected_version.ok_or_else(|| {
1023 format!("package registry does not contain {registry_name} matching {requirement}")
1024 })?;
1025 let git = normalize_git_url(&selected.git)?;
1026 let tag = selected.tag;
1027 let rev = if tag.is_some() { None } else { selected.rev };
1028 Ok(Dependency::Table(Box::new(DepTable {
1029 git: Some(git),
1030 tag,
1031 rev,
1032 branch: selected.branch,
1033 package: selected.package.or_else(|| table.package.clone()),
1034 registry: Some(registry_source),
1035 registry_name: Some(registry_name.to_string()),
1036 registry_version: Some(selected.version),
1037 ..DepTable::default()
1038 })))
1039}
1040
1041pub(crate) fn is_probable_shorthand_git_url(raw: &str) -> bool {
1042 !raw.contains("://")
1043 && !raw.starts_with("git@")
1044 && raw.contains('/')
1045 && raw
1046 .split('/')
1047 .next()
1048 .is_some_and(|segment| segment.contains('.'))
1049}
1050
1051pub(crate) fn normalize_git_url(raw: &str) -> Result<String, PackageError> {
1052 let trimmed = raw.trim();
1053 if trimmed.is_empty() {
1054 return Err("git URL cannot be empty".to_string().into());
1055 }
1056
1057 let candidate_path = PathBuf::from(trimmed);
1058 if candidate_path.exists() {
1059 let canonical = candidate_path
1060 .canonicalize()
1061 .map_err(|error| format!("failed to canonicalize {}: {error}", trimmed))?;
1062 let url = Url::from_file_path(canonical)
1063 .map_err(|_| format!("failed to convert {} to file:// URL", trimmed))?;
1064 return Ok(url.to_string().trim_end_matches('/').to_string());
1065 }
1066
1067 if let Some(rest) = trimmed.strip_prefix("git@") {
1068 if let Some((host, path)) = rest.split_once(':') {
1069 return Ok(format!(
1070 "ssh://git@{}/{}",
1071 host,
1072 path.trim_start_matches('/').trim_end_matches('/')
1073 ));
1074 }
1075 }
1076
1077 let with_scheme = if is_probable_shorthand_git_url(trimmed) {
1078 format!("https://{trimmed}")
1079 } else {
1080 trimmed.to_string()
1081 };
1082 let parsed =
1083 Url::parse(&with_scheme).map_err(|error| format!("invalid git URL {trimmed}: {error}"))?;
1084 let mut normalized = parsed.to_string();
1085 while normalized.ends_with('/') {
1086 normalized.pop();
1087 }
1088 if parsed.scheme() != "file" && normalized.ends_with(".git") {
1089 normalized.truncate(normalized.len() - 4);
1090 }
1091 Ok(normalized)
1092}
1093
1094pub(crate) fn derive_repo_name_from_source(source: &str) -> Result<String, PackageError> {
1095 let url = Url::parse(source).map_err(|error| format!("invalid git URL {source}: {error}"))?;
1096 let segment = url
1097 .path_segments()
1098 .and_then(|mut segments| segments.rfind(|segment| !segment.is_empty()))
1099 .ok_or_else(|| format!("failed to derive package name from {source}"))?;
1100 Ok(segment.trim_end_matches(".git").to_string())
1101}
1102
1103pub(crate) fn parse_positional_git_spec(spec: &str) -> (&str, Option<&str>) {
1104 if let Some((source, candidate_ref)) = spec.rsplit_once('@') {
1105 if !candidate_ref.is_empty()
1106 && !candidate_ref.contains('/')
1107 && !candidate_ref.contains(':')
1108 && !source.ends_with("://")
1109 {
1110 return (source, Some(candidate_ref));
1111 }
1112 }
1113 (spec, None)
1114}
1115
1116pub(crate) fn existing_local_path_spec(spec: &str) -> Option<PathBuf> {
1117 if spec.trim().is_empty() || spec.contains("://") || spec.starts_with("git@") {
1118 return None;
1119 }
1120 let candidate = PathBuf::from(spec);
1121 if candidate.exists() {
1122 return Some(candidate);
1123 }
1124 if candidate.extension().is_none() {
1125 let with_ext = candidate.with_extension("harn");
1126 if with_ext.exists() {
1127 return Some(with_ext);
1128 }
1129 }
1130 if is_probable_shorthand_git_url(spec) {
1131 return None;
1132 }
1133 None
1134}
1135
1136pub(crate) fn package_manifest_name(path: &Path) -> Option<String> {
1137 let manifest_path = if path.is_dir() {
1138 path.join(MANIFEST)
1139 } else {
1140 path.parent()?.join(MANIFEST)
1141 };
1142 let manifest = read_manifest_from_path(&manifest_path).ok()?;
1143 manifest
1144 .package
1145 .and_then(|pkg| pkg.name)
1146 .map(|name| name.trim().to_string())
1147 .filter(|name| !name.is_empty())
1148}
1149
1150pub(crate) fn derive_package_alias_from_path(path: &Path) -> Result<String, PackageError> {
1151 if let Some(name) = package_manifest_name(path) {
1152 return Ok(name);
1153 }
1154 let fallback = if path.is_dir() {
1155 path.file_name()
1156 } else {
1157 path.file_stem()
1158 };
1159 fallback
1160 .and_then(|name| name.to_str())
1161 .map(str::trim)
1162 .filter(|name| !name.is_empty())
1163 .map(str::to_string)
1164 .ok_or_else(|| {
1165 PackageError::Registry(format!(
1166 "failed to derive package alias from {}",
1167 path.display()
1168 ))
1169 })
1170}
1171
1172pub(crate) fn is_full_git_sha(value: &str) -> bool {
1173 value.len() == 40 && value.as_bytes().iter().all(|byte| byte.is_ascii_hexdigit())
1174}
1175
1176pub(crate) fn git_output<I, S>(
1177 args: I,
1178 cwd: Option<&Path>,
1179) -> Result<std::process::Output, PackageError>
1180where
1181 I: IntoIterator<Item = S>,
1182 S: AsRef<OsStr>,
1183{
1184 let mut command = process::Command::new("git");
1185 command.args(args);
1186 if let Some(dir) = cwd {
1187 command.current_dir(dir);
1188 }
1189 command
1190 .env_remove("GIT_DIR")
1191 .env_remove("GIT_WORK_TREE")
1192 .env_remove("GIT_INDEX_FILE")
1193 .output()
1194 .map_err(|error| PackageError::Registry(format!("failed to run git: {error}")))
1195}
1196
1197pub(crate) fn resolve_git_commit(
1198 url: &str,
1199 rev: Option<&str>,
1200 tag: Option<&str>,
1201 branch: Option<&str>,
1202) -> Result<String, PackageError> {
1203 let requested = branch.or(rev).or(tag).unwrap_or("HEAD");
1204 if branch.is_none() && tag.is_none() && is_full_git_sha(requested) {
1205 return Ok(requested.to_string());
1206 }
1207
1208 let refs = if let Some(branch) = branch {
1209 vec![format!("refs/heads/{branch}")]
1210 } else if let Some(tag) = tag {
1211 vec![format!("refs/tags/{tag}^{{}}"), format!("refs/tags/{tag}")]
1212 } else if requested == "HEAD" {
1213 vec!["HEAD".to_string()]
1214 } else {
1215 vec![
1216 requested.to_string(),
1217 format!("refs/tags/{requested}^{{}}"),
1218 format!("refs/tags/{requested}"),
1219 format!("refs/heads/{requested}"),
1220 ]
1221 };
1222
1223 let output = git_output(
1224 std::iter::once("ls-remote".to_string())
1225 .chain(std::iter::once(url.to_string()))
1226 .chain(refs.clone()),
1227 None,
1228 )?;
1229 if !output.status.success() {
1230 return Err(format!(
1231 "failed to resolve git ref from {url}: {}",
1232 String::from_utf8_lossy(&output.stderr).trim()
1233 )
1234 .into());
1235 }
1236 let stdout = String::from_utf8_lossy(&output.stdout);
1237 pick_ls_remote_commit(&stdout)
1238 .map(str::to_string)
1239 .ok_or_else(|| format!("could not resolve {requested} from {url}").into())
1240}
1241
1242fn pick_ls_remote_commit(stdout: &str) -> Option<&str> {
1250 let parsed: Vec<(&str, &str)> = stdout
1251 .lines()
1252 .filter_map(|line| {
1253 let mut parts = line.split_whitespace();
1254 let sha = parts.next()?;
1255 let refname = parts.next().unwrap_or("");
1256 is_full_git_sha(sha).then_some((sha, refname))
1257 })
1258 .collect();
1259 parsed
1260 .iter()
1261 .find_map(|(sha, refname)| refname.ends_with("^{}").then_some(*sha))
1262 .or_else(|| parsed.first().map(|(sha, _)| *sha))
1263}
1264
1265pub(crate) fn clone_git_commit_to(
1266 url: &str,
1267 commit: &str,
1268 dest: &Path,
1269) -> Result<(), PackageError> {
1270 if dest.exists() {
1271 fs::remove_dir_all(dest)
1272 .map_err(|error| format!("failed to reset {}: {error}", dest.display()))?;
1273 }
1274 fs::create_dir_all(dest)
1275 .map_err(|error| format!("failed to create {}: {error}", dest.display()))?;
1276
1277 let init = git_output(["init", "--quiet"], Some(dest))?;
1278 if !init.status.success() {
1279 return Err(format!(
1280 "failed to initialize git repo in {}: {}",
1281 dest.display(),
1282 String::from_utf8_lossy(&init.stderr).trim()
1283 )
1284 .into());
1285 }
1286
1287 let remote = git_output(["remote", "add", "origin", url], Some(dest))?;
1288 if !remote.status.success() {
1289 return Err(format!(
1290 "failed to add git remote {url}: {}",
1291 String::from_utf8_lossy(&remote.stderr).trim()
1292 )
1293 .into());
1294 }
1295
1296 let fetch = git_output(["fetch", "--depth", "1", "origin", commit], Some(dest))?;
1297 if !fetch.status.success() {
1298 let fallback_dir = dest.with_extension("full-clone");
1299 if fallback_dir.exists() {
1300 fs::remove_dir_all(&fallback_dir)
1301 .map_err(|error| format!("failed to remove {}: {error}", fallback_dir.display()))?;
1302 }
1303 let clone = git_output(
1304 ["clone", url, fallback_dir.to_string_lossy().as_ref()],
1305 None,
1306 )?;
1307 if !clone.status.success() {
1308 return Err(format!(
1309 "failed to fetch {commit} from {url}: {}",
1310 String::from_utf8_lossy(&fetch.stderr).trim()
1311 )
1312 .into());
1313 }
1314 let checkout = git_output(["checkout", commit], Some(&fallback_dir))?;
1315 if !checkout.status.success() {
1316 return Err(format!(
1317 "failed to checkout {commit} in {}: {}",
1318 fallback_dir.display(),
1319 String::from_utf8_lossy(&checkout.stderr).trim()
1320 )
1321 .into());
1322 }
1323 fs::remove_dir_all(dest)
1324 .map_err(|error| format!("failed to remove {}: {error}", dest.display()))?;
1325 fs::rename(&fallback_dir, dest).map_err(|error| {
1326 format!(
1327 "failed to move {} to {}: {error}",
1328 fallback_dir.display(),
1329 dest.display()
1330 )
1331 })?;
1332 } else {
1333 let checkout = git_output(["checkout", "--detach", "FETCH_HEAD"], Some(dest))?;
1334 if !checkout.status.success() {
1335 return Err(format!(
1336 "failed to checkout FETCH_HEAD in {}: {}",
1337 dest.display(),
1338 String::from_utf8_lossy(&checkout.stderr).trim()
1339 )
1340 .into());
1341 }
1342 }
1343
1344 let git_dir = dest.join(".git");
1345 if git_dir.exists() {
1346 fs::remove_dir_all(&git_dir)
1347 .map_err(|error| format!("failed to remove {}: {error}", git_dir.display()))?;
1348 }
1349 Ok(())
1350}
1351
1352pub(crate) fn unique_temp_dir(base: &Path, label: &str) -> Result<PathBuf, PackageError> {
1353 for _ in 0..16 {
1354 let suffix = uuid::Uuid::now_v7();
1355 let candidate = base.join(format!("{label}-{suffix}"));
1356 if !candidate.exists() {
1357 return Ok(candidate);
1358 }
1359 }
1360 Err(format!(
1361 "failed to allocate a unique temporary directory under {}",
1362 base.display()
1363 )
1364 .into())
1365}
1366
1367pub(crate) fn ensure_git_cache_populated_in(
1368 workspace: &PackageWorkspace,
1369 url: &str,
1370 source: &str,
1371 commit: &str,
1372 expected_hash: Option<&str>,
1373 refetch: bool,
1374 offline: bool,
1375) -> Result<String, PackageError> {
1376 let cache_dir = git_cache_dir_in(workspace, source, commit)?;
1377 let _lock = acquire_git_cache_lock_in(workspace, source, commit)?;
1378 if refetch && cache_dir.exists() {
1379 fs::remove_dir_all(&cache_dir)
1380 .map_err(|error| format!("failed to remove {}: {error}", cache_dir.display()))?;
1381 }
1382 if cache_dir.exists() {
1383 if let Some(expected) = expected_hash {
1384 verify_content_hash_or_compute(&cache_dir, expected)?;
1385 write_cache_metadata(&cache_dir, source, commit, expected)?;
1386 return Ok(expected.to_string());
1387 }
1388 let hash = compute_content_hash(&cache_dir)?;
1389 write_cached_content_hash(&cache_dir, &hash)?;
1390 write_cache_metadata(&cache_dir, source, commit, &hash)?;
1391 return Ok(hash);
1392 }
1393
1394 if offline {
1395 return Err(format!(
1396 "package cache entry for {source} at {commit} is missing; cannot fetch in offline mode"
1397 )
1398 .into());
1399 }
1400
1401 let parent = cache_dir
1402 .parent()
1403 .ok_or_else(|| format!("invalid cache path {}", cache_dir.display()))?;
1404 fs::create_dir_all(parent)
1405 .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
1406 let temp_dir = unique_temp_dir(parent, "tmp")?;
1407 let populated = (|| -> Result<String, PackageError> {
1408 clone_git_commit_to(url, commit, &temp_dir)?;
1409 let hash = compute_content_hash(&temp_dir)?;
1410 if let Some(expected) = expected_hash {
1411 if hash != expected {
1412 return Err(format!(
1413 "content hash mismatch for {} at {}: expected {}, got {}",
1414 source, commit, expected, hash
1415 )
1416 .into());
1417 }
1418 }
1419 write_cached_content_hash(&temp_dir, &hash)?;
1420 write_cache_metadata(&temp_dir, source, commit, &hash)?;
1421 fs::rename(&temp_dir, &cache_dir).map_err(|error| {
1422 format!(
1423 "failed to move {} to {}: {error}",
1424 temp_dir.display(),
1425 cache_dir.display()
1426 )
1427 })?;
1428 Ok(hash)
1429 })();
1430 let hash = match populated {
1431 Ok(hash) => hash,
1432 Err(error) => {
1433 let _ = fs::remove_dir_all(&temp_dir);
1434 return Err(error);
1435 }
1436 };
1437 Ok(hash)
1438}
1439
1440#[derive(Debug, Clone)]
1441pub(crate) struct PackageCacheEntry {
1442 path: PathBuf,
1443 source_hash: String,
1444 commit: String,
1445 metadata: Option<PackageCacheMetadata>,
1446}
1447
1448pub(crate) fn git_cache_root_in(workspace: &PackageWorkspace) -> Result<PathBuf, PackageError> {
1449 Ok(workspace.cache_root()?.join("git"))
1450}
1451
1452pub(crate) fn discover_git_cache_entries() -> Result<Vec<PackageCacheEntry>, PackageError> {
1453 discover_git_cache_entries_in(&PackageWorkspace::from_current_dir()?)
1454}
1455
1456pub(crate) fn discover_git_cache_entries_in(
1457 workspace: &PackageWorkspace,
1458) -> Result<Vec<PackageCacheEntry>, PackageError> {
1459 let root = git_cache_root_in(workspace)?;
1460 let mut entries = Vec::new();
1461 let source_dirs = match fs::read_dir(&root) {
1462 Ok(source_dirs) => source_dirs,
1463 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(entries),
1464 Err(error) => return Err(format!("failed to read {}: {error}", root.display()).into()),
1465 };
1466 for source_dir in source_dirs {
1467 let source_dir = source_dir
1468 .map_err(|error| format!("failed to read {} entry: {error}", root.display()))?;
1469 let source_type = source_dir
1470 .file_type()
1471 .map_err(|error| format!("failed to stat {}: {error}", source_dir.path().display()))?;
1472 if !source_type.is_dir() {
1473 continue;
1474 }
1475 let source_hash = source_dir.file_name().to_string_lossy().to_string();
1476 let commit_dirs = fs::read_dir(source_dir.path())
1477 .map_err(|error| format!("failed to read {}: {error}", source_dir.path().display()))?;
1478 for commit_dir in commit_dirs {
1479 let commit_dir = commit_dir.map_err(|error| {
1480 format!(
1481 "failed to read {} entry: {error}",
1482 source_dir.path().display()
1483 )
1484 })?;
1485 let commit_type = commit_dir.file_type().map_err(|error| {
1486 format!("failed to stat {}: {error}", commit_dir.path().display())
1487 })?;
1488 if !commit_type.is_dir() {
1489 continue;
1490 }
1491 let commit = commit_dir.file_name().to_string_lossy().to_string();
1492 if commit.starts_with("tmp-") || commit.ends_with(".full-clone") {
1493 continue;
1494 }
1495 let metadata = read_cache_metadata(&commit_dir.path())?;
1496 entries.push(PackageCacheEntry {
1497 path: commit_dir.path(),
1498 source_hash: source_hash.clone(),
1499 commit,
1500 metadata,
1501 });
1502 }
1503 }
1504 entries.sort_by(|left, right| {
1505 left.source_hash
1506 .cmp(&right.source_hash)
1507 .then_with(|| left.commit.cmp(&right.commit))
1508 });
1509 Ok(entries)
1510}
1511
1512pub(crate) fn locked_git_cache_paths_in(
1513 workspace: &PackageWorkspace,
1514 lock: &LockFile,
1515) -> Result<HashSet<PathBuf>, PackageError> {
1516 let mut keep = HashSet::new();
1517 for entry in &lock.packages {
1518 validate_package_alias(&entry.name)?;
1519 if !entry.source.starts_with("git+") {
1520 continue;
1521 }
1522 let commit = entry
1523 .commit
1524 .as_deref()
1525 .ok_or_else(|| format!("missing locked commit for {}", entry.name))?;
1526 keep.insert(git_cache_dir_in(workspace, &entry.source, commit)?);
1527 }
1528 Ok(keep)
1529}
1530
1531pub(crate) fn verify_lock_entry_cache_in(
1532 workspace: &PackageWorkspace,
1533 entry: &LockEntry,
1534) -> Result<bool, PackageError> {
1535 validate_package_alias(&entry.name)?;
1536 if !entry.source.starts_with("git+") {
1537 if entry.source.starts_with("path+") {
1538 let path = path_from_source_uri(&entry.source)?;
1539 if !path.exists() {
1540 return Err(format!(
1541 "path dependency {} source is missing: {}",
1542 entry.name,
1543 path.display()
1544 )
1545 .into());
1546 }
1547 }
1548 return Ok(false);
1549 }
1550 let commit = entry
1551 .commit
1552 .as_deref()
1553 .ok_or_else(|| format!("missing locked commit for {}", entry.name))?;
1554 let expected_hash = entry
1555 .content_hash
1556 .as_deref()
1557 .ok_or_else(|| format!("missing content hash for {}", entry.name))?;
1558 let cache_dir = git_cache_dir_in(workspace, &entry.source, commit)?;
1559 if !cache_dir.is_dir() {
1560 return Err(format!(
1561 "package cache entry for {} is missing: {}",
1562 entry.name,
1563 cache_dir.display()
1564 )
1565 .into());
1566 }
1567 verify_content_hash_or_compute(&cache_dir, expected_hash)?;
1568 match read_cache_metadata(&cache_dir)? {
1569 Some(metadata)
1570 if metadata.source == entry.source
1571 && metadata.commit == commit
1572 && metadata.content_hash == expected_hash => {}
1573 Some(metadata) => {
1574 return Err(format!(
1575 "package cache metadata mismatch for {}: expected {} {} {}, got {} {} {}",
1576 entry.name,
1577 entry.source,
1578 commit,
1579 expected_hash,
1580 metadata.source,
1581 metadata.commit,
1582 metadata.content_hash
1583 )
1584 .into());
1585 }
1586 None => write_cache_metadata(&cache_dir, &entry.source, commit, expected_hash)?,
1587 }
1588 Ok(true)
1589}
1590
1591pub(crate) fn verify_materialized_lock_entry(
1592 ctx: &ManifestContext,
1593 entry: &LockEntry,
1594) -> Result<bool, PackageError> {
1595 validate_package_alias(&entry.name)?;
1596 let packages_dir = ctx.packages_dir();
1597 if entry.source.starts_with("path+") {
1598 let dir = packages_dir.join(&entry.name);
1599 let file = packages_dir.join(format!("{}.harn", entry.name));
1600 if !dir.exists() && !file.exists() {
1601 return Err(format!(
1602 "materialized path dependency {} is missing under {}",
1603 entry.name,
1604 packages_dir.display()
1605 )
1606 .into());
1607 }
1608 return Ok(true);
1609 }
1610 if !entry.source.starts_with("git+") {
1611 return Ok(false);
1612 }
1613 let expected_hash = entry
1614 .content_hash
1615 .as_deref()
1616 .ok_or_else(|| format!("missing content hash for {}", entry.name))?;
1617 let dest_dir = packages_dir.join(&entry.name);
1618 if !dest_dir.is_dir() {
1619 return Err(format!(
1620 "materialized package {} is missing: {}",
1621 entry.name,
1622 dest_dir.display()
1623 )
1624 .into());
1625 }
1626 verify_content_hash_or_compute(&dest_dir, expected_hash)?;
1627 Ok(true)
1628}
1629
1630pub(crate) fn verify_package_cache_impl(materialized: bool) -> Result<usize, PackageError> {
1631 verify_package_cache_in(&PackageWorkspace::from_current_dir()?, materialized)
1632}
1633
1634pub(crate) fn verify_package_cache_in(
1635 workspace: &PackageWorkspace,
1636 materialized: bool,
1637) -> Result<usize, PackageError> {
1638 let ctx = workspace.load_manifest_context()?;
1639 let lock = LockFile::load(&ctx.lock_path())?
1640 .ok_or_else(|| format!("{} is missing", ctx.lock_path().display()))?;
1641 validate_lock_matches_manifest(workspace, &ctx, &lock)?;
1642 let mut verified = 0usize;
1643 for entry in &lock.packages {
1644 if verify_lock_entry_cache_in(workspace, entry)? {
1645 verified += 1;
1646 }
1647 if materialized && verify_materialized_lock_entry(&ctx, entry)? {
1648 verified += 1;
1649 }
1650 }
1651 Ok(verified)
1652}
1653
1654pub(crate) fn clean_package_cache_impl(all: bool) -> Result<usize, PackageError> {
1655 clean_package_cache_in(&PackageWorkspace::from_current_dir()?, all)
1656}
1657
1658pub(crate) fn clean_package_cache_in(
1659 workspace: &PackageWorkspace,
1660 all: bool,
1661) -> Result<usize, PackageError> {
1662 let entries = discover_git_cache_entries_in(workspace)?;
1663 if entries.is_empty() {
1664 return Ok(0);
1665 }
1666 if all {
1667 let root = workspace.cache_root()?;
1668 for child in ["git", "locks"] {
1669 let path = root.join(child);
1670 if path.exists() {
1671 fs::remove_dir_all(&path)
1672 .map_err(|error| format!("failed to remove {}: {error}", path.display()))?;
1673 }
1674 }
1675 return Ok(entries.len());
1676 }
1677
1678 let ctx = workspace.load_manifest_context()?;
1679 let lock = LockFile::load(&ctx.lock_path())?.ok_or_else(|| {
1680 format!(
1681 "{} is missing; pass --all to clean every cache entry",
1682 LOCK_FILE
1683 )
1684 })?;
1685 validate_lock_matches_manifest(workspace, &ctx, &lock)?;
1686 let keep = locked_git_cache_paths_in(workspace, &lock)?;
1687 let mut removed = 0usize;
1688 for entry in entries {
1689 if keep.contains(&entry.path) {
1690 continue;
1691 }
1692 fs::remove_dir_all(&entry.path)
1693 .map_err(|error| format!("failed to remove {}: {error}", entry.path.display()))?;
1694 removed += 1;
1695 if let Some(parent) = entry.path.parent() {
1696 let is_empty = fs::read_dir(parent)
1697 .map(|mut children| children.next().is_none())
1698 .unwrap_or(false);
1699 if is_empty {
1700 fs::remove_dir(parent)
1701 .map_err(|error| format!("failed to remove {}: {error}", parent.display()))?;
1702 }
1703 }
1704 }
1705 Ok(removed)
1706}
1707
1708pub fn list_package_cache() {
1709 let result = (|| -> Result<(PathBuf, Vec<PackageCacheEntry>), PackageError> {
1710 Ok((cache_root()?, discover_git_cache_entries()?))
1711 })();
1712
1713 match result {
1714 Ok((root, entries)) => {
1715 println!("Cache root: {}", root.display());
1716 if entries.is_empty() {
1717 println!("No cached git packages.");
1718 return;
1719 }
1720 println!("commit\tcontent_hash\tsource\tpath");
1721 for entry in entries {
1722 let (source, content_hash) = entry
1723 .metadata
1724 .as_ref()
1725 .map(|metadata| (metadata.source.as_str(), metadata.content_hash.as_str()))
1726 .unwrap_or(("(unknown)", "(unknown)"));
1727 println!(
1728 "{}\t{}\t{}\t{}",
1729 entry.commit,
1730 content_hash,
1731 source,
1732 entry.path.display()
1733 );
1734 }
1735 }
1736 Err(error) => {
1737 eprintln!("error: {error}");
1738 process::exit(1);
1739 }
1740 }
1741}
1742
1743pub fn clean_package_cache(all: bool) {
1744 match clean_package_cache_impl(all) {
1745 Ok(removed) => println!("Removed {removed} cached package entries."),
1746 Err(error) => {
1747 eprintln!("error: {error}");
1748 process::exit(1);
1749 }
1750 }
1751}
1752
1753pub fn verify_package_cache(materialized: bool) {
1754 match verify_package_cache_impl(materialized) {
1755 Ok(verified) => println!("Verified {verified} package cache entries."),
1756 Err(error) => {
1757 eprintln!("error: {error}");
1758 process::exit(1);
1759 }
1760 }
1761}
1762
1763pub fn search_package_registry(query: Option<&str>, registry: Option<&str>, json: bool) {
1764 match search_package_registry_impl(query, registry) {
1765 Ok(packages) if json => {
1766 println!(
1767 "{}",
1768 serde_json::to_string_pretty(&packages)
1769 .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#))
1770 );
1771 }
1772 Ok(packages) => {
1773 if packages.is_empty() {
1774 println!("No packages found.");
1775 return;
1776 }
1777 println!("name\tlatest\tharn\tcontract\tdescription");
1778 for package in packages {
1779 let latest = latest_registry_version(&package)
1780 .map(|version| version.version.as_str())
1781 .unwrap_or("-");
1782 println!(
1783 "{}\t{}\t{}\t{}\t{}",
1784 package.name,
1785 latest,
1786 package.harn.as_deref().unwrap_or("-"),
1787 package.connector_contract.as_deref().unwrap_or("-"),
1788 package.description.as_deref().unwrap_or("")
1789 );
1790 }
1791 }
1792 Err(error) => {
1793 eprintln!("error: {error}");
1794 process::exit(1);
1795 }
1796 }
1797}
1798
1799pub fn show_package_registry_info(spec: &str, registry: Option<&str>, json: bool) {
1800 match package_registry_info_impl(spec, registry) {
1801 Ok(info) if json => {
1802 println!(
1803 "{}",
1804 serde_json::to_string_pretty(&info)
1805 .unwrap_or_else(|error| format!(r#"{{"error":"{error}"}}"#))
1806 );
1807 }
1808 Ok(info) => {
1809 let package = info.package;
1810 println!("{}", package.name);
1811 if let Some(description) = package.description.as_deref() {
1812 println!("description: {description}");
1813 }
1814 println!("repository: {}", package.repository);
1815 if let Some(license) = package.license.as_deref() {
1816 println!("license: {license}");
1817 }
1818 if let Some(harn) = package.harn.as_deref() {
1819 println!("harn: {harn}");
1820 }
1821 if let Some(contract) = package.connector_contract.as_deref() {
1822 println!("connector_contract: {contract}");
1823 }
1824 if let Some(docs) = package.docs_url.as_deref() {
1825 println!("docs: {docs}");
1826 }
1827 if let Some(checksum) = package.checksum.as_deref() {
1828 println!("checksum: {checksum}");
1829 }
1830 if let Some(provenance) = package.provenance.as_deref() {
1831 println!("provenance: {provenance}");
1832 }
1833 if !package.exports.is_empty() {
1834 println!("exports: {}", package.exports.join(", "));
1835 }
1836 if let Some(version) = info.selected_version {
1837 println!("selected: {}", version.version);
1838 println!("git: {}", version.git);
1839 if let Some(rev) = version.rev.as_deref() {
1840 println!("rev: {rev}");
1841 }
1842 if let Some(branch) = version.branch.as_deref() {
1843 println!("branch: {branch}");
1844 }
1845 if let Some(package_name) = version.package.as_deref() {
1846 println!("package: {package_name}");
1847 }
1848 }
1849 if !package.versions.is_empty() {
1850 let versions = package
1851 .versions
1852 .iter()
1853 .map(|version| {
1854 if version.yanked {
1855 format!("{} (yanked)", version.version)
1856 } else {
1857 version.version.clone()
1858 }
1859 })
1860 .collect::<Vec<_>>()
1861 .join(", ");
1862 println!("versions: {versions}");
1863 }
1864 }
1865 Err(error) => {
1866 eprintln!("error: {error}");
1867 process::exit(1);
1868 }
1869 }
1870}
1871
1872#[cfg(test)]
1873mod tests {
1874 use super::*;
1875 use crate::package::test_support::*;
1876
1877 #[test]
1878 fn pick_ls_remote_commit_prefers_peeled_tag_over_tag_object() {
1879 let output = "\
1883963b6e8acfdf030a9b922bc5a73e010758ff47da\trefs/tags/v0.1.0\n\
1884bad580c5fbe8ede612b2748ad98606642ce2fc02\trefs/tags/v0.1.0^{}\n";
1885 assert_eq!(
1886 pick_ls_remote_commit(output),
1887 Some("bad580c5fbe8ede612b2748ad98606642ce2fc02"),
1888 );
1889 }
1890
1891 #[test]
1892 fn pick_ls_remote_commit_falls_back_to_first_match_for_lightweight_tags() {
1893 let output = "\
1894abc123abc123abc123abc123abc123abc1234567\trefs/tags/v0.0.1\n";
1895 assert_eq!(
1896 pick_ls_remote_commit(output),
1897 Some("abc123abc123abc123abc123abc123abc1234567"),
1898 );
1899 }
1900
1901 #[test]
1902 fn pick_ls_remote_commit_returns_none_on_empty_output() {
1903 assert_eq!(pick_ls_remote_commit(""), None);
1904 }
1905
1906 #[test]
1907 fn compute_content_hash_ignores_git_and_hash_marker() {
1908 let tmp = tempfile::tempdir().unwrap();
1909 let root = tmp.path();
1910 fs::create_dir_all(root.join(".git")).unwrap();
1911 fs::write(root.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
1912 fs::write(root.join(".gitignore"), "ignored\n").unwrap();
1913 fs::write(root.join(CONTENT_HASH_FILE), "stale\n").unwrap();
1914 fs::write(
1915 root.join("lib.harn"),
1916 "pub fn value() -> number { return 1 }\n",
1917 )
1918 .unwrap();
1919 let first = compute_content_hash(root).unwrap();
1920 fs::write(root.join(".git/HEAD"), "changed\n").unwrap();
1921 fs::write(root.join(".gitignore"), "changed\n").unwrap();
1922 fs::write(root.join(CONTENT_HASH_FILE), "changed\n").unwrap();
1923 let second = compute_content_hash(root).unwrap();
1924 assert_eq!(first, second);
1925 }
1926
1927 #[cfg(unix)]
1928 #[test]
1929 fn remove_materialized_package_unlinks_directory_symlink_without_touching_source() {
1930 let tmp = tempfile::tempdir().unwrap();
1931 let source = tmp.path().join("source");
1932 let packages = tmp.path().join(".harn/packages");
1933 fs::create_dir_all(&source).unwrap();
1934 fs::create_dir_all(&packages).unwrap();
1935 fs::write(
1936 source.join("lib.harn"),
1937 "pub fn value() -> number { return 1 }\n",
1938 )
1939 .unwrap();
1940
1941 let materialized = packages.join("acme");
1942 std::os::unix::fs::symlink(&source, &materialized).unwrap();
1943
1944 remove_materialized_package(&packages, "acme").unwrap();
1945
1946 assert!(!materialized.exists());
1947 assert!(source.join("lib.harn").is_file());
1948 }
1949
1950 #[test]
1951 fn package_cache_verify_detects_tampering_even_with_stale_marker() {
1952 let (_repo_tmp, repo, _branch) = create_git_package_repo();
1953 let project_tmp = tempfile::tempdir().unwrap();
1954 let root = project_tmp.path();
1955 let workspace = TestWorkspace::new(root);
1956 fs::create_dir_all(root.join(".git")).unwrap();
1957 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1958 fs::write(
1959 root.join(MANIFEST),
1960 format!(
1961 r#"
1962 [package]
1963 name = "workspace"
1964 version = "0.1.0"
1965
1966 [dependencies]
1967 acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
1968 "#
1969 ),
1970 )
1971 .unwrap();
1972
1973 install_packages_in(workspace.env(), false, None, false).unwrap();
1974 let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
1975 let entry = lock.find("acme-lib").unwrap();
1976 let cache_dir = git_cache_dir_in(
1977 workspace.env(),
1978 &entry.source,
1979 entry.commit.as_deref().unwrap(),
1980 )
1981 .unwrap();
1982 fs::write(
1983 cache_dir.join("lib.harn"),
1984 "pub fn value() { return \"pwned\" }\n",
1985 )
1986 .unwrap();
1987
1988 let error = verify_package_cache_in(workspace.env(), false).unwrap_err();
1989 assert!(error.to_string().contains("content hash mismatch"));
1990 }
1991
1992 #[test]
1993 fn package_cache_clean_all_removes_cached_git_entries() {
1994 let (_repo_tmp, repo, _branch) = create_git_package_repo();
1995 let project_tmp = tempfile::tempdir().unwrap();
1996 let root = project_tmp.path();
1997 let workspace = TestWorkspace::new(root);
1998 fs::create_dir_all(root.join(".git")).unwrap();
1999 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
2000 fs::write(
2001 root.join(MANIFEST),
2002 format!(
2003 r#"
2004 [package]
2005 name = "workspace"
2006 version = "0.1.0"
2007
2008 [dependencies]
2009 acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
2010 "#
2011 ),
2012 )
2013 .unwrap();
2014
2015 install_packages_in(workspace.env(), false, None, false).unwrap();
2016 assert_eq!(
2017 discover_git_cache_entries_in(workspace.env())
2018 .unwrap()
2019 .len(),
2020 1
2021 );
2022
2023 let removed = clean_package_cache_in(workspace.env(), true).unwrap();
2024 assert_eq!(removed, 1);
2025 assert!(discover_git_cache_entries_in(workspace.env())
2026 .unwrap()
2027 .is_empty());
2028 }
2029
2030 #[test]
2031 fn registry_index_search_and_info_use_local_file_without_network() {
2032 let (_repo_tmp, repo, _branch) = create_git_package_repo();
2033 let project_tmp = tempfile::tempdir().unwrap();
2034 let root = project_tmp.path();
2035 let workspace = TestWorkspace::new(root);
2036 let registry_path = root.join("index.toml");
2037 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
2038 write_package_registry_index(®istry_path, "@burin/acme-lib", &git, "acme-lib");
2039 fs::create_dir_all(root.join(".git")).unwrap();
2040 fs::write(
2041 root.join(MANIFEST),
2042 r#"
2043 [package]
2044 name = "workspace"
2045 version = "0.1.0"
2046 "#,
2047 )
2048 .unwrap();
2049
2050 let matches = search_package_registry_in(
2051 workspace.env(),
2052 Some("acme"),
2053 Some(registry_path.to_string_lossy().as_ref()),
2054 )
2055 .unwrap();
2056 assert_eq!(matches.len(), 1);
2057 assert_eq!(matches[0].name, "@burin/acme-lib");
2058 assert_eq!(
2059 matches[0].harn.as_deref(),
2060 Some(crate::package::current_harn_range_example().as_str())
2061 );
2062 assert_eq!(matches[0].connector_contract.as_deref(), Some("v1"));
2063 assert_eq!(matches[0].exports, vec!["lib"]);
2064
2065 let info = package_registry_info_in(
2066 workspace.env(),
2067 "@burin/acme-lib@1.0.0",
2068 Some(registry_path.to_string_lossy().as_ref()),
2069 )
2070 .unwrap();
2071 assert_eq!(info.package.license.as_deref(), Some("MIT OR Apache-2.0"));
2072 assert_eq!(
2073 info.selected_version
2074 .as_ref()
2075 .map(|version| version.git.as_str()),
2076 Some(git.as_str())
2077 );
2078 }
2079
2080 #[test]
2081 fn add_registry_dependency_preserves_provenance_in_manifest_and_lock() {
2082 let (_repo_tmp, repo, _branch) = create_git_package_repo();
2083 let project_tmp = tempfile::tempdir().unwrap();
2084 let root = project_tmp.path();
2085 let registry_path = root.join("index.toml");
2086 let workspace =
2087 TestWorkspace::new(root).with_registry_source(registry_path.display().to_string());
2088 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
2089 write_package_registry_index(®istry_path, "@burin/acme-lib", &git, "acme-lib");
2090 fs::create_dir_all(root.join(".git")).unwrap();
2091 fs::write(
2092 root.join(MANIFEST),
2093 r#"
2094 [package]
2095 name = "workspace"
2096 version = "0.1.0"
2097 "#,
2098 )
2099 .unwrap();
2100
2101 add_package_to(
2102 workspace.env(),
2103 "@burin/acme-lib@1.0.0",
2104 None,
2105 None,
2106 None,
2107 None,
2108 None,
2109 None,
2110 None,
2111 )
2112 .unwrap();
2113
2114 let manifest = fs::read_to_string(root.join(MANIFEST)).unwrap();
2115 assert!(
2116 manifest.contains(&format!("git = \"{git}\"")),
2117 "registry install must record the resolved git URL: {manifest}"
2118 );
2119 assert!(
2120 manifest.contains("tag = \"v1.0.0\""),
2121 "registry install must pin the resolved tag: {manifest}"
2122 );
2123 assert!(
2124 manifest.contains("registry_name = \"@burin/acme-lib\""),
2125 "registry install must preserve the registry-side package name: {manifest}"
2126 );
2127 assert!(
2128 manifest.contains("registry_version = \"1.0.0\""),
2129 "registry install must preserve the requested registry version: {manifest}"
2130 );
2131 let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
2132 let entry = lock.find("acme-lib").unwrap();
2133 assert_eq!(entry.source, format!("git+{git}"));
2134 let registry = entry
2135 .registry
2136 .as_ref()
2137 .expect("registry-added entry should carry registry provenance");
2138 assert_eq!(registry.name, "@burin/acme-lib");
2139 assert_eq!(registry.version, "1.0.0");
2140 assert!(root
2141 .join(PKG_DIR)
2142 .join("acme-lib")
2143 .join("lib.harn")
2144 .is_file());
2145 }
2146
2147 #[test]
2148 fn add_registry_dependency_accepts_bare_alias_and_semver_range() {
2149 let (_repo_tmp, repo, _branch) = create_git_package_repo();
2153 let project_tmp = tempfile::tempdir().unwrap();
2154 let root = project_tmp.path();
2155 let registry_path = root.join("index.toml");
2156 let workspace =
2157 TestWorkspace::new(root).with_registry_source(registry_path.display().to_string());
2158 let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
2159 write_package_registry_index(®istry_path, "@burin/acme-lib", &git, "acme-lib");
2160 fs::create_dir_all(root.join(".git")).unwrap();
2161 fs::write(
2162 root.join(MANIFEST),
2163 r#"
2164 [package]
2165 name = "workspace"
2166 version = "0.1.0"
2167 "#,
2168 )
2169 .unwrap();
2170
2171 add_package_to(
2173 workspace.env(),
2174 "acme-lib@^1",
2175 None,
2176 None,
2177 None,
2178 None,
2179 None,
2180 None,
2181 None,
2182 )
2183 .unwrap();
2184
2185 let manifest = fs::read_to_string(root.join(MANIFEST)).unwrap();
2186 assert!(
2187 manifest.contains("registry_name = \"@burin/acme-lib\""),
2188 "bare-alias add must record the canonical scoped registry name: {manifest}"
2189 );
2190 assert!(
2191 manifest.contains("registry_version = \"1.0.0\""),
2192 "semver range must resolve to the highest matching exact version: {manifest}"
2193 );
2194 }
2195
2196 #[test]
2197 fn registry_index_rejects_invalid_names_and_duplicate_versions() {
2198 let content = r#"
2199 version = 1
2200
2201 [[package]]
2202 name = "@bad/"
2203 repository = "https://github.com/acme/acme-lib"
2204
2205 [[package.version]]
2206 version = "1.0.0"
2207 git = "https://github.com/acme/acme-lib"
2208 rev = "v1.0.0"
2209 "#;
2210 let error = parse_package_registry_index("fixture", content).unwrap_err();
2211 assert!(error.to_string().contains("invalid package name"));
2212
2213 let content = r#"
2214 version = 1
2215
2216 [[package]]
2217 name = "@burin/acme-lib"
2218 repository = "https://github.com/acme/acme-lib"
2219
2220 [[package.version]]
2221 version = "1.0.0"
2222 git = "https://github.com/acme/acme-lib"
2223 rev = "v1.0.0"
2224
2225 [[package.version]]
2226 version = "1.0.0"
2227 git = "https://github.com/acme/acme-lib"
2228 rev = "v1.0.0"
2229 "#;
2230 let error = parse_package_registry_index("fixture", content).unwrap_err();
2231 assert!(error.to_string().contains("more than once"));
2232 }
2233}