Skip to main content

harn_cli/package/
registry.rs

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