Skip to main content

harn_cli/package/
lockfile.rs

1use super::errors::PackageError;
2use super::*;
3
4#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
5pub(crate) struct LockFile {
6    pub(crate) version: u32,
7    /// Harn CLI version that resolved this lockfile. Lets downstream
8    /// automation flag stale checkouts when the project bumps Harn.
9    #[serde(default = "current_generator_version")]
10    pub(crate) generator_version: String,
11    /// Protocol artifact contract version this resolver shipped against.
12    /// Pinning it in the lock means a host can detect when bindings
13    /// regenerated by a newer Harn would diverge from what is committed
14    /// downstream without running its own generator.
15    #[serde(default = "current_protocol_artifact_version")]
16    pub(crate) protocol_artifact_version: String,
17    #[serde(default, rename = "package")]
18    pub(crate) packages: Vec<LockEntry>,
19}
20
21impl Default for LockFile {
22    fn default() -> Self {
23        Self {
24            version: LOCK_FILE_VERSION,
25            generator_version: current_generator_version(),
26            protocol_artifact_version: current_protocol_artifact_version(),
27            packages: Vec::new(),
28        }
29    }
30}
31
32pub(crate) fn current_generator_version() -> String {
33    env!("CARGO_PKG_VERSION").to_string()
34}
35
36pub(crate) fn current_protocol_artifact_version() -> String {
37    env!("CARGO_PKG_VERSION").to_string()
38}
39
40#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
41pub(crate) struct LockEntry {
42    pub(crate) name: String,
43    pub(crate) source: String,
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub(crate) rev_request: Option<String>,
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub(crate) commit: Option<String>,
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub(crate) content_hash: Option<String>,
50    /// `[package].version` from the resolved package's manifest. Captured so
51    /// `harn package outdated` and `harn package audit` can compare without
52    /// reaching into `.harn/packages/`.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub(crate) package_version: Option<String>,
55    /// `[package].harn` compatibility range from the resolved package's
56    /// manifest. Used by audit to flag packages that no longer support the
57    /// current Harn line.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub(crate) harn_compat: Option<String>,
60    /// Package-authored provenance URL or identifier from `[package]`.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub(crate) provenance: Option<String>,
63    /// SHA-256 digest (`sha256:<hex>`) of the resolved package's
64    /// `harn.toml`, separate from the full-contents `content_hash`. Lets
65    /// audit detect manifest tampering without re-hashing the entire tree.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub(crate) manifest_digest: Option<String>,
68    /// Provenance for entries that were originally added through the
69    /// package registry index and lowered to a git source. Preserved so
70    /// `harn package outdated` can compare against the registry's latest
71    /// version.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub(crate) registry: Option<RegistryProvenance>,
74    #[serde(default, skip_serializing_if = "PackageLockExports::is_empty")]
75    pub(crate) exports: PackageLockExports,
76    #[serde(default, skip_serializing_if = "Vec::is_empty")]
77    pub(crate) permissions: Vec<String>,
78    #[serde(default, skip_serializing_if = "Vec::is_empty")]
79    pub(crate) host_requirements: Vec<String>,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub(crate) struct RegistryProvenance {
84    pub(crate) source: String,
85    pub(crate) name: String,
86    pub(crate) version: String,
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub(crate) provenance_url: Option<String>,
89}
90
91#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
92pub struct PackageLockExports {
93    #[serde(default, skip_serializing_if = "Vec::is_empty")]
94    pub modules: Vec<PackageLockExport>,
95    #[serde(default, skip_serializing_if = "Vec::is_empty")]
96    pub tools: Vec<PackageLockExport>,
97    #[serde(default, skip_serializing_if = "Vec::is_empty")]
98    pub skills: Vec<PackageLockExport>,
99    #[serde(default, skip_serializing_if = "Vec::is_empty")]
100    pub personas: Vec<String>,
101}
102
103impl PackageLockExports {
104    pub(crate) fn is_empty(&self) -> bool {
105        self.modules.is_empty()
106            && self.tools.is_empty()
107            && self.skills.is_empty()
108            && self.personas.is_empty()
109    }
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113pub struct PackageLockExport {
114    pub name: String,
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub path: Option<String>,
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub symbol: Option<String>,
119}
120
121impl LockFile {
122    pub(crate) fn load(path: &Path) -> Result<Option<Self>, PackageError> {
123        let content = match fs::read_to_string(path) {
124            Ok(s) => s,
125            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
126            Err(error) => return Err(format!("failed to read {}: {error}", path.display()).into()),
127        };
128
129        // Peek at the version field so older lock formats migrate cleanly
130        // even when their schema is otherwise compatible with the current
131        // structs (e.g. v1 → v2 only added optional fields).
132        let raw_version = toml::from_str::<RawVersionedFile>(&content)
133            .ok()
134            .map(|raw| raw.version);
135
136        match raw_version {
137            Some(LOCK_FILE_VERSION) => {
138                let mut lock: Self = toml::from_str(&content)
139                    .map_err(|error| format!("failed to parse {}: {error}", path.display()))?;
140                lock.sort_entries();
141                Ok(Some(lock))
142            }
143            Some(1 | 2) => {
144                // Older lockfile versions load through the current struct
145                // because added fields are optional. Saving stamps the current
146                // version and enriches provenance on the next install.
147                let mut lock: Self = toml::from_str(&content)
148                    .map_err(|error| format!("failed to parse {}: {error}", path.display()))?;
149                lock.version = LOCK_FILE_VERSION;
150                lock.sort_entries();
151                Ok(Some(lock))
152            }
153            Some(other) => Err(format!(
154                "unsupported {} version {} (expected {})",
155                path.display(),
156                other,
157                LOCK_FILE_VERSION
158            )
159            .into()),
160            None => {
161                let legacy = toml::from_str::<LegacyLockFile>(&content)
162                    .map_err(|error| format!("failed to parse {}: {error}", path.display()))?;
163                let mut lock = Self {
164                    version: LOCK_FILE_VERSION,
165                    generator_version: current_generator_version(),
166                    protocol_artifact_version: current_protocol_artifact_version(),
167                    packages: legacy
168                        .packages
169                        .into_iter()
170                        .map(|entry| LockEntry {
171                            name: entry.name,
172                            source: entry
173                                .path
174                                .map(|path| format!("path+{path}"))
175                                .or_else(|| entry.git.map(|git| format!("git+{git}")))
176                                .unwrap_or_default(),
177                            rev_request: entry.rev_request.or(entry.tag),
178                            commit: entry.commit,
179                            content_hash: None,
180                            package_version: None,
181                            harn_compat: None,
182                            provenance: None,
183                            manifest_digest: None,
184                            registry: None,
185                            exports: PackageLockExports::default(),
186                            permissions: Vec::new(),
187                            host_requirements: Vec::new(),
188                        })
189                        .collect(),
190                };
191                lock.sort_entries();
192                Ok(Some(lock))
193            }
194        }
195    }
196
197    fn save(&self, path: &Path) -> Result<(), PackageError> {
198        let mut normalized = self.clone();
199        normalized.version = LOCK_FILE_VERSION;
200        normalized.generator_version = current_generator_version();
201        normalized.protocol_artifact_version = current_protocol_artifact_version();
202        normalized.sort_entries();
203        let body = toml::to_string_pretty(&normalized)
204            .map_err(|error| format!("failed to encode {}: {error}", path.display()))?;
205        let mut out = String::from("# This file is auto-generated by Harn. Do not edit.\n\n");
206        out.push_str(&body);
207        harn_vm::atomic_io::atomic_write(path, out.as_bytes()).map_err(|error| {
208            PackageError::Lockfile(format!("failed to write {}: {error}", path.display()))
209        })
210    }
211
212    pub(crate) fn sort_entries(&mut self) {
213        self.packages
214            .sort_by(|left, right| left.name.cmp(&right.name));
215    }
216
217    pub(crate) fn find(&self, name: &str) -> Option<&LockEntry> {
218        self.packages.iter().find(|entry| entry.name == name)
219    }
220
221    fn replace(&mut self, entry: LockEntry) {
222        if let Some(existing) = self.packages.iter_mut().find(|pkg| pkg.name == entry.name) {
223            *existing = entry;
224        } else {
225            self.packages.push(entry);
226        }
227        self.sort_entries();
228    }
229
230    fn remove(&mut self, name: &str) {
231        self.packages.retain(|entry| entry.name != name);
232    }
233}
234
235#[derive(Debug, Deserialize)]
236struct RawVersionedFile {
237    version: u32,
238}
239
240#[derive(Debug, Deserialize)]
241pub(crate) struct LegacyLockFile {
242    #[serde(default, rename = "package")]
243    packages: Vec<LegacyLockEntry>,
244}
245
246#[derive(Debug, Deserialize)]
247pub(crate) struct LegacyLockEntry {
248    pub(crate) name: String,
249    #[serde(default)]
250    git: Option<String>,
251    #[serde(default)]
252    tag: Option<String>,
253    #[serde(default)]
254    pub(crate) rev_request: Option<String>,
255    #[serde(default)]
256    pub(crate) commit: Option<String>,
257    #[serde(default)]
258    path: Option<String>,
259}
260
261pub(crate) fn compatible_locked_entry(
262    alias: &str,
263    dependency: &Dependency,
264    lock: &LockEntry,
265    manifest_dir: &Path,
266) -> Result<bool, PackageError> {
267    if lock.name != alias {
268        return Ok(false);
269    }
270    if let Some(path) = dependency.local_path() {
271        let source = path_source_uri(&resolve_path_dependency_source(manifest_dir, path)?)?;
272        return Ok(lock.source == source);
273    }
274    if let Some(url) = dependency.git_url() {
275        let source = format!("git+{}", normalize_git_url(url)?);
276        let requested = dependency
277            .branch()
278            .map(str::to_string)
279            .or_else(|| dependency.rev().map(str::to_string));
280        return Ok(lock.source == source
281            && lock.rev_request == requested
282            && lock.commit.is_some()
283            && lock.content_hash.is_some());
284    }
285    Ok(false)
286}
287
288#[derive(Debug, Clone)]
289pub(crate) struct PendingDependency {
290    alias: String,
291    dependency: Dependency,
292    manifest_dir: PathBuf,
293    parent: Option<String>,
294    parent_is_git: bool,
295}
296
297pub(crate) fn git_rev_request(
298    alias: &str,
299    dependency: &Dependency,
300) -> Result<String, PackageError> {
301    dependency
302        .branch()
303        .or_else(|| dependency.rev())
304        .map(str::to_string)
305        .ok_or_else(|| {
306            PackageError::Lockfile(format!(
307                "git dependency {alias} must specify `rev` or `branch`; use `harn add <url>@<tag-or-sha>` or add `rev = \"...\"` to {MANIFEST}"
308            ))
309        })
310}
311
312pub(crate) fn dependency_manifest_dir(source: &Path) -> Option<PathBuf> {
313    if source.is_dir() {
314        return Some(source.to_path_buf());
315    }
316    source.parent().map(Path::to_path_buf)
317}
318
319pub(crate) fn read_package_manifest_from_dir(dir: &Path) -> Result<Option<Manifest>, PackageError> {
320    let manifest_path = dir.join(MANIFEST);
321    if !manifest_path.exists() {
322        return Ok(None);
323    }
324    read_manifest_from_path(&manifest_path).map(Some)
325}
326
327/// Provenance pulled from a resolved package's manifest. Used to enrich a
328/// `LockEntry` so audit/outdated reports stay self-contained.
329#[derive(Debug, Clone, Default)]
330pub(crate) struct LockEntryProvenance {
331    pub(crate) package_version: Option<String>,
332    pub(crate) harn_compat: Option<String>,
333    pub(crate) provenance: Option<String>,
334    pub(crate) manifest_digest: Option<String>,
335    pub(crate) exports: PackageLockExports,
336    pub(crate) permissions: Vec<String>,
337    pub(crate) host_requirements: Vec<String>,
338}
339
340pub(crate) fn read_lock_entry_provenance(
341    package_dir: &Path,
342) -> Result<LockEntryProvenance, PackageError> {
343    let manifest_path = package_dir.join(MANIFEST);
344    if !manifest_path.exists() {
345        return Ok(LockEntryProvenance::default());
346    }
347    let bytes = fs::read(&manifest_path)
348        .map_err(|error| format!("failed to read {}: {error}", manifest_path.display()))?;
349    let digest = format!("sha256:{}", sha256_hex(&bytes));
350    let manifest = read_manifest_from_path(&manifest_path)?;
351    let (package_version, harn_compat, provenance, permissions, host_requirements) = manifest
352        .package
353        .as_ref()
354        .map(|info| {
355            (
356                info.version.clone(),
357                info.harn.clone(),
358                info.provenance.clone(),
359                info.permissions.clone(),
360                info.host_requirements.clone(),
361            )
362        })
363        .unwrap_or((None, None, None, Vec::new(), Vec::new()));
364    Ok(LockEntryProvenance {
365        package_version,
366        harn_compat,
367        provenance,
368        manifest_digest: Some(digest),
369        exports: package_lock_exports_from_manifest(&manifest),
370        permissions: normalized_requirements(&permissions),
371        host_requirements: normalized_requirements(&host_requirements),
372    })
373}
374
375fn fill_provenance(entry: &mut LockEntry, provenance: LockEntryProvenance) {
376    entry.package_version = provenance.package_version;
377    entry.harn_compat = provenance.harn_compat;
378    entry.provenance = provenance.provenance;
379    entry.manifest_digest = provenance.manifest_digest;
380    entry.exports = provenance.exports;
381    entry.permissions = provenance.permissions;
382    entry.host_requirements = provenance.host_requirements;
383}
384
385pub(crate) fn package_lock_exports_from_manifest(manifest: &Manifest) -> PackageLockExports {
386    let mut modules: Vec<PackageLockExport> = manifest
387        .exports
388        .iter()
389        .map(|(name, path)| PackageLockExport {
390            name: name.clone(),
391            path: Some(path.clone()),
392            symbol: None,
393        })
394        .collect();
395    modules.sort_by(|left, right| left.name.cmp(&right.name));
396
397    let (mut tools, mut skills) = manifest
398        .package
399        .as_ref()
400        .map(|package| {
401            let tools = package
402                .tools
403                .iter()
404                .map(|tool| PackageLockExport {
405                    name: tool.name.clone(),
406                    path: Some(tool.module.clone()),
407                    symbol: Some(tool.symbol.clone()),
408                })
409                .collect::<Vec<_>>();
410            let skills = package
411                .skills
412                .iter()
413                .map(|skill| PackageLockExport {
414                    name: skill.name.clone(),
415                    path: Some(skill.path.clone()),
416                    symbol: None,
417                })
418                .collect::<Vec<_>>();
419            (tools, skills)
420        })
421        .unwrap_or_default();
422    tools.sort_by(|left, right| left.name.cmp(&right.name));
423    skills.sort_by(|left, right| left.name.cmp(&right.name));
424
425    let mut personas: Vec<String> = manifest
426        .personas
427        .iter()
428        .filter_map(|persona| persona.name.clone())
429        .collect();
430    personas.sort();
431    personas.dedup();
432
433    PackageLockExports {
434        modules,
435        tools,
436        skills,
437        personas,
438    }
439}
440
441pub(crate) fn normalized_requirements(values: &[String]) -> Vec<String> {
442    let mut out: Vec<String> = values
443        .iter()
444        .map(|value| value.trim())
445        .filter(|value| !value.is_empty())
446        .map(str::to_string)
447        .collect();
448    out.sort();
449    out.dedup();
450    out
451}
452
453pub(crate) fn dependency_conflict_message(
454    existing: &LockEntry,
455    candidate: &LockEntry,
456) -> PackageError {
457    PackageError::Lockfile(format!(
458        "dependency alias '{}' resolves to multiple packages ({} and {}); use distinct aliases in {MANIFEST}",
459        candidate.name, existing.source, candidate.source
460    ))
461}
462
463pub(crate) fn replace_lock_entry(
464    lock: &mut LockFile,
465    candidate: LockEntry,
466) -> Result<bool, PackageError> {
467    validate_package_alias(&candidate.name)?;
468    if let Some(existing) = lock.find(&candidate.name) {
469        if existing == &candidate {
470            return Ok(false);
471        }
472        return Err(dependency_conflict_message(existing, &candidate));
473    }
474    lock.replace(candidate);
475    Ok(true)
476}
477
478pub(crate) fn enqueue_manifest_dependencies(
479    pending: &mut Vec<PendingDependency>,
480    manifest: Manifest,
481    manifest_dir: PathBuf,
482    parent: String,
483    parent_is_git: bool,
484) {
485    let mut aliases: Vec<String> = manifest.dependencies.keys().cloned().collect();
486    aliases.sort();
487    for alias in aliases.into_iter().rev() {
488        if let Some(dependency) = manifest.dependencies.get(&alias).cloned() {
489            pending.push(PendingDependency {
490                alias,
491                dependency,
492                manifest_dir: manifest_dir.clone(),
493                parent: Some(parent.clone()),
494                parent_is_git,
495            });
496        }
497    }
498}
499
500pub(crate) fn build_lockfile(
501    workspace: &PackageWorkspace,
502    ctx: &ManifestContext,
503    existing: Option<&LockFile>,
504    refresh_alias: Option<&str>,
505    refresh_all: bool,
506    allow_resolve: bool,
507    offline: bool,
508) -> Result<LockFile, PackageError> {
509    if manifest_has_git_dependencies(&ctx.manifest) {
510        ensure_git_available()?;
511    }
512
513    let mut lock = LockFile::default();
514    let mut pending: Vec<PendingDependency> = Vec::new();
515    let mut aliases: Vec<String> = ctx.manifest.dependencies.keys().cloned().collect();
516    aliases.sort();
517    for alias in aliases.into_iter().rev() {
518        let dependency = ctx
519            .manifest
520            .dependencies
521            .get(&alias)
522            .ok_or_else(|| format!("dependency {alias} disappeared while locking"))?
523            .clone();
524        pending.push(PendingDependency {
525            alias,
526            dependency,
527            manifest_dir: ctx.dir.clone(),
528            parent: None,
529            parent_is_git: false,
530        });
531    }
532
533    while let Some(next) = pending.pop() {
534        let alias = next.alias;
535        validate_package_alias(&alias)?;
536        let dependency = next.dependency;
537        if dependency.local_path().is_some() && next.parent_is_git {
538            let parent = next.parent.as_deref().unwrap_or("a git package");
539            return Err(format!(
540                "package {parent} declares local path dependency {alias}, but path dependencies are not supported inside git-installed packages; publish {alias} as a git dependency with `rev` or `branch`"
541            ).into());
542        }
543        if dependency.git_url().is_some() {
544            ensure_git_available()?;
545            git_rev_request(&alias, &dependency)?;
546        }
547        let refresh = refresh_all || refresh_alias == Some(alias.as_str());
548        if let Some(existing_lock) = existing.and_then(|lock| lock.find(&alias)) {
549            if !refresh
550                && compatible_locked_entry(&alias, &dependency, existing_lock, &next.manifest_dir)?
551            {
552                let mut entry = existing_lock.clone();
553                if entry.source.starts_with("git+") && entry.content_hash.is_none() {
554                    let url = entry.source.trim_start_matches("git+");
555                    let commit = entry
556                        .commit
557                        .as_deref()
558                        .ok_or_else(|| format!("missing locked commit for {alias}"))?;
559                    entry.content_hash = Some(ensure_git_cache_populated_in(
560                        workspace,
561                        url,
562                        &entry.source,
563                        commit,
564                        None,
565                        false,
566                        offline,
567                    )?);
568                }
569                if entry.source.starts_with("git+") {
570                    let url = entry.source.trim_start_matches("git+");
571                    let commit = entry
572                        .commit
573                        .as_deref()
574                        .ok_or_else(|| format!("missing locked commit for {alias}"))?;
575                    let expected_hash = entry
576                        .content_hash
577                        .as_deref()
578                        .ok_or_else(|| format!("missing content hash for {alias}"))?;
579                    ensure_git_cache_populated_in(
580                        workspace,
581                        url,
582                        &entry.source,
583                        commit,
584                        Some(expected_hash),
585                        false,
586                        offline,
587                    )?;
588                    let cache_dir = git_cache_dir_in(workspace, &entry.source, commit)?;
589                    if entry.manifest_digest.is_none()
590                        || entry.package_version.is_none()
591                        || entry.provenance.is_none()
592                    {
593                        fill_provenance(&mut entry, read_lock_entry_provenance(&cache_dir)?);
594                    }
595                    if entry.registry.is_none() {
596                        entry.registry = dependency.registry_provenance();
597                    }
598                    let inserted = replace_lock_entry(&mut lock, entry.clone())?;
599                    if inserted {
600                        if let Some(manifest) = read_package_manifest_from_dir(&cache_dir)? {
601                            enqueue_manifest_dependencies(
602                                &mut pending,
603                                manifest,
604                                cache_dir,
605                                alias,
606                                true,
607                            );
608                        }
609                    }
610                } else if entry.source.starts_with("path+") {
611                    let source = path_from_source_uri(&entry.source)?;
612                    let manifest_dir = dependency_manifest_dir(&source);
613                    if entry.manifest_digest.is_none()
614                        || entry.package_version.is_none()
615                        || entry.provenance.is_none()
616                    {
617                        if let Some(dir) = manifest_dir.as_deref() {
618                            fill_provenance(&mut entry, read_lock_entry_provenance(dir)?);
619                        }
620                    }
621                    let inserted = replace_lock_entry(&mut lock, entry.clone())?;
622                    if inserted {
623                        if let Some(manifest_dir) = manifest_dir {
624                            if let Some(manifest) = read_package_manifest_from_dir(&manifest_dir)? {
625                                enqueue_manifest_dependencies(
626                                    &mut pending,
627                                    manifest,
628                                    manifest_dir,
629                                    alias,
630                                    false,
631                                );
632                            }
633                        }
634                    }
635                } else {
636                    replace_lock_entry(&mut lock, entry)?;
637                }
638                continue;
639            }
640        }
641
642        if !allow_resolve {
643            return Err(format!("{} would need to change", ctx.lock_path().display()).into());
644        }
645
646        if let Some(path) = dependency.local_path() {
647            let source = resolve_path_dependency_source(&next.manifest_dir, path)?;
648            let package_alias = alias.clone();
649            let manifest_dir = dependency_manifest_dir(&source);
650            let provenance = manifest_dir
651                .as_deref()
652                .map(read_lock_entry_provenance)
653                .transpose()?
654                .unwrap_or_default();
655            let mut entry = LockEntry {
656                name: alias.clone(),
657                source: path_source_uri(&source)?,
658                rev_request: None,
659                commit: None,
660                content_hash: None,
661                package_version: None,
662                harn_compat: None,
663                provenance: None,
664                manifest_digest: None,
665                registry: None,
666                exports: PackageLockExports::default(),
667                permissions: Vec::new(),
668                host_requirements: Vec::new(),
669            };
670            fill_provenance(&mut entry, provenance);
671            let inserted = replace_lock_entry(&mut lock, entry)?;
672            if inserted {
673                if let Some(manifest_dir) = manifest_dir {
674                    if let Some(manifest) = read_package_manifest_from_dir(&manifest_dir)? {
675                        enqueue_manifest_dependencies(
676                            &mut pending,
677                            manifest,
678                            manifest_dir,
679                            package_alias,
680                            false,
681                        );
682                    }
683                }
684            }
685            continue;
686        }
687
688        if let Some(url) = dependency.git_url() {
689            let rev_request = git_rev_request(&alias, &dependency)?;
690            let normalized_url = normalize_git_url(url)?;
691            let source = format!("git+{normalized_url}");
692            let commit =
693                resolve_git_commit(&normalized_url, dependency.rev(), dependency.branch())?;
694            let content_hash = ensure_git_cache_populated_in(
695                workspace,
696                &normalized_url,
697                &source,
698                &commit,
699                None,
700                false,
701                offline,
702            )?;
703            let cache_dir = git_cache_dir_in(workspace, &source, &commit)?;
704            let provenance = read_lock_entry_provenance(&cache_dir)?;
705            let mut entry = LockEntry {
706                name: alias.clone(),
707                source: source.clone(),
708                rev_request: Some(rev_request),
709                commit: Some(commit.clone()),
710                content_hash: Some(content_hash),
711                package_version: None,
712                harn_compat: None,
713                provenance: None,
714                manifest_digest: None,
715                registry: dependency.registry_provenance(),
716                exports: PackageLockExports::default(),
717                permissions: Vec::new(),
718                host_requirements: Vec::new(),
719            };
720            fill_provenance(&mut entry, provenance);
721            let inserted = replace_lock_entry(&mut lock, entry)?;
722            if inserted {
723                if let Some(manifest) = read_package_manifest_from_dir(&cache_dir)? {
724                    enqueue_manifest_dependencies(&mut pending, manifest, cache_dir, alias, true);
725                }
726            }
727            continue;
728        }
729
730        return Err(format!("dependency {alias} is missing a git or path source").into());
731    }
732    Ok(lock)
733}
734
735pub(crate) fn materialize_dependencies_from_lock(
736    workspace: &PackageWorkspace,
737    ctx: &ManifestContext,
738    lock: &LockFile,
739    refetch: Option<&str>,
740    offline: bool,
741) -> Result<usize, PackageError> {
742    let packages_dir = ctx.packages_dir();
743    fs::create_dir_all(&packages_dir)
744        .map_err(|error| format!("failed to create {}: {error}", packages_dir.display()))?;
745
746    let mut installed = 0usize;
747    for entry in &lock.packages {
748        let alias = &entry.name;
749        validate_package_alias(alias)?;
750        if entry.source.starts_with("path+") {
751            let source = path_from_source_uri(&entry.source)?;
752            materialize_path_dependency(&source, &packages_dir, alias)?;
753            installed += 1;
754            continue;
755        }
756
757        let commit = entry
758            .commit
759            .as_deref()
760            .ok_or_else(|| format!("missing locked commit for {alias}"))?;
761        let expected_hash = entry
762            .content_hash
763            .as_deref()
764            .ok_or_else(|| format!("missing content hash for {alias}"))?;
765        let source = entry.source.clone();
766        let url = source.trim_start_matches("git+");
767        let refetch_this = refetch == Some("all") || refetch == Some(alias.as_str());
768        ensure_git_cache_populated_in(
769            workspace,
770            url,
771            &source,
772            commit,
773            Some(expected_hash),
774            refetch_this,
775            offline,
776        )?;
777        let cache_dir = git_cache_dir_in(workspace, &source, commit)?;
778        let dest_dir = packages_dir.join(alias);
779        if !dest_dir.exists() || !materialized_hash_matches(&dest_dir, expected_hash) {
780            remove_materialized_package(&packages_dir, alias)?;
781            copy_dir_recursive(&cache_dir, &dest_dir)?;
782            write_cached_content_hash(&dest_dir, expected_hash)?;
783        }
784        installed += 1;
785    }
786    Ok(installed)
787}
788
789pub(crate) fn validate_lock_matches_manifest(
790    ctx: &ManifestContext,
791    lock: &LockFile,
792) -> Result<(), PackageError> {
793    for (alias, dependency) in &ctx.manifest.dependencies {
794        validate_package_alias(alias)?;
795        let entry = lock.find(alias).ok_or_else(|| {
796            format!(
797                "{} is missing an entry for {alias}",
798                ctx.lock_path().display()
799            )
800        })?;
801        if !compatible_locked_entry(alias, dependency, entry, &ctx.dir)? {
802            return Err(format!(
803                "{} is out of date for {alias}; run `harn install`",
804                ctx.lock_path().display()
805            )
806            .into());
807        }
808    }
809    Ok(())
810}
811
812pub fn ensure_dependencies_materialized(anchor: &Path) -> Result<(), PackageError> {
813    let Some((manifest, dir)) = find_nearest_manifest(anchor) else {
814        return Ok(());
815    };
816    if manifest.dependencies.is_empty() {
817        return Ok(());
818    }
819    let ctx = ManifestContext { manifest, dir };
820    let lock = LockFile::load(&ctx.lock_path())?.ok_or_else(|| {
821        format!(
822            "{} is missing; run `harn install`",
823            ctx.lock_path().display()
824        )
825    })?;
826    validate_lock_matches_manifest(&ctx, &lock)?;
827    let workspace = PackageWorkspace::from_current_dir()?;
828    materialize_dependencies_from_lock(&workspace, &ctx, &lock, None, false)?;
829    Ok(())
830}
831
832pub(crate) fn dependency_section_bounds(lines: &[String]) -> Option<(usize, usize)> {
833    let start = lines
834        .iter()
835        .position(|line| line.trim() == "[dependencies]")?;
836    let end = lines
837        .iter()
838        .enumerate()
839        .skip(start + 1)
840        .find(|(_, line)| line.trim_start().starts_with('['))
841        .map(|(index, _)| index)
842        .unwrap_or(lines.len());
843    Some((start, end))
844}
845
846pub(crate) fn render_dependency_line(
847    alias: &str,
848    dependency: &Dependency,
849) -> Result<String, PackageError> {
850    validate_package_alias(alias)?;
851    match dependency {
852        Dependency::Path(path) => Ok(format!(
853            "{alias} = {{ path = {} }}",
854            toml_string_literal(path)?
855        )),
856        Dependency::Table(table) => {
857            let mut fields = Vec::new();
858            if let Some(path) = table.path.as_deref() {
859                fields.push(format!("path = {}", toml_string_literal(path)?));
860            }
861            if let Some(git) = table.git.as_deref() {
862                fields.push(format!("git = {}", toml_string_literal(git)?));
863            }
864            if let Some(branch) = table.branch.as_deref() {
865                fields.push(format!("branch = {}", toml_string_literal(branch)?));
866            } else if let Some(rev) = table.rev.as_deref().or(table.tag.as_deref()) {
867                fields.push(format!("rev = {}", toml_string_literal(rev)?));
868            }
869            if let Some(package) = table.package.as_deref() {
870                fields.push(format!("package = {}", toml_string_literal(package)?));
871            }
872            if let Some(registry) = table.registry.as_deref() {
873                fields.push(format!("registry = {}", toml_string_literal(registry)?));
874            }
875            if let Some(name) = table.registry_name.as_deref() {
876                fields.push(format!("registry_name = {}", toml_string_literal(name)?));
877            }
878            if let Some(version) = table.registry_version.as_deref() {
879                fields.push(format!(
880                    "registry_version = {}",
881                    toml_string_literal(version)?
882                ));
883            }
884            Ok(format!("{alias} = {{ {} }}", fields.join(", ")))
885        }
886    }
887}
888
889pub(crate) fn ensure_manifest_exists(manifest_path: &Path) -> Result<String, PackageError> {
890    if manifest_path.exists() {
891        return fs::read_to_string(manifest_path).map_err(|error| {
892            PackageError::Lockfile(format!(
893                "failed to read {}: {error}",
894                manifest_path.display()
895            ))
896        });
897    }
898    Ok("[package]\nname = \"my-project\"\nversion = \"0.1.0\"\n".to_string())
899}
900
901pub(crate) fn upsert_dependency_in_manifest(
902    manifest_path: &Path,
903    alias: &str,
904    dependency: &Dependency,
905) -> Result<(), PackageError> {
906    let content = ensure_manifest_exists(manifest_path)?;
907    let mut lines: Vec<String> = content.lines().map(|line| line.to_string()).collect();
908    if dependency_section_bounds(&lines).is_none() {
909        if !lines.is_empty() && !lines.last().is_some_and(|line| line.is_empty()) {
910            lines.push(String::new());
911        }
912        lines.push("[dependencies]".to_string());
913    }
914    let (start, end) = dependency_section_bounds(&lines).ok_or_else(|| {
915        format!(
916            "failed to locate [dependencies] in {}",
917            manifest_path.display()
918        )
919    })?;
920    let rendered = render_dependency_line(alias, dependency)?;
921    if let Some((index, _)) = lines
922        .iter()
923        .enumerate()
924        .skip(start + 1)
925        .take(end - start - 1)
926        .find(|(_, line)| {
927            line.split('=')
928                .next()
929                .is_some_and(|key| key.trim() == alias)
930        })
931    {
932        lines[index] = rendered;
933    } else {
934        lines.insert(end, rendered);
935    }
936    write_manifest_content(manifest_path, &(lines.join("\n") + "\n"))
937}
938
939pub(crate) fn remove_dependency_from_manifest(
940    manifest_path: &Path,
941    alias: &str,
942) -> Result<bool, PackageError> {
943    let content = fs::read_to_string(manifest_path)
944        .map_err(|error| format!("failed to read {}: {error}", manifest_path.display()))?;
945    let mut lines: Vec<String> = content.lines().map(|line| line.to_string()).collect();
946    let Some((start, end)) = dependency_section_bounds(&lines) else {
947        return Ok(false);
948    };
949    let mut removed = false;
950    lines = lines
951        .into_iter()
952        .enumerate()
953        .filter_map(|(index, line)| {
954            if index <= start || index >= end {
955                return Some(line);
956            }
957            let matches = line
958                .split('=')
959                .next()
960                .is_some_and(|key| key.trim() == alias);
961            if matches {
962                removed = true;
963                None
964            } else {
965                Some(line)
966            }
967        })
968        .collect();
969    if removed {
970        write_manifest_content(manifest_path, &(lines.join("\n") + "\n"))?;
971    }
972    Ok(removed)
973}
974
975pub(crate) fn install_packages_impl(
976    frozen: bool,
977    refetch: Option<&str>,
978    offline: bool,
979) -> Result<usize, PackageError> {
980    install_packages_in(
981        &PackageWorkspace::from_current_dir()?,
982        frozen,
983        refetch,
984        offline,
985    )
986}
987
988pub(crate) fn install_packages_in(
989    workspace: &PackageWorkspace,
990    frozen: bool,
991    refetch: Option<&str>,
992    offline: bool,
993) -> Result<usize, PackageError> {
994    let ctx = workspace.load_manifest_context()?;
995    let existing = LockFile::load(&ctx.lock_path())?;
996    if ctx.manifest.dependencies.is_empty() {
997        if !frozen {
998            LockFile::default().save(&ctx.lock_path())?;
999        }
1000        return Ok(0);
1001    }
1002
1003    if (frozen || offline) && existing.is_none() {
1004        return Err(format!("{} is missing", ctx.lock_path().display()).into());
1005    }
1006
1007    let desired = build_lockfile(
1008        workspace,
1009        &ctx,
1010        existing.as_ref(),
1011        None,
1012        false,
1013        !frozen && !offline,
1014        offline,
1015    )?;
1016    if frozen || offline {
1017        if existing.as_ref() != Some(&desired) {
1018            return Err(format!("{} would need to change", ctx.lock_path().display()).into());
1019        }
1020    } else {
1021        desired.save(&ctx.lock_path())?;
1022    }
1023    materialize_dependencies_from_lock(workspace, &ctx, &desired, refetch, offline)
1024}
1025
1026pub fn install_packages(frozen: bool, refetch: Option<&str>, offline: bool, json: bool) {
1027    match install_packages_impl(frozen, refetch, offline) {
1028        Ok(installed) if json => {
1029            print_install_summary_json("install", installed, frozen, offline);
1030        }
1031        Ok(0) => println!("No dependencies to install."),
1032        Ok(installed) => println!("Installed {installed} package(s) to {PKG_DIR}/"),
1033        Err(error) if json => {
1034            print_install_error_json("install", &error);
1035            process::exit(1);
1036        }
1037        Err(error) => {
1038            eprintln!("error: {error}");
1039            process::exit(1);
1040        }
1041    }
1042}
1043
1044fn print_install_summary_json(action: &str, installed: usize, frozen: bool, offline: bool) {
1045    let body = serde_json::json!({
1046        "action": action,
1047        "ok": true,
1048        "installed": installed,
1049        "frozen": frozen,
1050        "offline": offline,
1051        "lock_file": LOCK_FILE,
1052        "packages_dir": PKG_DIR,
1053    });
1054    println!(
1055        "{}",
1056        serde_json::to_string_pretty(&body).unwrap_or_default()
1057    );
1058}
1059
1060fn print_install_error_json(action: &str, error: &PackageError) {
1061    let body = serde_json::json!({
1062        "action": action,
1063        "ok": false,
1064        "error": error.to_string(),
1065    });
1066    println!(
1067        "{}",
1068        serde_json::to_string_pretty(&body).unwrap_or_default()
1069    );
1070}
1071
1072pub fn lock_packages() {
1073    let result = (|| -> Result<usize, PackageError> {
1074        let workspace = PackageWorkspace::from_current_dir()?;
1075        let ctx = workspace.load_manifest_context()?;
1076        let existing = LockFile::load(&ctx.lock_path())?;
1077        let lock = build_lockfile(&workspace, &ctx, existing.as_ref(), None, true, true, false)?;
1078        lock.save(&ctx.lock_path())?;
1079        Ok(lock.packages.len())
1080    })();
1081
1082    match result {
1083        Ok(count) => println!("Wrote {} with {count} package(s).", LOCK_FILE),
1084        Err(error) => {
1085            eprintln!("error: {error}");
1086            process::exit(1);
1087        }
1088    }
1089}
1090
1091pub fn update_packages(alias: Option<&str>, all: bool, json: bool) {
1092    let result = PackageWorkspace::from_current_dir()
1093        .and_then(|workspace| update_packages_in(&workspace, alias, all));
1094    print_update_packages_result(result, json);
1095}
1096
1097pub(crate) fn update_packages_in(
1098    workspace: &PackageWorkspace,
1099    alias: Option<&str>,
1100    all: bool,
1101) -> Result<usize, PackageError> {
1102    if !all && alias.is_none() {
1103        return Err("specify a dependency alias or pass --all"
1104            .to_string()
1105            .into());
1106    }
1107
1108    let ctx = workspace.load_manifest_context()?;
1109    if let Some(alias) = alias {
1110        validate_package_alias(alias)?;
1111        if !ctx.manifest.dependencies.contains_key(alias) {
1112            return Err(format!("{alias} is not present in [dependencies]").into());
1113        }
1114    }
1115    let existing = LockFile::load(&ctx.lock_path())?;
1116    let lock = build_lockfile(workspace, &ctx, existing.as_ref(), alias, all, true, false)?;
1117    lock.save(&ctx.lock_path())?;
1118    materialize_dependencies_from_lock(workspace, &ctx, &lock, None, false)
1119}
1120
1121fn print_update_packages_result(result: Result<usize, PackageError>, json: bool) {
1122    match result {
1123        Ok(installed) if json => print_install_summary_json("update", installed, false, false),
1124        Ok(installed) => println!("Updated {installed} package(s)."),
1125        Err(error) if json => {
1126            print_install_error_json("update", &error);
1127            process::exit(1);
1128        }
1129        Err(error) => {
1130            eprintln!("error: {error}");
1131            process::exit(1);
1132        }
1133    }
1134}
1135
1136pub fn remove_package(alias: &str) {
1137    let result = PackageWorkspace::from_current_dir()
1138        .and_then(|workspace| remove_package_in(&workspace, alias));
1139    print_remove_package_result(alias, result);
1140}
1141
1142pub(crate) fn remove_package_in(
1143    workspace: &PackageWorkspace,
1144    alias: &str,
1145) -> Result<bool, PackageError> {
1146    validate_package_alias(alias)?;
1147    let ctx = workspace.load_manifest_context()?;
1148    let removed = remove_dependency_from_manifest(&ctx.manifest_path(), alias)?;
1149    if !removed {
1150        return Ok(false);
1151    }
1152    let mut lock = LockFile::load(&ctx.lock_path())?.unwrap_or_default();
1153    lock.remove(alias);
1154    lock.save(&ctx.lock_path())?;
1155    remove_materialized_package(&ctx.packages_dir(), alias)?;
1156    Ok(true)
1157}
1158
1159fn print_remove_package_result(alias: &str, result: Result<bool, PackageError>) {
1160    match result {
1161        Ok(true) => println!("Removed {alias} from {MANIFEST} and {LOCK_FILE}."),
1162        Ok(false) => {
1163            eprintln!("error: {alias} is not present in [dependencies]");
1164            process::exit(1);
1165        }
1166        Err(error) => {
1167            eprintln!("error: {error}");
1168            process::exit(1);
1169        }
1170    }
1171}
1172
1173#[derive(Clone, Copy, Debug)]
1174pub(crate) struct AddPackageRequest<'a> {
1175    name_or_spec: &'a str,
1176    alias: Option<&'a str>,
1177    git_url: Option<&'a str>,
1178    tag: Option<&'a str>,
1179    rev: Option<&'a str>,
1180    branch: Option<&'a str>,
1181    local_path: Option<&'a str>,
1182    registry: Option<&'a str>,
1183}
1184
1185#[cfg(test)]
1186#[allow(clippy::too_many_arguments)]
1187pub(crate) fn normalize_add_request(
1188    name_or_spec: &str,
1189    alias: Option<&str>,
1190    git_url: Option<&str>,
1191    tag: Option<&str>,
1192    rev: Option<&str>,
1193    branch: Option<&str>,
1194    local_path: Option<&str>,
1195    registry: Option<&str>,
1196) -> Result<(String, Dependency), PackageError> {
1197    normalize_add_request_in(
1198        &PackageWorkspace::from_current_dir()?,
1199        AddPackageRequest {
1200            name_or_spec,
1201            alias,
1202            git_url,
1203            tag,
1204            rev,
1205            branch,
1206            local_path,
1207            registry,
1208        },
1209    )
1210}
1211
1212pub(crate) fn normalize_add_request_in(
1213    workspace: &PackageWorkspace,
1214    request: AddPackageRequest<'_>,
1215) -> Result<(String, Dependency), PackageError> {
1216    let AddPackageRequest {
1217        name_or_spec,
1218        alias,
1219        git_url,
1220        tag,
1221        rev,
1222        branch,
1223        local_path,
1224        registry,
1225    } = request;
1226
1227    if local_path.is_some() && (rev.is_some() || tag.is_some() || branch.is_some()) {
1228        return Err("path dependencies do not accept --rev, --tag, or --branch"
1229            .to_string()
1230            .into());
1231    }
1232    if git_url.is_none()
1233        && local_path.is_none()
1234        && rev.is_none()
1235        && tag.is_none()
1236        && branch.is_none()
1237    {
1238        if let Some(path) = existing_local_path_spec(name_or_spec) {
1239            let alias = alias
1240                .map(str::to_string)
1241                .map(Ok)
1242                .unwrap_or_else(|| derive_package_alias_from_path(&path))?;
1243            validate_package_alias(&alias)?;
1244            return Ok((
1245                alias,
1246                Dependency::Table(DepTable {
1247                    path: Some(name_or_spec.to_string()),
1248                    ..DepTable::default()
1249                }),
1250            ));
1251        }
1252        if parse_registry_package_spec(name_or_spec).is_some() {
1253            return registry_dependency_from_spec_in(workspace, name_or_spec, alias, registry);
1254        }
1255    }
1256    if git_url.is_some() || local_path.is_some() {
1257        if let Some(path) = local_path {
1258            let alias = alias
1259                .map(str::to_string)
1260                .unwrap_or_else(|| name_or_spec.to_string());
1261            validate_package_alias(&alias)?;
1262            return Ok((
1263                alias,
1264                Dependency::Table(DepTable {
1265                    path: Some(path.to_string()),
1266                    ..DepTable::default()
1267                }),
1268            ));
1269        }
1270        let alias = alias.unwrap_or(name_or_spec).to_string();
1271        validate_package_alias(&alias)?;
1272        if rev.is_none() && tag.is_none() && branch.is_none() {
1273            return Err(format!(
1274                "git dependency {alias} must specify `rev` or `branch`; use `harn add <url>@<tag-or-sha>` or pass `--rev`/`--branch`"
1275            ).into());
1276        }
1277        let git = normalize_git_url(git_url.ok_or_else(|| "missing --git URL".to_string())?)?;
1278        let package_name = derive_repo_name_from_source(&git)?;
1279        return Ok((
1280            alias.clone(),
1281            Dependency::Table(DepTable {
1282                git: Some(git),
1283                rev: rev.or(tag).map(str::to_string),
1284                branch: branch.map(str::to_string),
1285                package: (alias != package_name).then_some(package_name),
1286                ..DepTable::default()
1287            }),
1288        ));
1289    }
1290
1291    if rev.is_some() && tag.is_some() {
1292        return Err("use only one of --rev or --tag".to_string().into());
1293    }
1294    let (raw_source, inline_ref) = parse_positional_git_spec(name_or_spec);
1295    if inline_ref.is_some() && (rev.is_some() || tag.is_some() || branch.is_some()) {
1296        return Err(
1297            "specify the git ref either inline as @ref or via --rev/--branch"
1298                .to_string()
1299                .into(),
1300        );
1301    }
1302    let git = normalize_git_url(raw_source)?;
1303    let package_name = derive_repo_name_from_source(&git)?;
1304    let alias = alias.unwrap_or(package_name.as_str()).to_string();
1305    validate_package_alias(&alias)?;
1306    if inline_ref.is_none() && rev.is_none() && tag.is_none() && branch.is_none() {
1307        return Err(format!(
1308            "git dependency {alias} must specify `rev` or `branch`; use `harn add {raw_source}@<tag-or-sha>` or pass `--rev`/`--branch`"
1309        ).into());
1310    }
1311    Ok((
1312        alias.clone(),
1313        Dependency::Table(DepTable {
1314            git: Some(git),
1315            rev: inline_ref.or(rev).or(tag).map(str::to_string),
1316            branch: branch.map(str::to_string),
1317            package: (alias != package_name).then_some(package_name),
1318            ..DepTable::default()
1319        }),
1320    ))
1321}
1322
1323#[cfg(test)]
1324pub fn add_package(
1325    name_or_spec: &str,
1326    alias: Option<&str>,
1327    git_url: Option<&str>,
1328    tag: Option<&str>,
1329    rev: Option<&str>,
1330    branch: Option<&str>,
1331    local_path: Option<&str>,
1332) {
1333    add_package_with_registry(
1334        name_or_spec,
1335        alias,
1336        git_url,
1337        tag,
1338        rev,
1339        branch,
1340        local_path,
1341        None,
1342    )
1343}
1344
1345pub fn add_package_with_registry(
1346    name_or_spec: &str,
1347    alias: Option<&str>,
1348    git_url: Option<&str>,
1349    tag: Option<&str>,
1350    rev: Option<&str>,
1351    branch: Option<&str>,
1352    local_path: Option<&str>,
1353    registry: Option<&str>,
1354) {
1355    let result = PackageWorkspace::from_current_dir().and_then(|workspace| {
1356        add_package_to(
1357            &workspace,
1358            name_or_spec,
1359            alias,
1360            git_url,
1361            tag,
1362            rev,
1363            branch,
1364            local_path,
1365            registry,
1366        )
1367    });
1368
1369    match result {
1370        Ok((alias, installed)) => {
1371            println!("Added {alias} to {MANIFEST}.");
1372            println!("Installed {installed} package(s).");
1373        }
1374        Err(error) => {
1375            eprintln!("error: {error}");
1376            process::exit(1);
1377        }
1378    }
1379}
1380
1381#[allow(clippy::too_many_arguments)]
1382pub(crate) fn add_package_to(
1383    workspace: &PackageWorkspace,
1384    name_or_spec: &str,
1385    alias: Option<&str>,
1386    git_url: Option<&str>,
1387    tag: Option<&str>,
1388    rev: Option<&str>,
1389    branch: Option<&str>,
1390    local_path: Option<&str>,
1391    registry: Option<&str>,
1392) -> Result<(String, usize), PackageError> {
1393    let manifest_path = workspace.manifest_dir().join(MANIFEST);
1394    let (alias, dependency) = normalize_add_request_in(
1395        workspace,
1396        AddPackageRequest {
1397            name_or_spec,
1398            alias,
1399            git_url,
1400            tag,
1401            rev,
1402            branch,
1403            local_path,
1404            registry,
1405        },
1406    )?;
1407    upsert_dependency_in_manifest(&manifest_path, &alias, &dependency)?;
1408    let installed = install_packages_in(workspace, false, None, false)?;
1409    Ok((alias, installed))
1410}
1411
1412#[cfg(test)]
1413mod tests {
1414    use super::*;
1415    use crate::package::test_support::*;
1416
1417    #[test]
1418    fn lock_file_round_trips_typed_schema() {
1419        let tmp = tempfile::tempdir().unwrap();
1420        let path = tmp.path().join(LOCK_FILE);
1421        let lock = LockFile {
1422            version: LOCK_FILE_VERSION,
1423            generator_version: current_generator_version(),
1424            protocol_artifact_version: current_protocol_artifact_version(),
1425            packages: vec![LockEntry {
1426                name: "acme-lib".to_string(),
1427                source: "git+https://github.com/acme/acme-lib".to_string(),
1428                rev_request: Some("v1.0.0".to_string()),
1429                commit: Some("0123456789abcdef0123456789abcdef01234567".to_string()),
1430                content_hash: Some("sha256:deadbeef".to_string()),
1431                package_version: Some("1.0.0".to_string()),
1432                harn_compat: Some(">=0.8,<0.9".to_string()),
1433                provenance: Some(
1434                    "https://github.com/acme/acme-lib/releases/tag/v1.0.0".to_string(),
1435                ),
1436                manifest_digest: Some("sha256:cafebabe".to_string()),
1437                registry: None,
1438                exports: PackageLockExports {
1439                    modules: vec![PackageLockExport {
1440                        name: "lib".to_string(),
1441                        path: Some("lib/main.harn".to_string()),
1442                        symbol: None,
1443                    }],
1444                    tools: vec![PackageLockExport {
1445                        name: "echo".to_string(),
1446                        path: Some("lib/tools.harn".to_string()),
1447                        symbol: Some("tools".to_string()),
1448                    }],
1449                    skills: Vec::new(),
1450                    personas: Vec::new(),
1451                },
1452                permissions: vec!["tool:read_only".to_string()],
1453                host_requirements: vec!["workspace.read_text".to_string()],
1454            }],
1455        };
1456        lock.save(&path).unwrap();
1457        let loaded = LockFile::load(&path).unwrap().unwrap();
1458        assert_eq!(loaded, lock);
1459    }
1460
1461    #[test]
1462    fn add_and_remove_git_dependency_round_trip() {
1463        let (_repo_tmp, repo, _branch) = create_git_package_repo();
1464        let project_tmp = tempfile::tempdir().unwrap();
1465        let root = project_tmp.path();
1466        let workspace = TestWorkspace::new(root);
1467        fs::create_dir_all(root.join(".git")).unwrap();
1468        fs::write(
1469            root.join(MANIFEST),
1470            r#"
1471    [package]
1472    name = "workspace"
1473    version = "0.1.0"
1474    "#,
1475        )
1476        .unwrap();
1477
1478        let spec = format!("{}@v1.0.0", repo.display());
1479        add_package_to(
1480            workspace.env(),
1481            &spec,
1482            None,
1483            None,
1484            None,
1485            None,
1486            None,
1487            None,
1488            None,
1489        )
1490        .unwrap();
1491
1492        let alias = "acme-lib";
1493        let manifest = fs::read_to_string(root.join(MANIFEST)).unwrap();
1494        assert!(manifest.contains("acme-lib"));
1495        assert!(manifest.contains("rev = \"v1.0.0\""));
1496
1497        let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
1498        let entry = lock.find(alias).unwrap();
1499        assert_eq!(lock.version, LOCK_FILE_VERSION);
1500        assert!(entry.source.starts_with("git+file://"));
1501        assert!(entry.commit.as_deref().is_some_and(is_full_git_sha));
1502        assert!(entry
1503            .content_hash
1504            .as_deref()
1505            .is_some_and(|hash| hash.starts_with("sha256:")));
1506        assert!(root.join(PKG_DIR).join(alias).join("lib.harn").is_file());
1507
1508        remove_package_in(workspace.env(), alias).unwrap();
1509        let updated_manifest = fs::read_to_string(root.join(MANIFEST)).unwrap();
1510        assert!(!updated_manifest.contains("acme-lib ="));
1511        let updated_lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
1512        assert!(updated_lock.find(alias).is_none());
1513        assert!(!root.join(PKG_DIR).join(alias).exists());
1514    }
1515
1516    #[test]
1517    fn update_branch_dependency_refreshes_locked_commit() {
1518        let (_repo_tmp, repo, branch) = create_git_package_repo();
1519        let project_tmp = tempfile::tempdir().unwrap();
1520        let root = project_tmp.path();
1521        let workspace = TestWorkspace::new(root);
1522        fs::create_dir_all(root.join(".git")).unwrap();
1523        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1524        fs::write(
1525            root.join(MANIFEST),
1526            format!(
1527                r#"
1528    [package]
1529    name = "workspace"
1530    version = "0.1.0"
1531
1532    [dependencies]
1533    acme-lib = {{ git = "{git}", branch = "{branch}" }}
1534    "#
1535            ),
1536        )
1537        .unwrap();
1538
1539        let installed = install_packages_in(workspace.env(), false, None, false).unwrap();
1540        assert_eq!(installed, 1);
1541        let first_lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
1542        let first_commit = first_lock
1543            .find("acme-lib")
1544            .and_then(|entry| entry.commit.clone())
1545            .unwrap();
1546
1547        fs::write(
1548            repo.join("lib.harn"),
1549            "pub fn value() -> string { return \"v2\" }\n",
1550        )
1551        .unwrap();
1552        run_git(&repo, &["add", "."]);
1553        run_git(&repo, &["commit", "-m", "update"]);
1554
1555        update_packages_in(workspace.env(), Some("acme-lib"), false).unwrap();
1556        let second_lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
1557        let second_commit = second_lock
1558            .find("acme-lib")
1559            .and_then(|entry| entry.commit.clone())
1560            .unwrap();
1561        assert_ne!(first_commit, second_commit);
1562    }
1563
1564    #[test]
1565    fn add_positional_local_path_dependency_uses_manifest_name_and_live_link() {
1566        let dependency_tmp = tempfile::tempdir().unwrap();
1567        let dependency_root = dependency_tmp.path().join("harn-openapi");
1568        fs::create_dir_all(&dependency_root).unwrap();
1569        fs::write(
1570            dependency_root.join(MANIFEST),
1571            r#"
1572    [package]
1573    name = "openapi"
1574    version = "0.1.0"
1575    "#,
1576        )
1577        .unwrap();
1578        fs::write(
1579            dependency_root.join("lib.harn"),
1580            "pub fn version() -> string { return \"v1\" }\n",
1581        )
1582        .unwrap();
1583
1584        let project_tmp = tempfile::tempdir().unwrap();
1585        let root = project_tmp.path();
1586        let workspace = TestWorkspace::new(root);
1587        fs::create_dir_all(root.join(".git")).unwrap();
1588        fs::write(
1589            root.join(MANIFEST),
1590            r#"
1591    [package]
1592    name = "workspace"
1593    version = "0.1.0"
1594    "#,
1595        )
1596        .unwrap();
1597
1598        add_package_to(
1599            workspace.env(),
1600            dependency_root.to_string_lossy().as_ref(),
1601            None,
1602            None,
1603            None,
1604            None,
1605            None,
1606            None,
1607            None,
1608        )
1609        .unwrap();
1610
1611        let manifest = fs::read_to_string(root.join(MANIFEST)).unwrap();
1612        assert!(
1613            manifest.contains("openapi = { path = "),
1614            "manifest should use package.name as alias: {manifest}"
1615        );
1616        let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
1617        let entry = lock.find("openapi").expect("openapi lock entry");
1618        assert!(entry.source.starts_with("path+file://"));
1619        let materialized = root.join(PKG_DIR).join("openapi");
1620        assert!(materialized.join("lib.harn").is_file());
1621
1622        #[cfg(unix)]
1623        assert!(
1624            fs::symlink_metadata(&materialized)
1625                .unwrap()
1626                .file_type()
1627                .is_symlink(),
1628            "path dependencies should be live-linked on Unix"
1629        );
1630
1631        #[cfg(windows)]
1632        let materialized_is_link = fs::symlink_metadata(&materialized)
1633            .unwrap()
1634            .file_type()
1635            .is_symlink();
1636
1637        fs::write(
1638            dependency_root.join("lib.harn"),
1639            "pub fn version() -> string { return \"v2\" }\n",
1640        )
1641        .unwrap();
1642        #[cfg(unix)]
1643        {
1644            let live_source = fs::read_to_string(materialized.join("lib.harn")).unwrap();
1645            assert!(
1646                live_source.contains("v2"),
1647                "materialized path dependency should reflect sibling repo edits"
1648            );
1649        }
1650        #[cfg(windows)]
1651        {
1652            let materialized_source = fs::read_to_string(materialized.join("lib.harn")).unwrap();
1653            if materialized_is_link {
1654                assert!(
1655                    materialized_source.contains("v2"),
1656                    "Windows path dependency symlink should reflect sibling repo edits"
1657                );
1658            } else {
1659                assert!(
1660                    materialized_source.contains("v1"),
1661                    "Windows path dependency copy fallback should keep the copied contents"
1662                );
1663            }
1664        }
1665
1666        remove_package_in(workspace.env(), "openapi").unwrap();
1667        assert!(!materialized.exists());
1668        assert!(dependency_root.join("lib.harn").exists());
1669    }
1670
1671    #[test]
1672    fn frozen_install_errors_when_lockfile_is_missing() {
1673        let (_repo_tmp, repo, _branch) = create_git_package_repo();
1674        let project_tmp = tempfile::tempdir().unwrap();
1675        let root = project_tmp.path();
1676        let workspace = TestWorkspace::new(root);
1677        fs::create_dir_all(root.join(".git")).unwrap();
1678        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1679        fs::write(
1680            root.join(MANIFEST),
1681            format!(
1682                r#"
1683    [package]
1684    name = "workspace"
1685    version = "0.1.0"
1686
1687    [dependencies]
1688    acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
1689    "#
1690            ),
1691        )
1692        .unwrap();
1693
1694        let error = install_packages_in(workspace.env(), true, None, false).unwrap_err();
1695        assert!(error.to_string().contains(LOCK_FILE));
1696    }
1697
1698    #[test]
1699    fn offline_locked_install_materializes_from_cache_without_source_repo() {
1700        let (_repo_tmp, repo, _branch) = create_git_package_repo();
1701        let project_tmp = tempfile::tempdir().unwrap();
1702        let root = project_tmp.path();
1703        let workspace = TestWorkspace::new(root);
1704        fs::create_dir_all(root.join(".git")).unwrap();
1705        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1706        fs::write(
1707            root.join(MANIFEST),
1708            format!(
1709                r#"
1710    [package]
1711    name = "workspace"
1712    version = "0.1.0"
1713
1714    [dependencies]
1715    acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
1716    "#
1717            ),
1718        )
1719        .unwrap();
1720
1721        let installed = install_packages_in(workspace.env(), false, None, false).unwrap();
1722        assert_eq!(installed, 1);
1723        fs::remove_dir_all(root.join(PKG_DIR)).unwrap();
1724        fs::remove_dir_all(&repo).unwrap();
1725
1726        let installed = install_packages_in(workspace.env(), true, None, true).unwrap();
1727        assert_eq!(installed, 1);
1728        assert!(root
1729            .join(PKG_DIR)
1730            .join("acme-lib")
1731            .join("lib.harn")
1732            .is_file());
1733    }
1734
1735    #[test]
1736    fn offline_locked_install_fails_when_cache_is_missing() {
1737        let (_repo_tmp, repo, _branch) = create_git_package_repo();
1738        let project_tmp = tempfile::tempdir().unwrap();
1739        let root = project_tmp.path();
1740        let workspace = TestWorkspace::new(root);
1741        let cache_dir = workspace.cache_dir();
1742        fs::create_dir_all(root.join(".git")).unwrap();
1743        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1744        fs::write(
1745            root.join(MANIFEST),
1746            format!(
1747                r#"
1748    [package]
1749    name = "workspace"
1750    version = "0.1.0"
1751
1752    [dependencies]
1753    acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
1754    "#
1755            ),
1756        )
1757        .unwrap();
1758
1759        install_packages_in(workspace.env(), false, None, false).unwrap();
1760        fs::remove_dir_all(cache_dir.join("git")).unwrap();
1761        let error = install_packages_in(workspace.env(), true, None, true).unwrap_err();
1762        assert!(error.to_string().contains("offline mode"));
1763    }
1764
1765    #[test]
1766    fn add_github_shorthand_requires_version_or_ref() {
1767        let error = normalize_add_request(
1768            "github.com/burin-labs/harn-openapi",
1769            None,
1770            None,
1771            None,
1772            None,
1773            None,
1774            None,
1775            None,
1776        )
1777        .unwrap_err();
1778        assert!(error.to_string().contains("must specify `rev` or `branch`"));
1779    }
1780
1781    #[test]
1782    fn add_github_shorthand_with_ref_writes_git_dependency() {
1783        let (alias, dependency) = normalize_add_request(
1784            "github.com/burin-labs/harn-openapi@v1.2.3",
1785            None,
1786            None,
1787            None,
1788            None,
1789            None,
1790            None,
1791            None,
1792        )
1793        .unwrap();
1794        assert_eq!(alias, "harn-openapi");
1795        assert_eq!(
1796            render_dependency_line(&alias, &dependency).unwrap(),
1797            "harn-openapi = { git = \"https://github.com/burin-labs/harn-openapi\", rev = \"v1.2.3\" }"
1798        );
1799    }
1800    #[test]
1801    fn install_resolves_transitive_git_dependencies_from_clean_cache() {
1802        let (_sdk_tmp, sdk_repo, _branch) = create_git_package_repo_with(
1803            "notion-sdk-harn",
1804            "",
1805            "pub fn sdk_value() -> string { return \"sdk\" }\n",
1806        );
1807        let sdk_git = normalize_git_url(sdk_repo.to_string_lossy().as_ref()).unwrap();
1808        let connector_tail = format!(
1809            r#"
1810
1811    [dependencies]
1812    notion-sdk-harn = {{ git = "{sdk_git}", rev = "v1.0.0" }}
1813    "#
1814        );
1815        let (_connector_tmp, connector_repo, _branch) = create_git_package_repo_with(
1816            "notion-connector-harn",
1817            &connector_tail,
1818            r#"
1819    import "notion-sdk-harn"
1820
1821    pub fn connector_value() -> string {
1822      return "connector"
1823    }
1824    "#,
1825        );
1826
1827        let project_tmp = tempfile::tempdir().unwrap();
1828        let root = project_tmp.path();
1829        let workspace = TestWorkspace::new(root);
1830        fs::create_dir_all(root.join(".git")).unwrap();
1831        let connector_git = normalize_git_url(connector_repo.to_string_lossy().as_ref()).unwrap();
1832        fs::write(
1833            root.join(MANIFEST),
1834            format!(
1835                r#"
1836    [package]
1837    name = "workspace"
1838    version = "0.1.0"
1839
1840    [dependencies]
1841    notion-connector-harn = {{ git = "{connector_git}", rev = "v1.0.0" }}
1842    "#
1843            ),
1844        )
1845        .unwrap();
1846
1847        let installed = install_packages_in(workspace.env(), false, None, false).unwrap();
1848        assert_eq!(installed, 2);
1849
1850        let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
1851        assert!(lock.find("notion-connector-harn").is_some());
1852        assert!(lock.find("notion-sdk-harn").is_some());
1853        assert!(root
1854            .join(PKG_DIR)
1855            .join("notion-connector-harn")
1856            .join("lib.harn")
1857            .is_file());
1858        assert!(root
1859            .join(PKG_DIR)
1860            .join("notion-sdk-harn")
1861            .join("lib.harn")
1862            .is_file());
1863
1864        let mut vm = test_vm();
1865        let exports = futures::executor::block_on(
1866            vm.load_module_exports(
1867                &root
1868                    .join(PKG_DIR)
1869                    .join("notion-connector-harn")
1870                    .join("lib.harn"),
1871            ),
1872        )
1873        .expect("transitive import should load from the workspace package root");
1874        assert!(exports.contains_key("connector_value"));
1875    }
1876
1877    #[test]
1878    fn git_packages_reject_transitive_path_dependencies() {
1879        let connector_tail = r#"
1880
1881    [dependencies]
1882    local-helper = { path = "../helper" }
1883    "#;
1884        let (_connector_tmp, connector_repo, _branch) = create_git_package_repo_with(
1885            "notion-connector-harn",
1886            connector_tail,
1887            "pub fn connector_value() -> string { return \"connector\" }\n",
1888        );
1889
1890        let project_tmp = tempfile::tempdir().unwrap();
1891        let root = project_tmp.path();
1892        let workspace = TestWorkspace::new(root);
1893        fs::create_dir_all(root.join(".git")).unwrap();
1894        let connector_git = normalize_git_url(connector_repo.to_string_lossy().as_ref()).unwrap();
1895        fs::write(
1896            root.join(MANIFEST),
1897            format!(
1898                r#"
1899    [package]
1900    name = "workspace"
1901    version = "0.1.0"
1902
1903    [dependencies]
1904    notion-connector-harn = {{ git = "{connector_git}", rev = "v1.0.0" }}
1905    "#
1906            ),
1907        )
1908        .unwrap();
1909
1910        let error = install_packages_in(workspace.env(), false, None, false).unwrap_err();
1911        assert!(error
1912            .to_string()
1913            .contains("path dependencies are not supported inside git-installed packages"));
1914    }
1915
1916    #[test]
1917    fn package_alias_validation_rejects_path_traversal_names() {
1918        for alias in [
1919            "../evil",
1920            "nested/evil",
1921            "nested\\evil",
1922            ".",
1923            "..",
1924            "bad alias",
1925        ] {
1926            assert!(
1927                validate_package_alias(alias).is_err(),
1928                "{alias:?} should be rejected"
1929            );
1930        }
1931        validate_package_alias("acme-lib_1.2").expect("ordinary alias should be accepted");
1932    }
1933
1934    #[test]
1935    fn add_package_rejects_aliases_that_escape_packages_dir() {
1936        let error = normalize_add_request(
1937            "ignored",
1938            Some("../evil"),
1939            None,
1940            None,
1941            None,
1942            None,
1943            Some("./dep"),
1944            None,
1945        )
1946        .unwrap_err();
1947        assert!(error.to_string().contains("invalid dependency alias"));
1948    }
1949
1950    #[test]
1951    fn rendered_dependency_values_are_toml_escaped() {
1952        let path = "dep\" \nmalicious = true";
1953        let line = render_dependency_line(
1954            "safe",
1955            &Dependency::Table(DepTable {
1956                path: Some(path.to_string()),
1957                ..DepTable::default()
1958            }),
1959        )
1960        .expect("dependency line");
1961        let parsed: Manifest = toml::from_str(&format!("[dependencies]\n{line}\n")).unwrap();
1962        assert_eq!(parsed.dependencies.len(), 1);
1963        assert_eq!(
1964            parsed
1965                .dependencies
1966                .get("safe")
1967                .and_then(Dependency::local_path),
1968            Some(path)
1969        );
1970    }
1971
1972    #[test]
1973    fn materialization_rejects_lock_alias_path_traversal_before_removing_paths() {
1974        let tmp = tempfile::tempdir().unwrap();
1975        let dep = tmp.path().join("dep");
1976        fs::create_dir_all(&dep).unwrap();
1977        fs::write(dep.join("lib.harn"), "pub fn dep() { 1 }\n").unwrap();
1978        let victim = tmp.path().join("victim");
1979        fs::create_dir_all(&victim).unwrap();
1980        fs::write(victim.join("keep.txt"), "keep").unwrap();
1981
1982        let manifest: Manifest = toml::from_str("[package]\nname = \"root\"\n").unwrap();
1983        let ctx = ManifestContext {
1984            manifest,
1985            dir: tmp.path().to_path_buf(),
1986        };
1987        let workspace = TestWorkspace::new(tmp.path());
1988        let lock = LockFile {
1989            packages: vec![LockEntry {
1990                name: "../victim".to_string(),
1991                source: path_source_uri(&dep).unwrap(),
1992                ..LockEntry::default()
1993            }],
1994            ..LockFile::default()
1995        };
1996
1997        let error = materialize_dependencies_from_lock(workspace.env(), &ctx, &lock, None, false)
1998            .unwrap_err();
1999        assert!(error.to_string().contains("invalid dependency alias"));
2000        assert!(
2001            victim.join("keep.txt").exists(),
2002            "malicious alias should not remove paths outside .harn/packages"
2003        );
2004    }
2005}