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) tag: Option<String>,
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub(crate) rev_request: Option<String>,
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub(crate) commit: Option<String>,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub(crate) content_hash: Option<String>,
52    /// `[package].version` from the resolved package's manifest. Captured so
53    /// `harn package outdated` and `harn package audit` can compare without
54    /// reopening a materialized package generation.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub(crate) package_version: Option<String>,
57    /// `[package].harn` compatibility range from the resolved package's
58    /// manifest. Used by audit to flag packages that no longer support the
59    /// current Harn line.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub(crate) harn_compat: Option<String>,
62    /// Package-authored provenance URL or identifier from `[package]`.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub(crate) provenance: Option<String>,
65    /// SHA-256 digest (`sha256:<hex>`) of the resolved package's
66    /// `harn.toml`, separate from the full-contents `content_hash`. Lets
67    /// audit detect manifest tampering without re-hashing the entire tree.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub(crate) manifest_digest: Option<String>,
70    /// Provenance for entries that were originally added through the
71    /// package registry index and lowered to a git source. Preserved so
72    /// `harn package outdated` can compare against the registry's latest
73    /// version.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub(crate) registry: Option<RegistryProvenance>,
76    #[serde(default, skip_serializing_if = "PackageLockExports::is_empty")]
77    pub(crate) exports: PackageLockExports,
78    #[serde(default, skip_serializing_if = "Vec::is_empty")]
79    pub(crate) permissions: Vec<String>,
80    #[serde(default, skip_serializing_if = "Vec::is_empty")]
81    pub(crate) host_requirements: Vec<String>,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85pub(crate) struct RegistryProvenance {
86    pub(crate) source: String,
87    pub(crate) name: String,
88    pub(crate) version: String,
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub(crate) provenance_url: Option<String>,
91}
92
93#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
94pub struct PackageLockExports {
95    #[serde(default, skip_serializing_if = "Vec::is_empty")]
96    pub modules: Vec<PackageLockExport>,
97    #[serde(default, skip_serializing_if = "Vec::is_empty")]
98    pub tools: Vec<PackageLockExport>,
99    #[serde(default, skip_serializing_if = "Vec::is_empty")]
100    pub skills: Vec<PackageLockExport>,
101    #[serde(default, skip_serializing_if = "Vec::is_empty")]
102    pub personas: Vec<String>,
103}
104
105impl PackageLockExports {
106    pub(crate) fn is_empty(&self) -> bool {
107        self.modules.is_empty()
108            && self.tools.is_empty()
109            && self.skills.is_empty()
110            && self.personas.is_empty()
111    }
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115pub struct PackageLockExport {
116    pub name: String,
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub path: Option<String>,
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub symbol: Option<String>,
121}
122
123impl LockFile {
124    pub(crate) fn load(path: &Path) -> Result<Option<Self>, PackageError> {
125        let content = match fs::read_to_string(path) {
126            Ok(s) => s,
127            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
128            Err(error) => return Err(format!("failed to read {}: {error}", path.display()).into()),
129        };
130
131        // Peek at the version field so older lock formats migrate cleanly
132        // even when their schema is otherwise compatible with the current
133        // structs (e.g. v1 → v2 only added optional fields).
134        let raw_version = toml::from_str::<RawVersionedFile>(&content)
135            .ok()
136            .map(|raw| raw.version);
137
138        match raw_version {
139            Some(LOCK_FILE_VERSION) => {
140                let mut lock: Self = toml::from_str(&content)
141                    .map_err(|error| format!("failed to parse {}: {error}", path.display()))?;
142                lock.sort_entries();
143                Ok(Some(lock))
144            }
145            Some(1..=3) => {
146                // Older lockfile versions load through the current struct
147                // because added fields are optional. Saving stamps the current
148                // version and enriches provenance on the next install.
149                let mut lock: Self = toml::from_str(&content)
150                    .map_err(|error| format!("failed to parse {}: {error}", path.display()))?;
151                lock.version = LOCK_FILE_VERSION;
152                lock.sort_entries();
153                Ok(Some(lock))
154            }
155            Some(other) => Err(format!(
156                "unsupported {} version {} (expected {})",
157                path.display(),
158                other,
159                LOCK_FILE_VERSION
160            )
161            .into()),
162            None => {
163                let legacy = toml::from_str::<LegacyLockFile>(&content)
164                    .map_err(|error| format!("failed to parse {}: {error}", path.display()))?;
165                let mut lock = Self {
166                    version: LOCK_FILE_VERSION,
167                    generator_version: current_generator_version(),
168                    protocol_artifact_version: current_protocol_artifact_version(),
169                    packages: legacy
170                        .packages
171                        .into_iter()
172                        .map(|entry| LockEntry {
173                            name: entry.name,
174                            source: entry
175                                .path
176                                .map(|path| format!("path+{path}"))
177                                .or_else(|| entry.git.map(|git| format!("git+{git}")))
178                                .unwrap_or_default(),
179                            tag: entry.tag.clone(),
180                            rev_request: entry.rev_request.or(entry.tag),
181                            commit: entry.commit,
182                            content_hash: None,
183                            package_version: None,
184                            harn_compat: None,
185                            provenance: None,
186                            manifest_digest: None,
187                            registry: None,
188                            exports: PackageLockExports::default(),
189                            permissions: Vec::new(),
190                            host_requirements: Vec::new(),
191                        })
192                        .collect(),
193                };
194                lock.sort_entries();
195                Ok(Some(lock))
196            }
197        }
198    }
199
200    pub(crate) fn encode(&self) -> Result<Vec<u8>, PackageError> {
201        let mut normalized = self.clone();
202        normalized.version = LOCK_FILE_VERSION;
203        normalized.generator_version = current_generator_version();
204        normalized.protocol_artifact_version = current_protocol_artifact_version();
205        normalized.sort_entries();
206        let body = toml::to_string_pretty(&normalized)
207            .map_err(|error| format!("failed to encode package lock file: {error}"))?;
208        let mut out = String::from("# This file is auto-generated by Harn. Do not edit.\n\n");
209        out.push_str(&body);
210        Ok(out.into_bytes())
211    }
212
213    fn save(&self, path: &Path) -> Result<(), PackageError> {
214        let bytes = self.encode()?;
215        harn_vm::atomic_io::atomic_write(path, &bytes).map_err(|error| {
216            PackageError::Lockfile(format!("failed to write {}: {error}", path.display()))
217        })
218    }
219
220    /// Whether two lockfiles resolve the same dependency set.
221    ///
222    /// Compares only the resolution content (`packages`), not the
223    /// `generator_version` / `protocol_artifact_version` provenance stamps.
224    /// Those stamps carry the CLI version that last *wrote* the file, so a
225    /// Harn release bump rewrites them even when every resolved dependency
226    /// is identical — and a frozen (`--locked` / `--frozen` / `--offline`)
227    /// install that included them in the comparison would fail on every
228    /// bump with "harn.lock would need to change" despite nothing
229    /// substantive changing. Provenance freshness stays softly enforced by
230    /// `harn package audit` (a warning, not a gate).
231    pub(crate) fn same_resolution(&self, other: &Self) -> bool {
232        self.packages == other.packages
233    }
234
235    pub(crate) fn sort_entries(&mut self) {
236        self.packages
237            .sort_by(|left, right| left.name.cmp(&right.name));
238    }
239
240    pub(crate) fn find(&self, name: &str) -> Option<&LockEntry> {
241        self.packages.iter().find(|entry| entry.name == name)
242    }
243
244    fn replace(&mut self, entry: LockEntry) {
245        if let Some(existing) = self.packages.iter_mut().find(|pkg| pkg.name == entry.name) {
246            *existing = entry;
247        } else {
248            self.packages.push(entry);
249        }
250        self.sort_entries();
251    }
252
253    fn remove(&mut self, name: &str) {
254        self.packages.retain(|entry| entry.name != name);
255    }
256}
257
258#[derive(Debug, Deserialize)]
259struct RawVersionedFile {
260    version: u32,
261}
262
263#[derive(Debug, Deserialize)]
264pub(crate) struct LegacyLockFile {
265    #[serde(default, rename = "package")]
266    packages: Vec<LegacyLockEntry>,
267}
268
269#[derive(Debug, Deserialize)]
270pub(crate) struct LegacyLockEntry {
271    pub(crate) name: String,
272    #[serde(default)]
273    git: Option<String>,
274    #[serde(default)]
275    tag: Option<String>,
276    #[serde(default)]
277    pub(crate) rev_request: Option<String>,
278    #[serde(default)]
279    pub(crate) commit: Option<String>,
280    #[serde(default)]
281    path: Option<String>,
282}
283
284pub(crate) fn compatible_locked_entry(
285    workspace: &PackageWorkspace,
286    alias: &str,
287    dependency: &Dependency,
288    lock: &LockEntry,
289    manifest_dir: &Path,
290) -> Result<bool, PackageError> {
291    if lock.name != alias {
292        return Ok(false);
293    }
294    if let Some(path) = dependency.local_path() {
295        let source = path_source_uri(&resolve_path_dependency_source(manifest_dir, path)?)?;
296        return Ok(lock.source == source);
297    }
298    if let Some(requirement) = dependency.version() {
299        let Dependency::Table(table) = dependency else {
300            return Ok(false);
301        };
302        let Some(registry) = lock.registry.as_ref() else {
303            return Ok(false);
304        };
305        let registry_name = table.registry_name.as_deref().unwrap_or(alias);
306        if registry.name != registry_name {
307            return Ok(false);
308        }
309        let expected_source = workspace.resolve_registry_source(table.registry.as_deref())?;
310        if registry.source != expected_source {
311            return Ok(false);
312        }
313        let version = parse_registry_semver(&registry.version)?;
314        let req = parse_registry_version_req(requirement)?;
315        let resolved_source_is_locked = if lock.source.starts_with("git+") {
316            lock.commit.is_some() && lock.content_hash.is_some()
317        } else if lock.source.starts_with("archive+") {
318            lock.content_hash.is_some()
319        } else {
320            false
321        };
322        return Ok(req.matches(&version) && resolved_source_is_locked);
323    }
324    if let Some(url) = dependency.git_url() {
325        let source = format!("git+{}", normalize_git_url(url)?);
326        let requested = dependency_git_request(dependency).map(str::to_string);
327        return Ok(lock.source == source
328            && lock.rev_request == requested
329            && lock.tag == dependency.tag().map(str::to_string)
330            && lock.commit.is_some()
331            && lock.content_hash.is_some());
332    }
333    if let Some(url) = dependency.archive_url() {
334        let source = archive_source_uri(url)?;
335        return Ok(lock.source == source && lock.content_hash.is_some());
336    }
337    Ok(false)
338}
339
340#[derive(Debug, Clone)]
341pub(crate) struct PendingDependency {
342    alias: String,
343    dependency: Dependency,
344    manifest_dir: PathBuf,
345    parent: Option<String>,
346    parent_is_remote: bool,
347}
348
349pub(crate) fn git_rev_request(
350    alias: &str,
351    dependency: &Dependency,
352) -> Result<String, PackageError> {
353    dependency_git_request(dependency)
354        .map(str::to_string)
355        .ok_or_else(|| {
356            PackageError::Lockfile(format!(
357                "git dependency {alias} must specify `tag`, `rev`, or `branch`; use `harn add <url>@<tag-or-sha>` or add `tag = \"...\"` to {MANIFEST}"
358            ))
359        })
360}
361
362pub(crate) fn dependency_git_request(dependency: &Dependency) -> Option<&str> {
363    dependency
364        .branch()
365        .or_else(|| dependency.rev())
366        .or_else(|| dependency.tag())
367}
368
369pub(crate) fn dependency_content_hash(
370    alias: &str,
371    dependency: &Dependency,
372) -> Result<String, PackageError> {
373    let Dependency::Table(table) = dependency else {
374        return Err(format!("dependency {alias} is missing checksum").into());
375    };
376    table
377        .checksum
378        .clone()
379        .ok_or_else(|| format!("archive dependency {alias} must specify checksum").into())
380}
381
382pub(crate) fn dependency_manifest_dir(source: &Path) -> Option<PathBuf> {
383    if source.is_dir() {
384        return Some(source.to_path_buf());
385    }
386    source.parent().map(Path::to_path_buf)
387}
388
389pub(crate) fn read_package_manifest_from_dir(dir: &Path) -> Result<Option<Manifest>, PackageError> {
390    let manifest_path = dir.join(MANIFEST);
391    if !manifest_path.exists() {
392        return Ok(None);
393    }
394    read_manifest_from_path(&manifest_path).map(Some)
395}
396
397/// Provenance pulled from a resolved package's manifest. Used to enrich a
398/// `LockEntry` so audit/outdated reports stay self-contained.
399#[derive(Debug, Clone, Default)]
400pub(crate) struct LockEntryProvenance {
401    pub(crate) package_version: Option<String>,
402    pub(crate) harn_compat: Option<String>,
403    pub(crate) provenance: Option<String>,
404    pub(crate) manifest_digest: Option<String>,
405    pub(crate) exports: PackageLockExports,
406    pub(crate) permissions: Vec<String>,
407    pub(crate) host_requirements: Vec<String>,
408}
409
410pub(crate) fn read_lock_entry_provenance(
411    package_dir: &Path,
412) -> Result<LockEntryProvenance, PackageError> {
413    let manifest_path = package_dir.join(MANIFEST);
414    if !manifest_path.exists() {
415        return Ok(LockEntryProvenance::default());
416    }
417    let bytes = fs::read(&manifest_path)
418        .map_err(|error| format!("failed to read {}: {error}", manifest_path.display()))?;
419    let digest = format!("sha256:{}", sha256_hex(&bytes));
420    let manifest = read_manifest_from_path(&manifest_path)?;
421    let (package_version, harn_compat, provenance, permissions, host_requirements) = manifest
422        .package
423        .as_ref()
424        .map(|info| {
425            (
426                info.version.clone(),
427                info.harn.clone(),
428                info.provenance.clone(),
429                info.permissions.clone(),
430                info.host_requirements.clone(),
431            )
432        })
433        .unwrap_or((None, None, None, Vec::new(), Vec::new()));
434    Ok(LockEntryProvenance {
435        package_version,
436        harn_compat,
437        provenance,
438        manifest_digest: Some(digest),
439        exports: package_lock_exports_from_manifest(&manifest),
440        permissions: normalized_requirements(&permissions),
441        host_requirements: normalized_requirements(&host_requirements),
442    })
443}
444
445fn fill_provenance(entry: &mut LockEntry, provenance: LockEntryProvenance) {
446    entry.package_version = provenance.package_version;
447    entry.harn_compat = provenance.harn_compat;
448    entry.provenance = provenance.provenance;
449    entry.manifest_digest = provenance.manifest_digest;
450    entry.exports = provenance.exports;
451    entry.permissions = provenance.permissions;
452    entry.host_requirements = provenance.host_requirements;
453}
454
455pub(crate) fn package_lock_exports_from_manifest(manifest: &Manifest) -> PackageLockExports {
456    let mut modules: Vec<PackageLockExport> = manifest
457        .exports
458        .iter()
459        .map(|(name, path)| PackageLockExport {
460            name: name.clone(),
461            path: Some(path.clone()),
462            symbol: None,
463        })
464        .collect();
465    modules.sort_by(|left, right| left.name.cmp(&right.name));
466
467    let (mut tools, mut skills) = manifest
468        .package
469        .as_ref()
470        .map(|package| {
471            let tools = package
472                .tools
473                .iter()
474                .map(|tool| PackageLockExport {
475                    name: tool.name.clone(),
476                    path: Some(tool.module.clone()),
477                    symbol: Some(tool.symbol.clone()),
478                })
479                .collect::<Vec<_>>();
480            let skills = package
481                .skills
482                .iter()
483                .map(|skill| PackageLockExport {
484                    name: skill.name.clone(),
485                    path: Some(skill.path.clone()),
486                    symbol: None,
487                })
488                .collect::<Vec<_>>();
489            (tools, skills)
490        })
491        .unwrap_or_default();
492    tools.sort_by(|left, right| left.name.cmp(&right.name));
493    skills.sort_by(|left, right| left.name.cmp(&right.name));
494
495    let mut personas: Vec<String> = manifest
496        .personas
497        .iter()
498        .filter_map(|persona| persona.name.clone())
499        .collect();
500    personas.sort();
501    personas.dedup();
502
503    PackageLockExports {
504        modules,
505        tools,
506        skills,
507        personas,
508    }
509}
510
511pub(crate) fn normalized_requirements(values: &[String]) -> Vec<String> {
512    let mut out: Vec<String> = values
513        .iter()
514        .map(|value| value.trim())
515        .filter(|value| !value.is_empty())
516        .map(str::to_string)
517        .collect();
518    out.sort();
519    out.dedup();
520    out
521}
522
523pub(crate) fn dependency_conflict_message(
524    existing: &LockEntry,
525    candidate: &LockEntry,
526) -> PackageError {
527    PackageError::Lockfile(format!(
528        "dependency alias '{}' resolves to multiple packages ({} and {}); use distinct aliases in {MANIFEST}",
529        candidate.name, existing.source, candidate.source
530    ))
531}
532
533pub(crate) fn replace_lock_entry(
534    lock: &mut LockFile,
535    candidate: LockEntry,
536) -> Result<bool, PackageError> {
537    validate_package_alias(&candidate.name)?;
538    if let Some(existing) = lock.find(&candidate.name) {
539        if existing == &candidate {
540            return Ok(false);
541        }
542        return Err(dependency_conflict_message(existing, &candidate));
543    }
544    lock.replace(candidate);
545    Ok(true)
546}
547
548pub(crate) fn enqueue_manifest_dependencies(
549    pending: &mut Vec<PendingDependency>,
550    manifest: Manifest,
551    manifest_dir: PathBuf,
552    parent: String,
553    parent_is_remote: bool,
554) {
555    let mut aliases: Vec<String> = manifest.dependencies.keys().cloned().collect();
556    aliases.sort();
557    for alias in aliases.into_iter().rev() {
558        if let Some(dependency) = manifest.dependencies.get(&alias).cloned() {
559            pending.push(PendingDependency {
560                alias,
561                dependency,
562                manifest_dir: manifest_dir.clone(),
563                parent: Some(parent.clone()),
564                parent_is_remote,
565            });
566        }
567    }
568}
569
570fn resolve_registry_version_dependency(
571    workspace: &PackageWorkspace,
572    alias: &str,
573    dependency: Dependency,
574) -> Result<Dependency, PackageError> {
575    let Dependency::Table(table) = &dependency else {
576        return Ok(dependency);
577    };
578    if table.version.is_none() {
579        return Ok(dependency);
580    }
581    registry_dependency_from_manifest_constraint_in(workspace, alias, table)
582}
583
584fn validate_dependency_source_shape(
585    alias: &str,
586    dependency: &Dependency,
587) -> Result<(), PackageError> {
588    let Dependency::Table(table) = dependency else {
589        return Ok(());
590    };
591    let source_count = usize::from(table.git.is_some())
592        + usize::from(table.archive.is_some())
593        + usize::from(table.path.is_some());
594    if table.version.is_some()
595        && (source_count > 0
596            || table.rev.is_some()
597            || table.tag.is_some()
598            || table.branch.is_some())
599    {
600        return Err(format!(
601            "dependency {alias} uses `version`; do not combine registry version constraints with git, archive, path, tag, rev, or branch"
602        )
603        .into());
604    }
605    if source_count > 1 {
606        return Err(
607            format!("dependency {alias} must specify only one of git, archive, or path").into(),
608        );
609    }
610    if table.archive.is_some()
611        && (table.tag.is_some() || table.rev.is_some() || table.branch.is_some())
612    {
613        return Err(
614            format!("archive dependency {alias} cannot specify tag, rev, or branch").into(),
615        );
616    }
617    Ok(())
618}
619
620pub(crate) fn build_lockfile(
621    workspace: &PackageWorkspace,
622    ctx: &ManifestContext,
623    existing: Option<&LockFile>,
624    refresh_alias: Option<&str>,
625    refresh_all: bool,
626    allow_resolve: bool,
627    offline: bool,
628) -> Result<LockFile, PackageError> {
629    if manifest_has_git_dependencies(&ctx.manifest) {
630        ensure_git_available()?;
631    }
632
633    let mut lock = LockFile::default();
634    let mut pending: Vec<PendingDependency> = Vec::new();
635    let mut aliases: Vec<String> = ctx.manifest.dependencies.keys().cloned().collect();
636    aliases.sort();
637    for alias in aliases.into_iter().rev() {
638        let dependency = ctx
639            .manifest
640            .dependencies
641            .get(&alias)
642            .ok_or_else(|| format!("dependency {alias} disappeared while locking"))?
643            .clone();
644        pending.push(PendingDependency {
645            alias,
646            dependency,
647            manifest_dir: ctx.dir.clone(),
648            parent: None,
649            parent_is_remote: false,
650        });
651    }
652
653    while let Some(next) = pending.pop() {
654        let alias = next.alias;
655        validate_package_alias(&alias)?;
656        let dependency = next.dependency;
657        if dependency.local_path().is_some() && next.parent_is_remote {
658            let parent = next.parent.as_deref().unwrap_or("a remote package");
659            return Err(format!(
660                "package {parent} declares local path dependency {alias}, but path dependencies are not supported inside remote-installed packages; publish {alias} as a git or registry dependency"
661            ).into());
662        }
663        if dependency.requires_git() {
664            ensure_git_available()?;
665            if dependency.git_url().is_some() {
666                git_rev_request(&alias, &dependency)?;
667            }
668        }
669        validate_dependency_source_shape(&alias, &dependency)?;
670        let refresh = refresh_all || refresh_alias == Some(alias.as_str());
671        if let Some(existing_lock) = existing.and_then(|lock| lock.find(&alias)) {
672            if !refresh
673                && compatible_locked_entry(
674                    workspace,
675                    &alias,
676                    &dependency,
677                    existing_lock,
678                    &next.manifest_dir,
679                )?
680            {
681                let mut entry = existing_lock.clone();
682                if entry.source.starts_with("git+") && entry.content_hash.is_none() {
683                    let url = entry.source.trim_start_matches("git+");
684                    let commit = entry
685                        .commit
686                        .as_deref()
687                        .ok_or_else(|| format!("missing locked commit for {alias}"))?;
688                    entry.content_hash = Some(ensure_git_cache_populated_in(
689                        workspace,
690                        url,
691                        &entry.source,
692                        commit,
693                        None,
694                        false,
695                        offline,
696                    )?);
697                }
698                if entry.source.starts_with("git+") {
699                    let url = entry.source.trim_start_matches("git+");
700                    let commit = entry
701                        .commit
702                        .as_deref()
703                        .ok_or_else(|| format!("missing locked commit for {alias}"))?;
704                    let expected_hash = entry
705                        .content_hash
706                        .as_deref()
707                        .ok_or_else(|| format!("missing content hash for {alias}"))?;
708                    ensure_git_cache_populated_in(
709                        workspace,
710                        url,
711                        &entry.source,
712                        commit,
713                        Some(expected_hash),
714                        false,
715                        offline,
716                    )?;
717                    let cache_dir = git_cache_dir_in(workspace, &entry.source, commit)?;
718                    if entry.manifest_digest.is_none()
719                        || entry.package_version.is_none()
720                        || entry.provenance.is_none()
721                    {
722                        fill_provenance(&mut entry, read_lock_entry_provenance(&cache_dir)?);
723                    }
724                    if entry.registry.is_none() {
725                        entry.registry = dependency.registry_provenance();
726                    }
727                    let inserted = replace_lock_entry(&mut lock, entry.clone())?;
728                    if inserted {
729                        if let Some(manifest) = read_package_manifest_from_dir(&cache_dir)? {
730                            enqueue_manifest_dependencies(
731                                &mut pending,
732                                manifest,
733                                cache_dir,
734                                alias,
735                                true,
736                            );
737                        }
738                    }
739                } else if entry.source.starts_with("archive+") {
740                    let url = archive_url_from_source_uri(&entry.source)?;
741                    let expected_hash = entry
742                        .content_hash
743                        .as_deref()
744                        .ok_or_else(|| format!("missing content hash for {alias}"))?;
745                    ensure_archive_cache_populated_in(
746                        workspace,
747                        url,
748                        &entry.source,
749                        expected_hash,
750                        false,
751                        offline,
752                    )?;
753                    let cache_dir = archive_cache_dir_in(workspace, &entry.source, expected_hash)?;
754                    if entry.manifest_digest.is_none()
755                        || entry.package_version.is_none()
756                        || entry.provenance.is_none()
757                    {
758                        fill_provenance(&mut entry, read_lock_entry_provenance(&cache_dir)?);
759                    }
760                    if entry.registry.is_none() {
761                        entry.registry = dependency.registry_provenance();
762                    }
763                    let inserted = replace_lock_entry(&mut lock, entry.clone())?;
764                    if inserted {
765                        if let Some(manifest) = read_package_manifest_from_dir(&cache_dir)? {
766                            enqueue_manifest_dependencies(
767                                &mut pending,
768                                manifest,
769                                cache_dir,
770                                alias,
771                                true,
772                            );
773                        }
774                    }
775                } else if entry.source.starts_with("path+") {
776                    let source = path_from_source_uri(&entry.source)?;
777                    let manifest_dir = dependency_manifest_dir(&source);
778                    if entry.manifest_digest.is_none()
779                        || entry.package_version.is_none()
780                        || entry.provenance.is_none()
781                    {
782                        if let Some(dir) = manifest_dir.as_deref() {
783                            fill_provenance(&mut entry, read_lock_entry_provenance(dir)?);
784                        }
785                    }
786                    let inserted = replace_lock_entry(&mut lock, entry.clone())?;
787                    if inserted {
788                        if let Some(manifest_dir) = manifest_dir {
789                            if let Some(manifest) = read_package_manifest_from_dir(&manifest_dir)? {
790                                enqueue_manifest_dependencies(
791                                    &mut pending,
792                                    manifest,
793                                    manifest_dir,
794                                    alias,
795                                    false,
796                                );
797                            }
798                        }
799                    }
800                } else {
801                    replace_lock_entry(&mut lock, entry)?;
802                }
803                continue;
804            }
805        }
806
807        if !allow_resolve {
808            return Err(format!("{} would need to change", ctx.lock_path().display()).into());
809        }
810
811        let dependency = resolve_registry_version_dependency(workspace, &alias, dependency)?;
812        validate_dependency_source_shape(&alias, &dependency)?;
813        if dependency.requires_git() {
814            ensure_git_available()?;
815            if dependency.git_url().is_some() {
816                git_rev_request(&alias, &dependency)?;
817            }
818        }
819
820        if let Some(path) = dependency.local_path() {
821            let source = resolve_path_dependency_source(&next.manifest_dir, path)?;
822            let package_alias = alias.clone();
823            let manifest_dir = dependency_manifest_dir(&source);
824            let provenance = manifest_dir
825                .as_deref()
826                .map(read_lock_entry_provenance)
827                .transpose()?
828                .unwrap_or_default();
829            let mut entry = LockEntry {
830                name: alias.clone(),
831                source: path_source_uri(&source)?,
832                tag: None,
833                rev_request: None,
834                commit: None,
835                content_hash: None,
836                package_version: None,
837                harn_compat: None,
838                provenance: None,
839                manifest_digest: None,
840                registry: None,
841                exports: PackageLockExports::default(),
842                permissions: Vec::new(),
843                host_requirements: Vec::new(),
844            };
845            fill_provenance(&mut entry, provenance);
846            let inserted = replace_lock_entry(&mut lock, entry)?;
847            if inserted {
848                if let Some(manifest_dir) = manifest_dir {
849                    if let Some(manifest) = read_package_manifest_from_dir(&manifest_dir)? {
850                        enqueue_manifest_dependencies(
851                            &mut pending,
852                            manifest,
853                            manifest_dir,
854                            package_alias,
855                            false,
856                        );
857                    }
858                }
859            }
860            continue;
861        }
862
863        if let Some(url) = dependency.archive_url() {
864            let normalized_url = normalize_archive_url(url)?;
865            let source = format!("archive+{normalized_url}");
866            let expected_hash = dependency_content_hash(&alias, &dependency)?;
867            let content_hash = ensure_archive_cache_populated_in(
868                workspace,
869                &normalized_url,
870                &source,
871                &expected_hash,
872                false,
873                offline,
874            )?;
875            let cache_dir = archive_cache_dir_in(workspace, &source, &content_hash)?;
876            let provenance = read_lock_entry_provenance(&cache_dir)?;
877            let mut entry = LockEntry {
878                name: alias.clone(),
879                source: source.clone(),
880                tag: None,
881                rev_request: None,
882                commit: None,
883                content_hash: Some(content_hash.clone()),
884                package_version: None,
885                harn_compat: None,
886                provenance: None,
887                manifest_digest: None,
888                registry: dependency.registry_provenance(),
889                exports: PackageLockExports::default(),
890                permissions: Vec::new(),
891                host_requirements: Vec::new(),
892            };
893            fill_provenance(&mut entry, provenance);
894            let inserted = replace_lock_entry(&mut lock, entry)?;
895            if inserted {
896                if let Some(manifest) = read_package_manifest_from_dir(&cache_dir)? {
897                    enqueue_manifest_dependencies(&mut pending, manifest, cache_dir, alias, true);
898                }
899            }
900            continue;
901        }
902
903        if let Some(url) = dependency.git_url() {
904            let rev_request = git_rev_request(&alias, &dependency)?;
905            let normalized_url = normalize_git_url(url)?;
906            let source = format!("git+{normalized_url}");
907            let commit = resolve_git_commit(
908                &normalized_url,
909                dependency.rev(),
910                dependency.tag(),
911                dependency.branch(),
912            )?;
913            let content_hash = ensure_git_cache_populated_in(
914                workspace,
915                &normalized_url,
916                &source,
917                &commit,
918                None,
919                false,
920                offline,
921            )?;
922            let cache_dir = git_cache_dir_in(workspace, &source, &commit)?;
923            let provenance = read_lock_entry_provenance(&cache_dir)?;
924            let mut entry = LockEntry {
925                name: alias.clone(),
926                source: source.clone(),
927                tag: dependency.tag().map(str::to_string),
928                rev_request: Some(rev_request),
929                commit: Some(commit.clone()),
930                content_hash: Some(content_hash),
931                package_version: None,
932                harn_compat: None,
933                provenance: None,
934                manifest_digest: None,
935                registry: dependency.registry_provenance(),
936                exports: PackageLockExports::default(),
937                permissions: Vec::new(),
938                host_requirements: Vec::new(),
939            };
940            fill_provenance(&mut entry, provenance);
941            let inserted = replace_lock_entry(&mut lock, entry)?;
942            if inserted {
943                if let Some(manifest) = read_package_manifest_from_dir(&cache_dir)? {
944                    enqueue_manifest_dependencies(&mut pending, manifest, cache_dir, alias, true);
945                }
946            }
947            continue;
948        }
949
950        return Err(format!("dependency {alias} is missing a git, archive, or path source").into());
951    }
952    Ok(lock)
953}
954
955pub(crate) fn materialize_dependencies_from_lock(
956    workspace: &PackageWorkspace,
957    ctx: &ManifestContext,
958    lock: &LockFile,
959    refetch: Option<&str>,
960    offline: bool,
961) -> Result<usize, PackageError> {
962    publish_package_generation(ctx, lock, refetch.is_some(), |packages_dir| {
963        let mut installed = 0usize;
964        for entry in &lock.packages {
965            let alias = &entry.name;
966            validate_package_alias(alias)?;
967            if entry.source.starts_with("path+") {
968                let source = path_from_source_uri(&entry.source)?;
969                materialize_path_dependency(&source, packages_dir, alias)?;
970                installed += 1;
971                continue;
972            }
973
974            let expected_hash = entry
975                .content_hash
976                .as_deref()
977                .ok_or_else(|| format!("missing content hash for {alias}"))?;
978            let source = entry.source.clone();
979            let refetch_this = refetch == Some("all") || refetch == Some(alias.as_str());
980            let cache_dir = if source.starts_with("git+") {
981                let commit = entry
982                    .commit
983                    .as_deref()
984                    .ok_or_else(|| format!("missing locked commit for {alias}"))?;
985                let url = source.trim_start_matches("git+");
986                ensure_git_cache_populated_in(
987                    workspace,
988                    url,
989                    &source,
990                    commit,
991                    Some(expected_hash),
992                    refetch_this,
993                    offline,
994                )?;
995                git_cache_dir_in(workspace, &source, commit)?
996            } else if source.starts_with("archive+") {
997                let url = archive_url_from_source_uri(&source)?;
998                ensure_archive_cache_populated_in(
999                    workspace,
1000                    url,
1001                    &source,
1002                    expected_hash,
1003                    refetch_this,
1004                    offline,
1005                )?;
1006                archive_cache_dir_in(workspace, &source, expected_hash)?
1007            } else {
1008                return Err(
1009                    format!("unsupported locked package source for {alias}: {source}").into(),
1010                );
1011            };
1012            let dest_dir = packages_dir.join(alias);
1013            copy_dir_recursive(&cache_dir, &dest_dir)?;
1014            write_cached_content_hash(&dest_dir, expected_hash)?;
1015            installed += 1;
1016        }
1017        Ok(installed)
1018    })
1019}
1020
1021pub(crate) fn validate_lock_matches_manifest(
1022    workspace: &PackageWorkspace,
1023    ctx: &ManifestContext,
1024    lock: &LockFile,
1025) -> Result<(), PackageError> {
1026    for (alias, dependency) in &ctx.manifest.dependencies {
1027        validate_package_alias(alias)?;
1028        let entry = lock.find(alias).ok_or_else(|| {
1029            format!(
1030                "{} is missing an entry for {alias}",
1031                ctx.lock_path().display()
1032            )
1033        })?;
1034        if !compatible_locked_entry(workspace, alias, dependency, entry, &ctx.dir)? {
1035            return Err(format!(
1036                "{} is out of date for {alias}; run `harn install`",
1037                ctx.lock_path().display()
1038            )
1039            .into());
1040        }
1041    }
1042    Ok(())
1043}
1044
1045pub fn ensure_dependencies_materialized(anchor: &Path) -> Result<(), PackageError> {
1046    let Some((manifest, dir)) = load_nearest_manifest(anchor).into_result()? else {
1047        return Ok(());
1048    };
1049    let ctx = ManifestContext { manifest, dir };
1050    if ctx.manifest.dependencies.is_empty() {
1051        return dependency_package_snapshot(&ctx.manifest, &ctx.dir).map(|_| ());
1052    }
1053    let lock = LockFile::load(&ctx.lock_path())?.ok_or_else(|| {
1054        format!(
1055            "{} is missing; run `harn install`",
1056            ctx.lock_path().display()
1057        )
1058    })?;
1059    let workspace = PackageWorkspace::from_current_dir()?;
1060    validate_lock_matches_manifest(&workspace, &ctx, &lock)?;
1061    materialize_dependencies_from_lock(&workspace, &ctx, &lock, None, false)?;
1062    Ok(())
1063}
1064
1065fn dependency_manifest_item(
1066    alias: &str,
1067    dependency: &Dependency,
1068) -> Result<toml_edit::Item, PackageError> {
1069    validate_package_alias(alias)?;
1070    let mut fields = toml_edit::InlineTable::new();
1071    let table = match dependency {
1072        Dependency::Path(path) => {
1073            fields.insert("path", path.clone().into());
1074            return Ok(toml_edit::Item::Value(fields.into()));
1075        }
1076        Dependency::Table(table) => table,
1077    };
1078    for (name, value) in [
1079        ("path", table.path.as_deref()),
1080        ("git", table.git.as_deref()),
1081        ("archive", table.archive.as_deref()),
1082    ] {
1083        if let Some(value) = value {
1084            fields.insert(name, value.into());
1085        }
1086    }
1087    if let Some(branch) = table.branch.as_deref() {
1088        fields.insert("branch", branch.into());
1089    } else if let Some(tag) = table.tag.as_deref() {
1090        fields.insert("tag", tag.into());
1091    } else if let Some(rev) = table.rev.as_deref() {
1092        fields.insert("rev", rev.into());
1093    }
1094    for (name, value) in [
1095        ("version", table.version.as_deref()),
1096        ("package", table.package.as_deref()),
1097        ("checksum", table.checksum.as_deref()),
1098        ("registry", table.registry.as_deref()),
1099        ("registry_name", table.registry_name.as_deref()),
1100        ("registry_version", table.registry_version.as_deref()),
1101    ] {
1102        if let Some(value) = value {
1103            fields.insert(name, value.into());
1104        }
1105    }
1106    Ok(toml_edit::Item::Value(fields.into()))
1107}
1108
1109pub(crate) fn ensure_manifest_exists(manifest_path: &Path) -> Result<String, PackageError> {
1110    if manifest_path.exists() {
1111        return fs::read_to_string(manifest_path).map_err(|error| {
1112            PackageError::Lockfile(format!(
1113                "failed to read {}: {error}",
1114                manifest_path.display()
1115            ))
1116        });
1117    }
1118    Ok("[package]\nname = \"my-project\"\nversion = \"0.1.0\"\n".to_string())
1119}
1120
1121pub(crate) fn upsert_dependency_in_manifest_locked(
1122    manifest_path: &Path,
1123    alias: &str,
1124    dependency: &Dependency,
1125) -> Result<(), PackageError> {
1126    let content = ensure_manifest_exists(manifest_path)?;
1127    let mut document = content.parse::<toml_edit::DocumentMut>().map_err(|error| {
1128        PackageError::Manifest(format!(
1129            "failed to parse {} for editing: {error}",
1130            manifest_path.display()
1131        ))
1132    })?;
1133    if document.get("dependencies").is_none() {
1134        document["dependencies"] = toml_edit::Item::Table(toml_edit::Table::new());
1135    }
1136    let dependencies = document["dependencies"].as_table_mut().ok_or_else(|| {
1137        PackageError::Manifest(format!(
1138            "[dependencies] in {} is not a table",
1139            manifest_path.display()
1140        ))
1141    })?;
1142    let mut replacement = dependency_manifest_item(alias, dependency)?;
1143    if let Some((_key, existing)) = dependencies.get_key_value_mut(alias) {
1144        if let (Some(old), Some(new)) = (existing.as_value(), replacement.as_value_mut()) {
1145            *new.decor_mut() = old.decor().clone();
1146        }
1147        *existing = replacement;
1148    } else {
1149        dependencies.insert(alias, replacement);
1150    }
1151    write_manifest_content_locked(manifest_path, &document.to_string())
1152}
1153
1154pub(crate) fn remove_dependency_from_manifest_locked(
1155    manifest_path: &Path,
1156    alias: &str,
1157) -> Result<bool, PackageError> {
1158    let content = fs::read_to_string(manifest_path)
1159        .map_err(|error| format!("failed to read {}: {error}", manifest_path.display()))?;
1160    let mut document = content.parse::<toml_edit::DocumentMut>().map_err(|error| {
1161        PackageError::Manifest(format!(
1162            "failed to parse {} for editing: {error}",
1163            manifest_path.display()
1164        ))
1165    })?;
1166    let Some(dependencies) = document
1167        .get_mut("dependencies")
1168        .and_then(toml_edit::Item::as_table_mut)
1169    else {
1170        return Ok(false);
1171    };
1172    if dependencies.remove(alias).is_some() {
1173        write_manifest_content_locked(manifest_path, &document.to_string())?;
1174        return Ok(true);
1175    }
1176    Ok(false)
1177}
1178
1179pub(crate) fn install_packages_impl(
1180    frozen: bool,
1181    refetch: Option<&str>,
1182    offline: bool,
1183) -> Result<usize, PackageError> {
1184    install_packages_in(
1185        &PackageWorkspace::from_current_dir()?,
1186        frozen,
1187        refetch,
1188        offline,
1189    )
1190}
1191
1192pub(crate) fn install_packages_in_locked(
1193    workspace: &PackageWorkspace,
1194    frozen: bool,
1195    refetch: Option<&str>,
1196    offline: bool,
1197) -> Result<usize, PackageError> {
1198    let ctx = workspace.load_manifest_context()?;
1199    let existing = LockFile::load(&ctx.lock_path())?;
1200    if ctx.manifest.dependencies.is_empty() {
1201        let empty = LockFile::default();
1202        if frozen || offline {
1203            // A lock that still pins packages the manifest no longer
1204            // declares is a substantive change; surface it instead of
1205            // silently succeeding against a stale lock.
1206            if existing
1207                .as_ref()
1208                .is_some_and(|lock| !lock.packages.is_empty())
1209            {
1210                return Err(format!("{} would need to change", ctx.lock_path().display()).into());
1211            }
1212        } else {
1213            empty.save(&ctx.lock_path())?;
1214        }
1215        return materialize_dependencies_from_lock(workspace, &ctx, &empty, refetch, offline);
1216    }
1217
1218    if (frozen || offline) && existing.is_none() {
1219        return Err(format!("{} is missing", ctx.lock_path().display()).into());
1220    }
1221
1222    let desired = build_lockfile(
1223        workspace,
1224        &ctx,
1225        existing.as_ref(),
1226        None,
1227        false,
1228        !frozen && !offline,
1229        offline,
1230    )?;
1231    if frozen || offline {
1232        if !existing
1233            .as_ref()
1234            .is_some_and(|lock| lock.same_resolution(&desired))
1235        {
1236            return Err(format!("{} would need to change", ctx.lock_path().display()).into());
1237        }
1238    } else {
1239        desired.save(&ctx.lock_path())?;
1240    }
1241    materialize_dependencies_from_lock(workspace, &ctx, &desired, refetch, offline)
1242}
1243
1244pub fn install_packages(frozen: bool, refetch: Option<&str>, offline: bool, json: bool) {
1245    match install_packages_impl(frozen, refetch, offline) {
1246        Ok(installed) if json => {
1247            print_install_summary_json("install", installed, frozen, offline);
1248        }
1249        Ok(0) => println!("No dependencies to install."),
1250        Ok(installed) => {
1251            println!("Installed {installed} package(s) in a new immutable generation.");
1252        }
1253        Err(error) if json => {
1254            print_install_error_json("install", &error);
1255            process::exit(1);
1256        }
1257        Err(error) => {
1258            eprintln!("error: {error}");
1259            process::exit(1);
1260        }
1261    }
1262}
1263
1264fn print_install_summary_json(action: &str, installed: usize, frozen: bool, offline: bool) {
1265    let body = serde_json::json!({
1266        "action": action,
1267        "ok": true,
1268        "installed": installed,
1269        "frozen": frozen,
1270        "offline": offline,
1271        "lock_file": LOCK_FILE,
1272        "package_pointer": ".harn/package-current.toml",
1273    });
1274    println!(
1275        "{}",
1276        serde_json::to_string_pretty(&body).unwrap_or_default()
1277    );
1278}
1279
1280fn print_install_error_json(action: &str, error: &PackageError) {
1281    let body = serde_json::json!({
1282        "action": action,
1283        "ok": false,
1284        "error": error.to_string(),
1285    });
1286    println!(
1287        "{}",
1288        serde_json::to_string_pretty(&body).unwrap_or_default()
1289    );
1290}
1291pub fn lock_packages() {
1292    let result = (|| -> Result<usize, PackageError> {
1293        let workspace = PackageWorkspace::from_current_dir()?;
1294        let _mutation_lock = acquire_package_mutation_lock(&workspace)?;
1295        let ctx = workspace.load_manifest_context()?;
1296        let existing = LockFile::load(&ctx.lock_path())?;
1297        let lock = build_lockfile(&workspace, &ctx, existing.as_ref(), None, true, true, false)?;
1298        lock.save(&ctx.lock_path())?;
1299        Ok(lock.packages.len())
1300    })();
1301
1302    match result {
1303        Ok(count) => println!("Wrote {LOCK_FILE} with {count} package(s)."),
1304        Err(error) => {
1305            eprintln!("error: {error}");
1306            process::exit(1);
1307        }
1308    }
1309}
1310pub fn update_packages(alias: Option<&str>, all: bool, json: bool) {
1311    let result = PackageWorkspace::from_current_dir()
1312        .and_then(|workspace| update_packages_in(&workspace, alias, all));
1313    print_update_packages_result(result, json);
1314}
1315
1316pub(crate) fn update_packages_in(
1317    workspace: &PackageWorkspace,
1318    alias: Option<&str>,
1319    all: bool,
1320) -> Result<usize, PackageError> {
1321    let _mutation_lock = acquire_package_mutation_lock(workspace)?;
1322    if !all && alias.is_none() {
1323        return Err("specify a dependency alias or pass --all"
1324            .to_string()
1325            .into());
1326    }
1327
1328    let ctx = workspace.load_manifest_context()?;
1329    if let Some(alias) = alias {
1330        validate_package_alias(alias)?;
1331        if !ctx.manifest.dependencies.contains_key(alias) {
1332            return Err(format!("{alias} is not present in [dependencies]").into());
1333        }
1334    }
1335    let existing = LockFile::load(&ctx.lock_path())?;
1336    let lock = build_lockfile(workspace, &ctx, existing.as_ref(), alias, all, true, false)?;
1337    lock.save(&ctx.lock_path())?;
1338    materialize_dependencies_from_lock(workspace, &ctx, &lock, None, false)
1339}
1340
1341fn print_update_packages_result(result: Result<usize, PackageError>, json: bool) {
1342    match result {
1343        Ok(installed) if json => print_install_summary_json("update", installed, false, false),
1344        Ok(installed) => println!("Updated {installed} package(s)."),
1345        Err(error) if json => {
1346            print_install_error_json("update", &error);
1347            process::exit(1);
1348        }
1349        Err(error) => {
1350            eprintln!("error: {error}");
1351            process::exit(1);
1352        }
1353    }
1354}
1355pub fn remove_package(alias: &str) {
1356    let result = PackageWorkspace::from_current_dir()
1357        .and_then(|workspace| remove_package_in(&workspace, alias));
1358    print_remove_package_result(alias, result);
1359}
1360
1361pub(crate) fn remove_package_in(
1362    workspace: &PackageWorkspace,
1363    alias: &str,
1364) -> Result<bool, PackageError> {
1365    let _mutation_lock = acquire_package_mutation_lock(workspace)?;
1366    validate_package_alias(alias)?;
1367    let ctx = workspace.load_manifest_context()?;
1368    let removed = remove_dependency_from_manifest(&ctx.manifest_path(), alias)?;
1369    if !removed {
1370        return Ok(false);
1371    }
1372    let mut lock = LockFile::load(&ctx.lock_path())?.unwrap_or_default();
1373    lock.remove(alias);
1374    lock.save(&ctx.lock_path())?;
1375    materialize_dependencies_from_lock(workspace, &ctx, &lock, None, false)?;
1376    Ok(true)
1377}
1378
1379fn print_remove_package_result(alias: &str, result: Result<bool, PackageError>) {
1380    match result {
1381        Ok(true) => println!("Removed {alias} from {MANIFEST} and {LOCK_FILE}."),
1382        Ok(false) => {
1383            eprintln!("error: {alias} is not present in [dependencies]");
1384            process::exit(1);
1385        }
1386        Err(error) => {
1387            eprintln!("error: {error}");
1388            process::exit(1);
1389        }
1390    }
1391}
1392
1393#[derive(Clone, Copy, Debug)]
1394pub(crate) struct AddPackageRequest<'a> {
1395    name_or_spec: &'a str,
1396    alias: Option<&'a str>,
1397    git_url: Option<&'a str>,
1398    tag: Option<&'a str>,
1399    rev: Option<&'a str>,
1400    branch: Option<&'a str>,
1401    local_path: Option<&'a str>,
1402    registry: Option<&'a str>,
1403}
1404
1405#[cfg(test)]
1406#[allow(clippy::too_many_arguments)]
1407pub(crate) fn normalize_add_request(
1408    name_or_spec: &str,
1409    alias: Option<&str>,
1410    git_url: Option<&str>,
1411    tag: Option<&str>,
1412    rev: Option<&str>,
1413    branch: Option<&str>,
1414    local_path: Option<&str>,
1415    registry: Option<&str>,
1416) -> Result<(String, Dependency), PackageError> {
1417    normalize_add_request_in(
1418        &PackageWorkspace::from_current_dir()?,
1419        AddPackageRequest {
1420            name_or_spec,
1421            alias,
1422            git_url,
1423            tag,
1424            rev,
1425            branch,
1426            local_path,
1427            registry,
1428        },
1429    )
1430}
1431
1432pub(crate) fn normalize_add_request_in(
1433    workspace: &PackageWorkspace,
1434    request: AddPackageRequest<'_>,
1435) -> Result<(String, Dependency), PackageError> {
1436    let AddPackageRequest {
1437        name_or_spec,
1438        alias,
1439        git_url,
1440        tag,
1441        rev,
1442        branch,
1443        local_path,
1444        registry,
1445    } = request;
1446
1447    if local_path.is_some() && (rev.is_some() || tag.is_some() || branch.is_some()) {
1448        return Err("path dependencies do not accept --rev, --tag, or --branch"
1449            .to_string()
1450            .into());
1451    }
1452    if git_url.is_none()
1453        && local_path.is_none()
1454        && rev.is_none()
1455        && tag.is_none()
1456        && branch.is_none()
1457    {
1458        if let Some(path) = existing_local_path_spec(name_or_spec) {
1459            let alias = alias
1460                .map(str::to_string)
1461                .map(Ok)
1462                .unwrap_or_else(|| derive_package_alias_from_path(&path))?;
1463            validate_package_alias(&alias)?;
1464            return Ok((
1465                alias,
1466                Dependency::Table(Box::new(DepTable {
1467                    path: Some(name_or_spec.to_string()),
1468                    ..DepTable::default()
1469                })),
1470            ));
1471        }
1472        if parse_registry_package_spec(name_or_spec).is_some() {
1473            return registry_dependency_from_spec_in(workspace, name_or_spec, alias, registry);
1474        }
1475    }
1476    if git_url.is_some() || local_path.is_some() {
1477        if let Some(path) = local_path {
1478            let alias = alias
1479                .map(str::to_string)
1480                .unwrap_or_else(|| name_or_spec.to_string());
1481            validate_package_alias(&alias)?;
1482            return Ok((
1483                alias,
1484                Dependency::Table(Box::new(DepTable {
1485                    path: Some(path.to_string()),
1486                    ..DepTable::default()
1487                })),
1488            ));
1489        }
1490        let alias = alias.unwrap_or(name_or_spec).to_string();
1491        validate_package_alias(&alias)?;
1492        if rev.is_some() && tag.is_some() {
1493            return Err("use only one of --rev or --tag".to_string().into());
1494        }
1495        if rev.is_none() && tag.is_none() && branch.is_none() {
1496            return Err(format!(
1497                "git dependency {alias} must specify `tag`, `rev`, or `branch`; use `harn add <url>@<tag-or-sha>` or pass `--tag`/`--rev`/`--branch`"
1498            ).into());
1499        }
1500        let git = normalize_git_url(git_url.ok_or_else(|| "missing --git URL".to_string())?)?;
1501        let package_name = derive_repo_name_from_source(&git)?;
1502        return Ok((
1503            alias.clone(),
1504            Dependency::Table(Box::new(DepTable {
1505                git: Some(git),
1506                tag: tag.map(str::to_string),
1507                rev: rev.map(str::to_string),
1508                branch: branch.map(str::to_string),
1509                package: (alias != package_name).then_some(package_name),
1510                ..DepTable::default()
1511            })),
1512        ));
1513    }
1514
1515    if rev.is_some() && tag.is_some() {
1516        return Err("use only one of --rev or --tag".to_string().into());
1517    }
1518    let (raw_source, inline_ref) = parse_positional_git_spec(name_or_spec);
1519    if inline_ref.is_some() && (rev.is_some() || tag.is_some() || branch.is_some()) {
1520        return Err(
1521            "specify the git ref either inline as @ref or via --tag/--rev/--branch"
1522                .to_string()
1523                .into(),
1524        );
1525    }
1526    let git = normalize_git_url(raw_source)?;
1527    let package_name = derive_repo_name_from_source(&git)?;
1528    let alias = alias.unwrap_or(package_name.as_str()).to_string();
1529    validate_package_alias(&alias)?;
1530    if inline_ref.is_none() && rev.is_none() && tag.is_none() && branch.is_none() {
1531        return Err(format!(
1532            "git dependency {alias} must specify `tag`, `rev`, or `branch`; use `harn add {raw_source}@<tag-or-sha>` or pass `--tag`/`--rev`/`--branch`"
1533        ).into());
1534    }
1535    Ok((
1536        alias.clone(),
1537        Dependency::Table(Box::new(DepTable {
1538            git: Some(git),
1539            tag: tag.map(str::to_string),
1540            rev: inline_ref.or(rev).map(str::to_string),
1541            branch: branch.map(str::to_string),
1542            package: (alias != package_name).then_some(package_name),
1543            ..DepTable::default()
1544        })),
1545    ))
1546}
1547
1548#[cfg(test)]
1549pub fn add_package(
1550    name_or_spec: &str,
1551    alias: Option<&str>,
1552    git_url: Option<&str>,
1553    tag: Option<&str>,
1554    rev: Option<&str>,
1555    branch: Option<&str>,
1556    local_path: Option<&str>,
1557) {
1558    add_package_with_registry(
1559        name_or_spec,
1560        alias,
1561        git_url,
1562        tag,
1563        rev,
1564        branch,
1565        local_path,
1566        None,
1567    );
1568}
1569
1570pub fn add_package_with_registry(
1571    name_or_spec: &str,
1572    alias: Option<&str>,
1573    git_url: Option<&str>,
1574    tag: Option<&str>,
1575    rev: Option<&str>,
1576    branch: Option<&str>,
1577    local_path: Option<&str>,
1578    registry: Option<&str>,
1579) {
1580    let result = PackageWorkspace::from_current_dir().and_then(|workspace| {
1581        add_package_to(
1582            &workspace,
1583            name_or_spec,
1584            alias,
1585            git_url,
1586            tag,
1587            rev,
1588            branch,
1589            local_path,
1590            registry,
1591        )
1592    });
1593
1594    match result {
1595        Ok((alias, installed)) => {
1596            println!("Added {alias} to {MANIFEST}.");
1597            println!("Installed {installed} package(s).");
1598        }
1599        Err(error) => {
1600            eprintln!("error: {error}");
1601            process::exit(1);
1602        }
1603    }
1604}
1605#[allow(clippy::too_many_arguments)]
1606pub(crate) fn add_package_to(
1607    workspace: &PackageWorkspace,
1608    name_or_spec: &str,
1609    alias: Option<&str>,
1610    git_url: Option<&str>,
1611    tag: Option<&str>,
1612    rev: Option<&str>,
1613    branch: Option<&str>,
1614    local_path: Option<&str>,
1615    registry: Option<&str>,
1616) -> Result<(String, usize), PackageError> {
1617    let _mutation_lock = acquire_package_mutation_lock(workspace)?;
1618    let manifest_path = workspace.manifest_dir().join(MANIFEST);
1619    let (alias, dependency) = normalize_add_request_in(
1620        workspace,
1621        AddPackageRequest {
1622            name_or_spec,
1623            alias,
1624            git_url,
1625            tag,
1626            rev,
1627            branch,
1628            local_path,
1629            registry,
1630        },
1631    )?;
1632    upsert_dependency_in_manifest(&manifest_path, &alias, &dependency)?;
1633    let installed = install_packages_in_locked(workspace, false, None, false)?;
1634    Ok((alias, installed))
1635}
1636
1637#[cfg(test)]
1638mod tests {
1639    use super::*;
1640    use crate::package::test_support::*;
1641
1642    fn write_tar_gz_package_archive(root: &Path, archive_path: &Path) {
1643        fn append_files(
1644            builder: &mut tar::Builder<flate2::write::GzEncoder<File>>,
1645            root: &Path,
1646            cursor: &Path,
1647        ) {
1648            let mut entries = fs::read_dir(cursor)
1649                .unwrap()
1650                .map(|entry| entry.unwrap().path())
1651                .collect::<Vec<_>>();
1652            entries.sort();
1653            for path in entries {
1654                if path.is_dir() {
1655                    append_files(builder, root, &path);
1656                } else {
1657                    let relative = path.strip_prefix(root).unwrap();
1658                    builder.append_path_with_name(&path, relative).unwrap();
1659                }
1660            }
1661        }
1662
1663        let file = File::create(archive_path).unwrap();
1664        let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
1665        let mut builder = tar::Builder::new(encoder);
1666        append_files(&mut builder, root, root);
1667        let encoder = builder.into_inner().unwrap();
1668        encoder.finish().unwrap();
1669    }
1670
1671    #[test]
1672    fn lock_file_round_trips_typed_schema() {
1673        let tmp = tempfile::tempdir().unwrap();
1674        let path = tmp.path().join(LOCK_FILE);
1675        let lock = LockFile {
1676            version: LOCK_FILE_VERSION,
1677            generator_version: current_generator_version(),
1678            protocol_artifact_version: current_protocol_artifact_version(),
1679            packages: vec![LockEntry {
1680                name: "acme-lib".to_string(),
1681                source: "git+https://github.com/acme/acme-lib".to_string(),
1682                tag: Some("v1.0.0".to_string()),
1683                rev_request: Some("v1.0.0".to_string()),
1684                commit: Some("0123456789abcdef0123456789abcdef01234567".to_string()),
1685                content_hash: Some("sha256:deadbeef".to_string()),
1686                package_version: Some("1.0.0".to_string()),
1687                harn_compat: Some(">=0.8,<0.9".to_string()),
1688                provenance: Some(
1689                    "https://github.com/acme/acme-lib/releases/tag/v1.0.0".to_string(),
1690                ),
1691                manifest_digest: Some("sha256:cafebabe".to_string()),
1692                registry: None,
1693                exports: PackageLockExports {
1694                    modules: vec![PackageLockExport {
1695                        name: "lib".to_string(),
1696                        path: Some("lib/main.harn".to_string()),
1697                        symbol: None,
1698                    }],
1699                    tools: vec![PackageLockExport {
1700                        name: "echo".to_string(),
1701                        path: Some("lib/tools.harn".to_string()),
1702                        symbol: Some("tools".to_string()),
1703                    }],
1704                    skills: Vec::new(),
1705                    personas: Vec::new(),
1706                },
1707                permissions: vec!["tool:read_only".to_string()],
1708                host_requirements: vec!["workspace.read_text".to_string()],
1709            }],
1710        };
1711        lock.save(&path).unwrap();
1712        let loaded = LockFile::load(&path).unwrap().unwrap();
1713        assert_eq!(loaded, lock);
1714    }
1715
1716    #[test]
1717    fn add_and_remove_git_dependency_round_trip() {
1718        let (_repo_tmp, repo, _branch) = create_git_package_repo();
1719        let project_tmp = tempfile::tempdir().unwrap();
1720        let root = project_tmp.path();
1721        let workspace = TestWorkspace::new(root);
1722        fs::create_dir_all(root.join(".git")).unwrap();
1723        fs::write(
1724            root.join(MANIFEST),
1725            r#"
1726    [package]
1727    name = "workspace"
1728    version = "0.1.0"
1729    "#,
1730        )
1731        .unwrap();
1732
1733        let spec = format!("{}@v1.0.0", repo.display());
1734        add_package_to(
1735            workspace.env(),
1736            &spec,
1737            None,
1738            None,
1739            None,
1740            None,
1741            None,
1742            None,
1743            None,
1744        )
1745        .unwrap();
1746
1747        let alias = "acme-lib";
1748        let manifest = fs::read_to_string(root.join(MANIFEST)).unwrap();
1749        assert!(manifest.contains("acme-lib"));
1750        assert!(manifest.contains("rev = \"v1.0.0\""));
1751
1752        let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
1753        let entry = lock.find(alias).unwrap();
1754        assert_eq!(lock.version, LOCK_FILE_VERSION);
1755        assert!(entry.source.starts_with("git+file://"));
1756        assert!(entry.commit.as_deref().is_some_and(is_full_git_sha));
1757        assert!(entry
1758            .content_hash
1759            .as_deref()
1760            .is_some_and(|hash| hash.starts_with("sha256:")));
1761        assert!(current_packages_dir(root)
1762            .join(alias)
1763            .join("lib.harn")
1764            .is_file());
1765
1766        remove_package_in(workspace.env(), alias).unwrap();
1767        let updated_manifest = fs::read_to_string(root.join(MANIFEST)).unwrap();
1768        assert!(!updated_manifest.contains("acme-lib ="));
1769        let updated_lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
1770        assert!(updated_lock.find(alias).is_none());
1771        assert!(!current_packages_dir(root).join(alias).exists());
1772    }
1773
1774    #[test]
1775    fn install_resolves_git_tag_dependency_and_records_tag() {
1776        let (_repo_tmp, repo, _branch) = create_git_package_repo();
1777        let project_tmp = tempfile::tempdir().unwrap();
1778        let root = project_tmp.path();
1779        let workspace = TestWorkspace::new(root);
1780        fs::create_dir_all(root.join(".git")).unwrap();
1781        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1782        fs::write(
1783            root.join(MANIFEST),
1784            format!(
1785                r#"
1786    [package]
1787    name = "workspace"
1788    version = "0.1.0"
1789
1790    [dependencies]
1791    acme-lib = {{ git = "{git}", tag = "v1.0.0" }}
1792    "#
1793            ),
1794        )
1795        .unwrap();
1796
1797        let installed = install_packages_in(workspace.env(), false, None, false).unwrap();
1798
1799        assert_eq!(installed, 1);
1800        let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
1801        let entry = lock.find("acme-lib").unwrap();
1802        assert_eq!(entry.tag.as_deref(), Some("v1.0.0"));
1803        assert_eq!(entry.rev_request.as_deref(), Some("v1.0.0"));
1804        assert!(entry.commit.as_deref().is_some_and(is_full_git_sha));
1805        assert!(entry.content_hash.as_deref().is_some());
1806        assert!(current_packages_dir(root)
1807            .join("acme-lib")
1808            .join("lib.harn")
1809            .is_file());
1810    }
1811
1812    #[test]
1813    fn concurrent_materialization_serializes_package_tree_updates() {
1814        let (_repo_tmp, repo, _branch) = create_git_package_repo();
1815        let project_tmp = tempfile::tempdir().unwrap();
1816        let root = project_tmp.path();
1817        let workspace = TestWorkspace::new(root);
1818        fs::create_dir_all(root.join(".git")).unwrap();
1819        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1820        fs::write(
1821            root.join(MANIFEST),
1822            format!(
1823                r#"
1824    [package]
1825    name = "workspace"
1826    version = "0.1.0"
1827
1828    [dependencies]
1829    acme-lib = {{ git = "{git}", tag = "v1.0.0" }}
1830    "#
1831            ),
1832        )
1833        .unwrap();
1834
1835        install_packages_in(workspace.env(), false, None, false).unwrap();
1836        fs::write(
1837            current_packages_dir(root).join("acme-lib").join("lib.harn"),
1838            "pub fn value() -> string { return \"stale\" }\n",
1839        )
1840        .unwrap();
1841
1842        let ctx = workspace.env().load_manifest_context().unwrap();
1843        let lock = LockFile::load(&ctx.lock_path()).unwrap().unwrap();
1844        let workspace_env = workspace.env().clone();
1845        let handles = (0..8)
1846            .map(|_| {
1847                let workspace_env = workspace_env.clone();
1848                let ctx = ctx.clone();
1849                let lock = lock.clone();
1850                std::thread::spawn(move || {
1851                    materialize_dependencies_from_lock(&workspace_env, &ctx, &lock, None, false)
1852                })
1853            })
1854            .collect::<Vec<_>>();
1855
1856        for handle in handles {
1857            handle.join().unwrap().unwrap();
1858        }
1859
1860        let materialized =
1861            fs::read_to_string(current_packages_dir(root).join("acme-lib").join("lib.harn"))
1862                .unwrap();
1863        assert!(materialized.contains("return \"v1\""));
1864    }
1865
1866    #[test]
1867    fn install_resolves_registry_version_range_to_highest_matching_tag() {
1868        let (_repo_tmp, repo, _branch) = create_git_package_repo();
1869        fs::write(
1870            repo.join("lib.harn"),
1871            "pub fn value() -> string { return \"v0.1.1\" }\n",
1872        )
1873        .unwrap();
1874        run_git(&repo, &["add", "."]);
1875        run_git(&repo, &["commit", "-m", "v0.1.1"]);
1876        run_git(&repo, &["tag", "v0.1.1"]);
1877        fs::write(
1878            repo.join("lib.harn"),
1879            "pub fn value() -> string { return \"v0.2.0\" }\n",
1880        )
1881        .unwrap();
1882        run_git(&repo, &["add", "."]);
1883        run_git(&repo, &["commit", "-m", "v0.2.0"]);
1884        run_git(&repo, &["tag", "v0.2.0"]);
1885
1886        let project_tmp = tempfile::tempdir().unwrap();
1887        let root = project_tmp.path();
1888        let registry_path = root.join("index.toml");
1889        let workspace =
1890            TestWorkspace::new(root).with_registry_source(registry_path.display().to_string());
1891        fs::create_dir_all(root.join(".git")).unwrap();
1892        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1893        fs::write(
1894            &registry_path,
1895            format!(
1896                r#"
1897version = 1
1898
1899[[package]]
1900name = "acme-lib"
1901repository = "{git}"
1902
1903[[package.version]]
1904version = "0.1.0"
1905git = "{git}"
1906tag = "v1.0.0"
1907
1908[[package.version]]
1909version = "0.1.1"
1910git = "{git}"
1911tag = "v0.1.1"
1912
1913[[package.version]]
1914version = "0.2.0"
1915git = "{git}"
1916tag = "v0.2.0"
1917"#
1918            ),
1919        )
1920        .unwrap();
1921        fs::write(
1922            root.join(MANIFEST),
1923            r#"
1924    [package]
1925    name = "workspace"
1926    version = "0.1.0"
1927
1928    [dependencies]
1929    acme-lib = { version = ">=0.1,<0.2" }
1930    "#,
1931        )
1932        .unwrap();
1933
1934        let installed = install_packages_in(workspace.env(), false, None, false).unwrap();
1935
1936        assert_eq!(installed, 1);
1937        let lock_path = root.join(LOCK_FILE);
1938        let lock = LockFile::load(&lock_path).unwrap().unwrap();
1939        let entry = lock.find("acme-lib").unwrap();
1940        assert_eq!(entry.tag.as_deref(), Some("v0.1.1"));
1941        assert_eq!(entry.rev_request.as_deref(), Some("v0.1.1"));
1942        assert_eq!(
1943            entry
1944                .registry
1945                .as_ref()
1946                .map(|registry| registry.version.as_str()),
1947            Some("0.1.1")
1948        );
1949        let source =
1950            fs::read_to_string(current_packages_dir(root).join("acme-lib").join("lib.harn"))
1951                .unwrap();
1952        assert!(source.contains("v0.1.1"), "{source}");
1953
1954        let original_lock = fs::read_to_string(&lock_path).unwrap();
1955        fs::remove_dir_all(current_packages_dir(root)).unwrap();
1956        fs::remove_dir_all(&repo).unwrap();
1957        fs::remove_file(&registry_path).unwrap();
1958
1959        let reinstalled = install_packages_in(workspace.env(), true, None, true).unwrap();
1960        assert_eq!(reinstalled, 1);
1961        assert_eq!(fs::read_to_string(&lock_path).unwrap(), original_lock);
1962        assert!(current_packages_dir(root)
1963            .join("acme-lib")
1964            .join("lib.harn")
1965            .is_file());
1966    }
1967
1968    #[test]
1969    fn registry_archive_dependency_materializes_and_reinstalls_offline() {
1970        let package_tmp = tempfile::tempdir().unwrap();
1971        let package_root = package_tmp.path().join("acme-rules");
1972        fs::create_dir_all(package_root.join("rules")).unwrap();
1973        fs::write(
1974            package_root.join(MANIFEST),
1975            r#"
1976    [package]
1977    name = "acme-rules"
1978    version = "1.0.0"
1979
1980    [rules]
1981    ruleDirs = ["rules"]
1982    "#,
1983        )
1984        .unwrap();
1985        fs::write(
1986            package_root.join("rules/no_todo.harn"),
1987            "pub fn rule() -> string { return \"no todo\" }\n",
1988        )
1989        .unwrap();
1990        let checksum = compute_content_hash(&package_root).unwrap();
1991
1992        let project_tmp = tempfile::tempdir().unwrap();
1993        let root = project_tmp.path();
1994        let registry_path = root.join("index.toml");
1995        let archive_path = root.join("acme-rules-1.0.0.tar.gz");
1996        write_tar_gz_package_archive(&package_root, &archive_path);
1997        let archive = normalize_archive_url(archive_path.to_string_lossy().as_ref()).unwrap();
1998        let workspace =
1999            TestWorkspace::new(root).with_registry_source(registry_path.display().to_string());
2000        fs::create_dir_all(root.join(".git")).unwrap();
2001        fs::write(
2002            &registry_path,
2003            format!(
2004                r#"
2005version = 1
2006
2007[[package]]
2008name = "@acme/rules"
2009description = "Rule pack"
2010repository = "https://github.com/acme/rules"
2011
2012[package.rule_pack]
2013rule_count = 1
2014languages = ["harn"]
2015safety_summary = ["advisory:1"]
2016
2017[[package.version]]
2018version = "1.0.0"
2019archive = "{archive}"
2020package = "acme-rules"
2021checksum = "{checksum}"
2022"#
2023            ),
2024        )
2025        .unwrap();
2026        fs::write(
2027            root.join(MANIFEST),
2028            r#"
2029    [package]
2030    name = "workspace"
2031    version = "0.1.0"
2032    "#,
2033        )
2034        .unwrap();
2035
2036        let (alias, installed) = add_package_to(
2037            workspace.env(),
2038            "@acme/rules@1.0.0",
2039            None,
2040            None,
2041            None,
2042            None,
2043            None,
2044            None,
2045            None,
2046        )
2047        .unwrap();
2048
2049        assert_eq!(alias, "acme-rules");
2050        assert_eq!(installed, 1);
2051        let manifest = fs::read_to_string(root.join(MANIFEST)).unwrap();
2052        assert!(manifest.contains("archive = "));
2053        assert!(manifest.contains(&format!("checksum = \"{checksum}\"")));
2054        assert!(manifest.contains("registry_name = \"@acme/rules\""));
2055        let lock_path = root.join(LOCK_FILE);
2056        let lock = LockFile::load(&lock_path).unwrap().unwrap();
2057        let entry = lock.find("acme-rules").unwrap();
2058        assert!(entry.source.starts_with("archive+file://"));
2059        assert_eq!(entry.content_hash.as_deref(), Some(checksum.as_str()));
2060        assert!(entry.commit.is_none());
2061        assert_eq!(
2062            entry
2063                .registry
2064                .as_ref()
2065                .map(|registry| registry.name.as_str()),
2066            Some("@acme/rules")
2067        );
2068        assert!(current_packages_dir(root)
2069            .join("acme-rules")
2070            .join("rules/no_todo.harn")
2071            .is_file());
2072
2073        let original_lock = fs::read_to_string(&lock_path).unwrap();
2074        fs::remove_dir_all(current_packages_dir(root)).unwrap();
2075        fs::remove_file(&archive_path).unwrap();
2076        let reinstalled = install_packages_in(workspace.env(), true, None, true).unwrap();
2077        assert_eq!(reinstalled, 1);
2078        assert_eq!(fs::read_to_string(&lock_path).unwrap(), original_lock);
2079        assert!(current_packages_dir(root)
2080            .join("acme-rules")
2081            .join("rules/no_todo.harn")
2082            .is_file());
2083    }
2084
2085    #[test]
2086    fn update_branch_dependency_refreshes_locked_commit() {
2087        let (_repo_tmp, repo, branch) = create_git_package_repo();
2088        let project_tmp = tempfile::tempdir().unwrap();
2089        let root = project_tmp.path();
2090        let workspace = TestWorkspace::new(root);
2091        fs::create_dir_all(root.join(".git")).unwrap();
2092        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
2093        fs::write(
2094            root.join(MANIFEST),
2095            format!(
2096                r#"
2097    [package]
2098    name = "workspace"
2099    version = "0.1.0"
2100
2101    [dependencies]
2102    acme-lib = {{ git = "{git}", branch = "{branch}" }}
2103    "#
2104            ),
2105        )
2106        .unwrap();
2107
2108        let installed = install_packages_in(workspace.env(), false, None, false).unwrap();
2109        assert_eq!(installed, 1);
2110        let first_lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
2111        let first_commit = first_lock
2112            .find("acme-lib")
2113            .and_then(|entry| entry.commit.clone())
2114            .unwrap();
2115
2116        fs::write(
2117            repo.join("lib.harn"),
2118            "pub fn value() -> string { return \"v2\" }\n",
2119        )
2120        .unwrap();
2121        run_git(&repo, &["add", "."]);
2122        run_git(&repo, &["commit", "-m", "update"]);
2123
2124        update_packages_in(workspace.env(), Some("acme-lib"), false).unwrap();
2125        let second_lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
2126        let second_commit = second_lock
2127            .find("acme-lib")
2128            .and_then(|entry| entry.commit.clone())
2129            .unwrap();
2130        assert_ne!(first_commit, second_commit);
2131    }
2132
2133    #[test]
2134    fn add_positional_local_path_dependency_uses_manifest_name_and_live_link() {
2135        let dependency_tmp = tempfile::tempdir().unwrap();
2136        let dependency_root = dependency_tmp.path().join("harn-openapi");
2137        fs::create_dir_all(&dependency_root).unwrap();
2138        fs::write(
2139            dependency_root.join(MANIFEST),
2140            r#"
2141    [package]
2142    name = "openapi"
2143    version = "0.1.0"
2144    "#,
2145        )
2146        .unwrap();
2147        fs::write(
2148            dependency_root.join("lib.harn"),
2149            "pub fn version() -> string { return \"v1\" }\n",
2150        )
2151        .unwrap();
2152
2153        let project_tmp = tempfile::tempdir().unwrap();
2154        let root = project_tmp.path();
2155        let workspace = TestWorkspace::new(root);
2156        fs::create_dir_all(root.join(".git")).unwrap();
2157        fs::write(
2158            root.join(MANIFEST),
2159            r#"
2160    [package]
2161    name = "workspace"
2162    version = "0.1.0"
2163    "#,
2164        )
2165        .unwrap();
2166
2167        add_package_to(
2168            workspace.env(),
2169            dependency_root.to_string_lossy().as_ref(),
2170            None,
2171            None,
2172            None,
2173            None,
2174            None,
2175            None,
2176            None,
2177        )
2178        .unwrap();
2179
2180        let manifest = fs::read_to_string(root.join(MANIFEST)).unwrap();
2181        assert!(
2182            manifest.contains("openapi = { path = "),
2183            "manifest should use package.name as alias: {manifest}"
2184        );
2185        let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
2186        let entry = lock.find("openapi").expect("openapi lock entry");
2187        assert!(entry.source.starts_with("path+file://"));
2188        let materialized = current_packages_dir(root).join("openapi");
2189        assert!(materialized.join("lib.harn").is_file());
2190
2191        #[cfg(unix)]
2192        assert!(
2193            fs::symlink_metadata(&materialized)
2194                .unwrap()
2195                .file_type()
2196                .is_symlink(),
2197            "path dependencies should be live-linked on Unix"
2198        );
2199
2200        #[cfg(windows)]
2201        let materialized_is_link = fs::symlink_metadata(&materialized)
2202            .unwrap()
2203            .file_type()
2204            .is_symlink();
2205
2206        fs::write(
2207            dependency_root.join("lib.harn"),
2208            "pub fn version() -> string { return \"v2\" }\n",
2209        )
2210        .unwrap();
2211        #[cfg(unix)]
2212        {
2213            let live_source = fs::read_to_string(materialized.join("lib.harn")).unwrap();
2214            assert!(
2215                live_source.contains("v2"),
2216                "materialized path dependency should reflect sibling repo edits"
2217            );
2218        }
2219        #[cfg(windows)]
2220        {
2221            let materialized_source = fs::read_to_string(materialized.join("lib.harn")).unwrap();
2222            if materialized_is_link {
2223                assert!(
2224                    materialized_source.contains("v2"),
2225                    "Windows path dependency symlink should reflect sibling repo edits"
2226                );
2227            } else {
2228                assert!(
2229                    materialized_source.contains("v1"),
2230                    "Windows path dependency copy fallback should keep the copied contents"
2231                );
2232            }
2233        }
2234
2235        remove_package_in(workspace.env(), "openapi").unwrap();
2236        assert!(!materialized.exists());
2237        assert!(dependency_root.join("lib.harn").exists());
2238    }
2239
2240    #[test]
2241    fn frozen_install_errors_when_lockfile_is_missing() {
2242        let (_repo_tmp, repo, _branch) = create_git_package_repo();
2243        let project_tmp = tempfile::tempdir().unwrap();
2244        let root = project_tmp.path();
2245        let workspace = TestWorkspace::new(root);
2246        fs::create_dir_all(root.join(".git")).unwrap();
2247        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
2248        fs::write(
2249            root.join(MANIFEST),
2250            format!(
2251                r#"
2252    [package]
2253    name = "workspace"
2254    version = "0.1.0"
2255
2256    [dependencies]
2257    acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
2258    "#
2259            ),
2260        )
2261        .unwrap();
2262
2263        let error = install_packages_in(workspace.env(), true, None, false).unwrap_err();
2264        assert!(error.to_string().contains(LOCK_FILE));
2265    }
2266
2267    #[test]
2268    fn frozen_install_tolerates_provenance_stamp_drift() {
2269        let (_repo_tmp, repo, _branch) = create_git_package_repo();
2270        let project_tmp = tempfile::tempdir().unwrap();
2271        let root = project_tmp.path();
2272        let workspace = TestWorkspace::new(root);
2273        fs::create_dir_all(root.join(".git")).unwrap();
2274        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
2275        fs::write(
2276            root.join(MANIFEST),
2277            format!(
2278                r#"
2279    [package]
2280    name = "workspace"
2281    version = "0.1.0"
2282
2283    [dependencies]
2284    acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
2285    "#
2286            ),
2287        )
2288        .unwrap();
2289
2290        let installed = install_packages_in(workspace.env(), false, None, false).unwrap();
2291        assert_eq!(installed, 1);
2292
2293        // Simulate a lock written by an older Harn release: identical
2294        // resolution, stale provenance stamps. A release bump must not
2295        // break `harn install --locked`.
2296        let lock_path = root.join(LOCK_FILE);
2297        let stale = fs::read_to_string(&lock_path)
2298            .unwrap()
2299            .replace(
2300                &format!("generator_version = \"{}\"", current_generator_version()),
2301                "generator_version = \"0.0.1\"",
2302            )
2303            .replace(
2304                &format!(
2305                    "protocol_artifact_version = \"{}\"",
2306                    current_protocol_artifact_version()
2307                ),
2308                "protocol_artifact_version = \"0.0.1\"",
2309            );
2310        assert!(
2311            stale.contains("generator_version = \"0.0.1\""),
2312            "test should have rewritten the provenance stamps: {stale}"
2313        );
2314        fs::write(&lock_path, stale).unwrap();
2315
2316        let installed = install_packages_in(workspace.env(), true, None, false).unwrap();
2317        assert_eq!(installed, 1);
2318
2319        // Frozen install must not rewrite the lock (the stale stamps stay
2320        // until a non-frozen install refreshes provenance).
2321        let after = fs::read_to_string(&lock_path).unwrap();
2322        assert!(after.contains("generator_version = \"0.0.1\""));
2323    }
2324
2325    #[test]
2326    fn frozen_install_errors_when_manifest_dropped_all_dependencies() {
2327        let (_repo_tmp, repo, _branch) = create_git_package_repo();
2328        let project_tmp = tempfile::tempdir().unwrap();
2329        let root = project_tmp.path();
2330        let workspace = TestWorkspace::new(root);
2331        fs::create_dir_all(root.join(".git")).unwrap();
2332        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
2333        fs::write(
2334            root.join(MANIFEST),
2335            format!(
2336                r#"
2337    [package]
2338    name = "workspace"
2339    version = "0.1.0"
2340
2341    [dependencies]
2342    acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
2343    "#
2344            ),
2345        )
2346        .unwrap();
2347
2348        install_packages_in(workspace.env(), false, None, false).unwrap();
2349
2350        // Manifest drops its dependencies but the stale lock still pins
2351        // them: frozen mode must flag the pending lock change instead of
2352        // silently succeeding.
2353        fs::write(
2354            root.join(MANIFEST),
2355            r#"
2356    [package]
2357    name = "workspace"
2358    version = "0.1.0"
2359    "#,
2360        )
2361        .unwrap();
2362
2363        let error = install_packages_in(workspace.env(), true, None, false).unwrap_err();
2364        assert!(error.to_string().contains("would need to change"));
2365
2366        // An empty lock (no packages) is fine in frozen mode.
2367        LockFile::default().save(&root.join(LOCK_FILE)).unwrap();
2368        let installed = install_packages_in(workspace.env(), true, None, false).unwrap();
2369        assert_eq!(installed, 0);
2370    }
2371
2372    #[test]
2373    fn offline_locked_install_materializes_from_cache_without_source_repo() {
2374        let (_repo_tmp, repo, _branch) = create_git_package_repo();
2375        let project_tmp = tempfile::tempdir().unwrap();
2376        let root = project_tmp.path();
2377        let workspace = TestWorkspace::new(root);
2378        fs::create_dir_all(root.join(".git")).unwrap();
2379        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
2380        fs::write(
2381            root.join(MANIFEST),
2382            format!(
2383                r#"
2384    [package]
2385    name = "workspace"
2386    version = "0.1.0"
2387
2388    [dependencies]
2389    acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
2390    "#
2391            ),
2392        )
2393        .unwrap();
2394
2395        let installed = install_packages_in(workspace.env(), false, None, false).unwrap();
2396        assert_eq!(installed, 1);
2397        fs::remove_dir_all(current_packages_dir(root)).unwrap();
2398        fs::remove_dir_all(&repo).unwrap();
2399
2400        let installed = install_packages_in(workspace.env(), true, None, true).unwrap();
2401        assert_eq!(installed, 1);
2402        assert!(current_packages_dir(root)
2403            .join("acme-lib")
2404            .join("lib.harn")
2405            .is_file());
2406    }
2407
2408    #[test]
2409    fn offline_locked_install_fails_when_cache_is_missing() {
2410        let (_repo_tmp, repo, _branch) = create_git_package_repo();
2411        let project_tmp = tempfile::tempdir().unwrap();
2412        let root = project_tmp.path();
2413        let workspace = TestWorkspace::new(root);
2414        let cache_dir = workspace.cache_dir();
2415        fs::create_dir_all(root.join(".git")).unwrap();
2416        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
2417        fs::write(
2418            root.join(MANIFEST),
2419            format!(
2420                r#"
2421    [package]
2422    name = "workspace"
2423    version = "0.1.0"
2424
2425    [dependencies]
2426    acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
2427    "#
2428            ),
2429        )
2430        .unwrap();
2431
2432        install_packages_in(workspace.env(), false, None, false).unwrap();
2433        fs::remove_dir_all(cache_dir.join("git")).unwrap();
2434        let error = install_packages_in(workspace.env(), true, None, true).unwrap_err();
2435        assert!(error.to_string().contains("offline mode"));
2436    }
2437
2438    #[test]
2439    fn add_github_shorthand_requires_version_or_ref() {
2440        let error = normalize_add_request(
2441            "github.com/burin-labs/harn-openapi",
2442            None,
2443            None,
2444            None,
2445            None,
2446            None,
2447            None,
2448            None,
2449        )
2450        .unwrap_err();
2451        assert!(error
2452            .to_string()
2453            .contains("must specify `tag`, `rev`, or `branch`"));
2454    }
2455
2456    #[test]
2457    fn add_github_shorthand_with_ref_writes_git_dependency() {
2458        let (alias, dependency) = normalize_add_request(
2459            "github.com/burin-labs/harn-openapi@v1.2.3",
2460            None,
2461            None,
2462            None,
2463            None,
2464            None,
2465            None,
2466            None,
2467        )
2468        .unwrap();
2469        assert_eq!(alias, "harn-openapi");
2470        let item = dependency_manifest_item(&alias, &dependency).unwrap();
2471        let table = item.as_inline_table().unwrap();
2472        assert_eq!(
2473            table.get("git").and_then(toml_edit::Value::as_str),
2474            Some("https://github.com/burin-labs/harn-openapi")
2475        );
2476        assert_eq!(
2477            table.get("rev").and_then(toml_edit::Value::as_str),
2478            Some("v1.2.3")
2479        );
2480    }
2481    #[test]
2482    fn install_resolves_transitive_git_dependencies_from_clean_cache() {
2483        let (_sdk_tmp, sdk_repo, _branch) = create_git_package_repo_with(
2484            "notion-sdk-harn",
2485            "",
2486            "pub fn sdk_value() -> string { return \"sdk\" }\n",
2487        );
2488        let sdk_git = normalize_git_url(sdk_repo.to_string_lossy().as_ref()).unwrap();
2489        let connector_tail = format!(
2490            r#"
2491
2492    [dependencies]
2493    notion-sdk-harn = {{ git = "{sdk_git}", rev = "v1.0.0" }}
2494    "#
2495        );
2496        let (_connector_tmp, connector_repo, _branch) = create_git_package_repo_with(
2497            "notion-connector-harn",
2498            &connector_tail,
2499            r#"
2500    import "notion-sdk-harn"
2501
2502    pub fn connector_value() -> string {
2503      return "connector"
2504    }
2505    "#,
2506        );
2507
2508        let project_tmp = tempfile::tempdir().unwrap();
2509        let root = project_tmp.path();
2510        let workspace = TestWorkspace::new(root);
2511        fs::create_dir_all(root.join(".git")).unwrap();
2512        let connector_git = normalize_git_url(connector_repo.to_string_lossy().as_ref()).unwrap();
2513        fs::write(
2514            root.join(MANIFEST),
2515            format!(
2516                r#"
2517    [package]
2518    name = "workspace"
2519    version = "0.1.0"
2520
2521    [dependencies]
2522    notion-connector-harn = {{ git = "{connector_git}", rev = "v1.0.0" }}
2523    "#
2524            ),
2525        )
2526        .unwrap();
2527
2528        let installed = install_packages_in(workspace.env(), false, None, false).unwrap();
2529        assert_eq!(installed, 2);
2530
2531        let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
2532        assert!(lock.find("notion-connector-harn").is_some());
2533        assert!(lock.find("notion-sdk-harn").is_some());
2534        assert!(current_packages_dir(root)
2535            .join("notion-connector-harn")
2536            .join("lib.harn")
2537            .is_file());
2538        assert!(current_packages_dir(root)
2539            .join("notion-sdk-harn")
2540            .join("lib.harn")
2541            .is_file());
2542
2543        let mut vm = test_vm();
2544        let exports = futures::executor::block_on(
2545            vm.load_module_exports(
2546                &current_packages_dir(root)
2547                    .join("notion-connector-harn")
2548                    .join("lib.harn"),
2549            ),
2550        )
2551        .expect("transitive import should load from the workspace package root");
2552        assert!(exports.contains_key("connector_value"));
2553    }
2554
2555    #[test]
2556    fn git_packages_reject_transitive_path_dependencies() {
2557        let connector_tail = r#"
2558
2559    [dependencies]
2560    local-helper = { path = "../helper" }
2561    "#;
2562        let (_connector_tmp, connector_repo, _branch) = create_git_package_repo_with(
2563            "notion-connector-harn",
2564            connector_tail,
2565            "pub fn connector_value() -> string { return \"connector\" }\n",
2566        );
2567
2568        let project_tmp = tempfile::tempdir().unwrap();
2569        let root = project_tmp.path();
2570        let workspace = TestWorkspace::new(root);
2571        fs::create_dir_all(root.join(".git")).unwrap();
2572        let connector_git = normalize_git_url(connector_repo.to_string_lossy().as_ref()).unwrap();
2573        fs::write(
2574            root.join(MANIFEST),
2575            format!(
2576                r#"
2577    [package]
2578    name = "workspace"
2579    version = "0.1.0"
2580
2581    [dependencies]
2582    notion-connector-harn = {{ git = "{connector_git}", rev = "v1.0.0" }}
2583    "#
2584            ),
2585        )
2586        .unwrap();
2587
2588        let error = install_packages_in(workspace.env(), false, None, false).unwrap_err();
2589        assert!(error
2590            .to_string()
2591            .contains("path dependencies are not supported inside remote-installed packages"));
2592    }
2593
2594    #[test]
2595    fn package_alias_validation_rejects_path_traversal_names() {
2596        for alias in [
2597            "../evil",
2598            "nested/evil",
2599            "nested\\evil",
2600            ".",
2601            "..",
2602            "bad alias",
2603        ] {
2604            assert!(
2605                validate_package_alias(alias).is_err(),
2606                "{alias:?} should be rejected"
2607            );
2608        }
2609        validate_package_alias("acme-lib_1.2").expect("ordinary alias should be accepted");
2610    }
2611
2612    #[test]
2613    fn add_package_rejects_aliases_that_escape_packages_dir() {
2614        let error = normalize_add_request(
2615            "ignored",
2616            Some("../evil"),
2617            None,
2618            None,
2619            None,
2620            None,
2621            Some("./dep"),
2622            None,
2623        )
2624        .unwrap_err();
2625        assert!(error.to_string().contains("invalid dependency alias"));
2626    }
2627
2628    #[test]
2629    fn rendered_dependency_values_are_toml_escaped() {
2630        let path = "dep\" \nmalicious = true";
2631        let item = dependency_manifest_item(
2632            "safe",
2633            &Dependency::Table(Box::new(DepTable {
2634                path: Some(path.to_string()),
2635                ..DepTable::default()
2636            })),
2637        )
2638        .expect("dependency item");
2639        let mut document = toml_edit::DocumentMut::new();
2640        document["dependencies"] = toml_edit::Item::Table(toml_edit::Table::new());
2641        document["dependencies"]["safe"] = item;
2642        let parsed: Manifest = toml::from_str(&document.to_string()).unwrap();
2643        assert_eq!(parsed.dependencies.len(), 1);
2644        assert_eq!(
2645            parsed
2646                .dependencies
2647                .get("safe")
2648                .and_then(Dependency::local_path),
2649            Some(path)
2650        );
2651    }
2652
2653    #[test]
2654    fn dependency_edits_preserve_unrelated_formatting_and_comments() {
2655        let tmp = tempfile::tempdir().unwrap();
2656        let manifest_path = tmp.path().join(MANIFEST);
2657        fs::write(
2658            &manifest_path,
2659            "# project\n[dependencies] # managed here\n\"acme.lib\" = { path = \"old\" } # retain\n\n[tool]\ncustom = true\n",
2660        )
2661        .unwrap();
2662
2663        upsert_dependency_in_manifest_locked(
2664            &manifest_path,
2665            "acme.lib",
2666            &Dependency::Table(Box::new(DepTable {
2667                path: Some("new".to_string()),
2668                ..DepTable::default()
2669            })),
2670        )
2671        .unwrap();
2672
2673        let updated = fs::read_to_string(&manifest_path).unwrap();
2674        assert!(updated.starts_with("# project\n[dependencies] # managed here\n"));
2675        assert!(updated.contains("\"acme.lib\" = { path = \"new\" } # retain"));
2676        assert!(updated.ends_with("\n[tool]\ncustom = true\n"));
2677        assert!(remove_dependency_from_manifest_locked(&manifest_path, "acme.lib").unwrap());
2678        let removed = fs::read_to_string(&manifest_path).unwrap();
2679        assert!(removed.starts_with("# project\n[dependencies] # managed here\n"));
2680        assert!(removed.ends_with("\n[tool]\ncustom = true\n"));
2681    }
2682
2683    #[test]
2684    fn materialization_rejects_lock_alias_path_traversal_before_removing_paths() {
2685        let tmp = tempfile::tempdir().unwrap();
2686        let dep = tmp.path().join("dep");
2687        fs::create_dir_all(&dep).unwrap();
2688        fs::write(dep.join("lib.harn"), "pub fn dep() { 1 }\n").unwrap();
2689        let victim = tmp.path().join("victim");
2690        fs::create_dir_all(&victim).unwrap();
2691        fs::write(victim.join("keep.txt"), "keep").unwrap();
2692
2693        let manifest: Manifest = toml::from_str("[package]\nname = \"root\"\n").unwrap();
2694        let ctx = ManifestContext {
2695            manifest,
2696            dir: tmp.path().to_path_buf(),
2697        };
2698        let workspace = TestWorkspace::new(tmp.path());
2699        let lock = LockFile {
2700            packages: vec![LockEntry {
2701                name: "../victim".to_string(),
2702                source: path_source_uri(&dep).unwrap(),
2703                ..LockEntry::default()
2704            }],
2705            ..LockFile::default()
2706        };
2707
2708        let error = materialize_dependencies_from_lock(workspace.env(), &ctx, &lock, None, false)
2709            .unwrap_err();
2710        assert!(error.to_string().contains("invalid dependency alias"));
2711        assert!(
2712            victim.join("keep.txt").exists(),
2713            "malicious alias should not remove paths outside the materialization root"
2714        );
2715    }
2716}