Skip to main content

harn_cli/package/
registry.rs

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