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