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)) = find_nearest_manifest(anchor) 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
1065pub(crate) fn dependency_section_bounds(lines: &[String]) -> Option<(usize, usize)> {
1066    let start = lines
1067        .iter()
1068        .position(|line| line.trim() == "[dependencies]")?;
1069    let end = lines
1070        .iter()
1071        .enumerate()
1072        .skip(start + 1)
1073        .find(|(_, line)| line.trim_start().starts_with('['))
1074        .map(|(index, _)| index)
1075        .unwrap_or(lines.len());
1076    Some((start, end))
1077}
1078
1079pub(crate) fn render_dependency_line(
1080    alias: &str,
1081    dependency: &Dependency,
1082) -> Result<String, PackageError> {
1083    validate_package_alias(alias)?;
1084    match dependency {
1085        Dependency::Path(path) => Ok(format!(
1086            "{alias} = {{ path = {} }}",
1087            toml_string_literal(path)?
1088        )),
1089        Dependency::Table(table) => {
1090            let mut fields = Vec::new();
1091            if let Some(path) = table.path.as_deref() {
1092                fields.push(format!("path = {}", toml_string_literal(path)?));
1093            }
1094            if let Some(git) = table.git.as_deref() {
1095                fields.push(format!("git = {}", toml_string_literal(git)?));
1096            }
1097            if let Some(archive) = table.archive.as_deref() {
1098                fields.push(format!("archive = {}", toml_string_literal(archive)?));
1099            }
1100            if let Some(branch) = table.branch.as_deref() {
1101                fields.push(format!("branch = {}", toml_string_literal(branch)?));
1102            } else if let Some(tag) = table.tag.as_deref() {
1103                fields.push(format!("tag = {}", toml_string_literal(tag)?));
1104            } else if let Some(rev) = table.rev.as_deref() {
1105                fields.push(format!("rev = {}", toml_string_literal(rev)?));
1106            }
1107            if let Some(version) = table.version.as_deref() {
1108                fields.push(format!("version = {}", toml_string_literal(version)?));
1109            }
1110            if let Some(package) = table.package.as_deref() {
1111                fields.push(format!("package = {}", toml_string_literal(package)?));
1112            }
1113            if let Some(checksum) = table.checksum.as_deref() {
1114                fields.push(format!("checksum = {}", toml_string_literal(checksum)?));
1115            }
1116            if let Some(registry) = table.registry.as_deref() {
1117                fields.push(format!("registry = {}", toml_string_literal(registry)?));
1118            }
1119            if let Some(name) = table.registry_name.as_deref() {
1120                fields.push(format!("registry_name = {}", toml_string_literal(name)?));
1121            }
1122            if let Some(version) = table.registry_version.as_deref() {
1123                fields.push(format!(
1124                    "registry_version = {}",
1125                    toml_string_literal(version)?
1126                ));
1127            }
1128            Ok(format!("{alias} = {{ {} }}", fields.join(", ")))
1129        }
1130    }
1131}
1132
1133pub(crate) fn ensure_manifest_exists(manifest_path: &Path) -> Result<String, PackageError> {
1134    if manifest_path.exists() {
1135        return fs::read_to_string(manifest_path).map_err(|error| {
1136            PackageError::Lockfile(format!(
1137                "failed to read {}: {error}",
1138                manifest_path.display()
1139            ))
1140        });
1141    }
1142    Ok("[package]\nname = \"my-project\"\nversion = \"0.1.0\"\n".to_string())
1143}
1144
1145pub(crate) fn upsert_dependency_in_manifest(
1146    manifest_path: &Path,
1147    alias: &str,
1148    dependency: &Dependency,
1149) -> Result<(), PackageError> {
1150    let content = ensure_manifest_exists(manifest_path)?;
1151    let mut lines: Vec<String> = content.lines().map(|line| line.to_string()).collect();
1152    if dependency_section_bounds(&lines).is_none() {
1153        if !lines.is_empty() && !lines.last().is_some_and(|line| line.is_empty()) {
1154            lines.push(String::new());
1155        }
1156        lines.push("[dependencies]".to_string());
1157    }
1158    let (start, end) = dependency_section_bounds(&lines).ok_or_else(|| {
1159        format!(
1160            "failed to locate [dependencies] in {}",
1161            manifest_path.display()
1162        )
1163    })?;
1164    let rendered = render_dependency_line(alias, dependency)?;
1165    if let Some((index, _)) = lines
1166        .iter()
1167        .enumerate()
1168        .skip(start + 1)
1169        .take(end - start - 1)
1170        .find(|(_, line)| {
1171            line.split('=')
1172                .next()
1173                .is_some_and(|key| key.trim() == alias)
1174        })
1175    {
1176        lines[index] = rendered;
1177    } else {
1178        lines.insert(end, rendered);
1179    }
1180    write_manifest_content(manifest_path, &(lines.join("\n") + "\n"))
1181}
1182
1183pub(crate) fn remove_dependency_from_manifest(
1184    manifest_path: &Path,
1185    alias: &str,
1186) -> Result<bool, PackageError> {
1187    let content = fs::read_to_string(manifest_path)
1188        .map_err(|error| format!("failed to read {}: {error}", manifest_path.display()))?;
1189    let mut lines: Vec<String> = content.lines().map(|line| line.to_string()).collect();
1190    let Some((start, end)) = dependency_section_bounds(&lines) else {
1191        return Ok(false);
1192    };
1193    let mut removed = false;
1194    lines = lines
1195        .into_iter()
1196        .enumerate()
1197        .filter_map(|(index, line)| {
1198            if index <= start || index >= end {
1199                return Some(line);
1200            }
1201            let matches = line
1202                .split('=')
1203                .next()
1204                .is_some_and(|key| key.trim() == alias);
1205            if matches {
1206                removed = true;
1207                None
1208            } else {
1209                Some(line)
1210            }
1211        })
1212        .collect();
1213    if removed {
1214        write_manifest_content(manifest_path, &(lines.join("\n") + "\n"))?;
1215    }
1216    Ok(removed)
1217}
1218
1219pub(crate) fn install_packages_impl(
1220    frozen: bool,
1221    refetch: Option<&str>,
1222    offline: bool,
1223) -> Result<usize, PackageError> {
1224    install_packages_in(
1225        &PackageWorkspace::from_current_dir()?,
1226        frozen,
1227        refetch,
1228        offline,
1229    )
1230}
1231
1232pub(crate) fn install_packages_in(
1233    workspace: &PackageWorkspace,
1234    frozen: bool,
1235    refetch: Option<&str>,
1236    offline: bool,
1237) -> Result<usize, PackageError> {
1238    let ctx = workspace.load_manifest_context()?;
1239    let existing = LockFile::load(&ctx.lock_path())?;
1240    if ctx.manifest.dependencies.is_empty() {
1241        let empty = LockFile::default();
1242        if frozen || offline {
1243            // A lock that still pins packages the manifest no longer
1244            // declares is a substantive change; surface it instead of
1245            // silently succeeding against a stale lock.
1246            if existing
1247                .as_ref()
1248                .is_some_and(|lock| !lock.packages.is_empty())
1249            {
1250                return Err(format!("{} would need to change", ctx.lock_path().display()).into());
1251            }
1252        } else {
1253            empty.save(&ctx.lock_path())?;
1254        }
1255        return materialize_dependencies_from_lock(workspace, &ctx, &empty, refetch, offline);
1256    }
1257
1258    if (frozen || offline) && existing.is_none() {
1259        return Err(format!("{} is missing", ctx.lock_path().display()).into());
1260    }
1261
1262    let desired = build_lockfile(
1263        workspace,
1264        &ctx,
1265        existing.as_ref(),
1266        None,
1267        false,
1268        !frozen && !offline,
1269        offline,
1270    )?;
1271    if frozen || offline {
1272        if !existing
1273            .as_ref()
1274            .is_some_and(|lock| lock.same_resolution(&desired))
1275        {
1276            return Err(format!("{} would need to change", ctx.lock_path().display()).into());
1277        }
1278    } else {
1279        desired.save(&ctx.lock_path())?;
1280    }
1281    materialize_dependencies_from_lock(workspace, &ctx, &desired, refetch, offline)
1282}
1283
1284pub fn install_packages(frozen: bool, refetch: Option<&str>, offline: bool, json: bool) {
1285    match install_packages_impl(frozen, refetch, offline) {
1286        Ok(installed) if json => {
1287            print_install_summary_json("install", installed, frozen, offline);
1288        }
1289        Ok(0) => println!("No dependencies to install."),
1290        Ok(installed) => {
1291            println!("Installed {installed} package(s) in a new immutable generation.");
1292        }
1293        Err(error) if json => {
1294            print_install_error_json("install", &error);
1295            process::exit(1);
1296        }
1297        Err(error) => {
1298            eprintln!("error: {error}");
1299            process::exit(1);
1300        }
1301    }
1302}
1303
1304fn print_install_summary_json(action: &str, installed: usize, frozen: bool, offline: bool) {
1305    let body = serde_json::json!({
1306        "action": action,
1307        "ok": true,
1308        "installed": installed,
1309        "frozen": frozen,
1310        "offline": offline,
1311        "lock_file": LOCK_FILE,
1312        "package_pointer": ".harn/package-current.toml",
1313    });
1314    println!(
1315        "{}",
1316        serde_json::to_string_pretty(&body).unwrap_or_default()
1317    );
1318}
1319
1320fn print_install_error_json(action: &str, error: &PackageError) {
1321    let body = serde_json::json!({
1322        "action": action,
1323        "ok": false,
1324        "error": error.to_string(),
1325    });
1326    println!(
1327        "{}",
1328        serde_json::to_string_pretty(&body).unwrap_or_default()
1329    );
1330}
1331
1332pub fn lock_packages() {
1333    let result = (|| -> Result<usize, PackageError> {
1334        let workspace = PackageWorkspace::from_current_dir()?;
1335        let ctx = workspace.load_manifest_context()?;
1336        let existing = LockFile::load(&ctx.lock_path())?;
1337        let lock = build_lockfile(&workspace, &ctx, existing.as_ref(), None, true, true, false)?;
1338        lock.save(&ctx.lock_path())?;
1339        Ok(lock.packages.len())
1340    })();
1341
1342    match result {
1343        Ok(count) => println!("Wrote {LOCK_FILE} with {count} package(s)."),
1344        Err(error) => {
1345            eprintln!("error: {error}");
1346            process::exit(1);
1347        }
1348    }
1349}
1350
1351pub fn update_packages(alias: Option<&str>, all: bool, json: bool) {
1352    let result = PackageWorkspace::from_current_dir()
1353        .and_then(|workspace| update_packages_in(&workspace, alias, all));
1354    print_update_packages_result(result, json);
1355}
1356
1357pub(crate) fn update_packages_in(
1358    workspace: &PackageWorkspace,
1359    alias: Option<&str>,
1360    all: bool,
1361) -> Result<usize, PackageError> {
1362    if !all && alias.is_none() {
1363        return Err("specify a dependency alias or pass --all"
1364            .to_string()
1365            .into());
1366    }
1367
1368    let ctx = workspace.load_manifest_context()?;
1369    if let Some(alias) = alias {
1370        validate_package_alias(alias)?;
1371        if !ctx.manifest.dependencies.contains_key(alias) {
1372            return Err(format!("{alias} is not present in [dependencies]").into());
1373        }
1374    }
1375    let existing = LockFile::load(&ctx.lock_path())?;
1376    let lock = build_lockfile(workspace, &ctx, existing.as_ref(), alias, all, true, false)?;
1377    lock.save(&ctx.lock_path())?;
1378    materialize_dependencies_from_lock(workspace, &ctx, &lock, None, false)
1379}
1380
1381fn print_update_packages_result(result: Result<usize, PackageError>, json: bool) {
1382    match result {
1383        Ok(installed) if json => print_install_summary_json("update", installed, false, false),
1384        Ok(installed) => println!("Updated {installed} package(s)."),
1385        Err(error) if json => {
1386            print_install_error_json("update", &error);
1387            process::exit(1);
1388        }
1389        Err(error) => {
1390            eprintln!("error: {error}");
1391            process::exit(1);
1392        }
1393    }
1394}
1395
1396pub fn remove_package(alias: &str) {
1397    let result = PackageWorkspace::from_current_dir()
1398        .and_then(|workspace| remove_package_in(&workspace, alias));
1399    print_remove_package_result(alias, result);
1400}
1401
1402pub(crate) fn remove_package_in(
1403    workspace: &PackageWorkspace,
1404    alias: &str,
1405) -> Result<bool, PackageError> {
1406    validate_package_alias(alias)?;
1407    let ctx = workspace.load_manifest_context()?;
1408    let removed = remove_dependency_from_manifest(&ctx.manifest_path(), alias)?;
1409    if !removed {
1410        return Ok(false);
1411    }
1412    let mut lock = LockFile::load(&ctx.lock_path())?.unwrap_or_default();
1413    lock.remove(alias);
1414    lock.save(&ctx.lock_path())?;
1415    materialize_dependencies_from_lock(workspace, &ctx, &lock, None, false)?;
1416    Ok(true)
1417}
1418
1419fn print_remove_package_result(alias: &str, result: Result<bool, PackageError>) {
1420    match result {
1421        Ok(true) => println!("Removed {alias} from {MANIFEST} and {LOCK_FILE}."),
1422        Ok(false) => {
1423            eprintln!("error: {alias} is not present in [dependencies]");
1424            process::exit(1);
1425        }
1426        Err(error) => {
1427            eprintln!("error: {error}");
1428            process::exit(1);
1429        }
1430    }
1431}
1432
1433#[derive(Clone, Copy, Debug)]
1434pub(crate) struct AddPackageRequest<'a> {
1435    name_or_spec: &'a str,
1436    alias: Option<&'a str>,
1437    git_url: Option<&'a str>,
1438    tag: Option<&'a str>,
1439    rev: Option<&'a str>,
1440    branch: Option<&'a str>,
1441    local_path: Option<&'a str>,
1442    registry: Option<&'a str>,
1443}
1444
1445#[cfg(test)]
1446#[allow(clippy::too_many_arguments)]
1447pub(crate) fn normalize_add_request(
1448    name_or_spec: &str,
1449    alias: Option<&str>,
1450    git_url: Option<&str>,
1451    tag: Option<&str>,
1452    rev: Option<&str>,
1453    branch: Option<&str>,
1454    local_path: Option<&str>,
1455    registry: Option<&str>,
1456) -> Result<(String, Dependency), PackageError> {
1457    normalize_add_request_in(
1458        &PackageWorkspace::from_current_dir()?,
1459        AddPackageRequest {
1460            name_or_spec,
1461            alias,
1462            git_url,
1463            tag,
1464            rev,
1465            branch,
1466            local_path,
1467            registry,
1468        },
1469    )
1470}
1471
1472pub(crate) fn normalize_add_request_in(
1473    workspace: &PackageWorkspace,
1474    request: AddPackageRequest<'_>,
1475) -> Result<(String, Dependency), PackageError> {
1476    let AddPackageRequest {
1477        name_or_spec,
1478        alias,
1479        git_url,
1480        tag,
1481        rev,
1482        branch,
1483        local_path,
1484        registry,
1485    } = request;
1486
1487    if local_path.is_some() && (rev.is_some() || tag.is_some() || branch.is_some()) {
1488        return Err("path dependencies do not accept --rev, --tag, or --branch"
1489            .to_string()
1490            .into());
1491    }
1492    if git_url.is_none()
1493        && local_path.is_none()
1494        && rev.is_none()
1495        && tag.is_none()
1496        && branch.is_none()
1497    {
1498        if let Some(path) = existing_local_path_spec(name_or_spec) {
1499            let alias = alias
1500                .map(str::to_string)
1501                .map(Ok)
1502                .unwrap_or_else(|| derive_package_alias_from_path(&path))?;
1503            validate_package_alias(&alias)?;
1504            return Ok((
1505                alias,
1506                Dependency::Table(Box::new(DepTable {
1507                    path: Some(name_or_spec.to_string()),
1508                    ..DepTable::default()
1509                })),
1510            ));
1511        }
1512        if parse_registry_package_spec(name_or_spec).is_some() {
1513            return registry_dependency_from_spec_in(workspace, name_or_spec, alias, registry);
1514        }
1515    }
1516    if git_url.is_some() || local_path.is_some() {
1517        if let Some(path) = local_path {
1518            let alias = alias
1519                .map(str::to_string)
1520                .unwrap_or_else(|| name_or_spec.to_string());
1521            validate_package_alias(&alias)?;
1522            return Ok((
1523                alias,
1524                Dependency::Table(Box::new(DepTable {
1525                    path: Some(path.to_string()),
1526                    ..DepTable::default()
1527                })),
1528            ));
1529        }
1530        let alias = alias.unwrap_or(name_or_spec).to_string();
1531        validate_package_alias(&alias)?;
1532        if rev.is_some() && tag.is_some() {
1533            return Err("use only one of --rev or --tag".to_string().into());
1534        }
1535        if rev.is_none() && tag.is_none() && branch.is_none() {
1536            return Err(format!(
1537                "git dependency {alias} must specify `tag`, `rev`, or `branch`; use `harn add <url>@<tag-or-sha>` or pass `--tag`/`--rev`/`--branch`"
1538            ).into());
1539        }
1540        let git = normalize_git_url(git_url.ok_or_else(|| "missing --git URL".to_string())?)?;
1541        let package_name = derive_repo_name_from_source(&git)?;
1542        return Ok((
1543            alias.clone(),
1544            Dependency::Table(Box::new(DepTable {
1545                git: Some(git),
1546                tag: tag.map(str::to_string),
1547                rev: rev.map(str::to_string),
1548                branch: branch.map(str::to_string),
1549                package: (alias != package_name).then_some(package_name),
1550                ..DepTable::default()
1551            })),
1552        ));
1553    }
1554
1555    if rev.is_some() && tag.is_some() {
1556        return Err("use only one of --rev or --tag".to_string().into());
1557    }
1558    let (raw_source, inline_ref) = parse_positional_git_spec(name_or_spec);
1559    if inline_ref.is_some() && (rev.is_some() || tag.is_some() || branch.is_some()) {
1560        return Err(
1561            "specify the git ref either inline as @ref or via --tag/--rev/--branch"
1562                .to_string()
1563                .into(),
1564        );
1565    }
1566    let git = normalize_git_url(raw_source)?;
1567    let package_name = derive_repo_name_from_source(&git)?;
1568    let alias = alias.unwrap_or(package_name.as_str()).to_string();
1569    validate_package_alias(&alias)?;
1570    if inline_ref.is_none() && rev.is_none() && tag.is_none() && branch.is_none() {
1571        return Err(format!(
1572            "git dependency {alias} must specify `tag`, `rev`, or `branch`; use `harn add {raw_source}@<tag-or-sha>` or pass `--tag`/`--rev`/`--branch`"
1573        ).into());
1574    }
1575    Ok((
1576        alias.clone(),
1577        Dependency::Table(Box::new(DepTable {
1578            git: Some(git),
1579            tag: tag.map(str::to_string),
1580            rev: inline_ref.or(rev).map(str::to_string),
1581            branch: branch.map(str::to_string),
1582            package: (alias != package_name).then_some(package_name),
1583            ..DepTable::default()
1584        })),
1585    ))
1586}
1587
1588#[cfg(test)]
1589pub fn add_package(
1590    name_or_spec: &str,
1591    alias: Option<&str>,
1592    git_url: Option<&str>,
1593    tag: Option<&str>,
1594    rev: Option<&str>,
1595    branch: Option<&str>,
1596    local_path: Option<&str>,
1597) {
1598    add_package_with_registry(
1599        name_or_spec,
1600        alias,
1601        git_url,
1602        tag,
1603        rev,
1604        branch,
1605        local_path,
1606        None,
1607    );
1608}
1609
1610pub fn add_package_with_registry(
1611    name_or_spec: &str,
1612    alias: Option<&str>,
1613    git_url: Option<&str>,
1614    tag: Option<&str>,
1615    rev: Option<&str>,
1616    branch: Option<&str>,
1617    local_path: Option<&str>,
1618    registry: Option<&str>,
1619) {
1620    let result = PackageWorkspace::from_current_dir().and_then(|workspace| {
1621        add_package_to(
1622            &workspace,
1623            name_or_spec,
1624            alias,
1625            git_url,
1626            tag,
1627            rev,
1628            branch,
1629            local_path,
1630            registry,
1631        )
1632    });
1633
1634    match result {
1635        Ok((alias, installed)) => {
1636            println!("Added {alias} to {MANIFEST}.");
1637            println!("Installed {installed} package(s).");
1638        }
1639        Err(error) => {
1640            eprintln!("error: {error}");
1641            process::exit(1);
1642        }
1643    }
1644}
1645
1646#[allow(clippy::too_many_arguments)]
1647pub(crate) fn add_package_to(
1648    workspace: &PackageWorkspace,
1649    name_or_spec: &str,
1650    alias: Option<&str>,
1651    git_url: Option<&str>,
1652    tag: Option<&str>,
1653    rev: Option<&str>,
1654    branch: Option<&str>,
1655    local_path: Option<&str>,
1656    registry: Option<&str>,
1657) -> Result<(String, usize), PackageError> {
1658    let manifest_path = workspace.manifest_dir().join(MANIFEST);
1659    let (alias, dependency) = normalize_add_request_in(
1660        workspace,
1661        AddPackageRequest {
1662            name_or_spec,
1663            alias,
1664            git_url,
1665            tag,
1666            rev,
1667            branch,
1668            local_path,
1669            registry,
1670        },
1671    )?;
1672    upsert_dependency_in_manifest(&manifest_path, &alias, &dependency)?;
1673    let installed = install_packages_in(workspace, false, None, false)?;
1674    Ok((alias, installed))
1675}
1676
1677#[cfg(test)]
1678mod tests {
1679    use super::*;
1680    use crate::package::test_support::*;
1681
1682    fn write_tar_gz_package_archive(root: &Path, archive_path: &Path) {
1683        fn append_files(
1684            builder: &mut tar::Builder<flate2::write::GzEncoder<File>>,
1685            root: &Path,
1686            cursor: &Path,
1687        ) {
1688            let mut entries = fs::read_dir(cursor)
1689                .unwrap()
1690                .map(|entry| entry.unwrap().path())
1691                .collect::<Vec<_>>();
1692            entries.sort();
1693            for path in entries {
1694                if path.is_dir() {
1695                    append_files(builder, root, &path);
1696                } else {
1697                    let relative = path.strip_prefix(root).unwrap();
1698                    builder.append_path_with_name(&path, relative).unwrap();
1699                }
1700            }
1701        }
1702
1703        let file = File::create(archive_path).unwrap();
1704        let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
1705        let mut builder = tar::Builder::new(encoder);
1706        append_files(&mut builder, root, root);
1707        let encoder = builder.into_inner().unwrap();
1708        encoder.finish().unwrap();
1709    }
1710
1711    #[test]
1712    fn lock_file_round_trips_typed_schema() {
1713        let tmp = tempfile::tempdir().unwrap();
1714        let path = tmp.path().join(LOCK_FILE);
1715        let lock = LockFile {
1716            version: LOCK_FILE_VERSION,
1717            generator_version: current_generator_version(),
1718            protocol_artifact_version: current_protocol_artifact_version(),
1719            packages: vec![LockEntry {
1720                name: "acme-lib".to_string(),
1721                source: "git+https://github.com/acme/acme-lib".to_string(),
1722                tag: Some("v1.0.0".to_string()),
1723                rev_request: Some("v1.0.0".to_string()),
1724                commit: Some("0123456789abcdef0123456789abcdef01234567".to_string()),
1725                content_hash: Some("sha256:deadbeef".to_string()),
1726                package_version: Some("1.0.0".to_string()),
1727                harn_compat: Some(">=0.8,<0.9".to_string()),
1728                provenance: Some(
1729                    "https://github.com/acme/acme-lib/releases/tag/v1.0.0".to_string(),
1730                ),
1731                manifest_digest: Some("sha256:cafebabe".to_string()),
1732                registry: None,
1733                exports: PackageLockExports {
1734                    modules: vec![PackageLockExport {
1735                        name: "lib".to_string(),
1736                        path: Some("lib/main.harn".to_string()),
1737                        symbol: None,
1738                    }],
1739                    tools: vec![PackageLockExport {
1740                        name: "echo".to_string(),
1741                        path: Some("lib/tools.harn".to_string()),
1742                        symbol: Some("tools".to_string()),
1743                    }],
1744                    skills: Vec::new(),
1745                    personas: Vec::new(),
1746                },
1747                permissions: vec!["tool:read_only".to_string()],
1748                host_requirements: vec!["workspace.read_text".to_string()],
1749            }],
1750        };
1751        lock.save(&path).unwrap();
1752        let loaded = LockFile::load(&path).unwrap().unwrap();
1753        assert_eq!(loaded, lock);
1754    }
1755
1756    #[test]
1757    fn add_and_remove_git_dependency_round_trip() {
1758        let (_repo_tmp, repo, _branch) = create_git_package_repo();
1759        let project_tmp = tempfile::tempdir().unwrap();
1760        let root = project_tmp.path();
1761        let workspace = TestWorkspace::new(root);
1762        fs::create_dir_all(root.join(".git")).unwrap();
1763        fs::write(
1764            root.join(MANIFEST),
1765            r#"
1766    [package]
1767    name = "workspace"
1768    version = "0.1.0"
1769    "#,
1770        )
1771        .unwrap();
1772
1773        let spec = format!("{}@v1.0.0", repo.display());
1774        add_package_to(
1775            workspace.env(),
1776            &spec,
1777            None,
1778            None,
1779            None,
1780            None,
1781            None,
1782            None,
1783            None,
1784        )
1785        .unwrap();
1786
1787        let alias = "acme-lib";
1788        let manifest = fs::read_to_string(root.join(MANIFEST)).unwrap();
1789        assert!(manifest.contains("acme-lib"));
1790        assert!(manifest.contains("rev = \"v1.0.0\""));
1791
1792        let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
1793        let entry = lock.find(alias).unwrap();
1794        assert_eq!(lock.version, LOCK_FILE_VERSION);
1795        assert!(entry.source.starts_with("git+file://"));
1796        assert!(entry.commit.as_deref().is_some_and(is_full_git_sha));
1797        assert!(entry
1798            .content_hash
1799            .as_deref()
1800            .is_some_and(|hash| hash.starts_with("sha256:")));
1801        assert!(current_packages_dir(root)
1802            .join(alias)
1803            .join("lib.harn")
1804            .is_file());
1805
1806        remove_package_in(workspace.env(), alias).unwrap();
1807        let updated_manifest = fs::read_to_string(root.join(MANIFEST)).unwrap();
1808        assert!(!updated_manifest.contains("acme-lib ="));
1809        let updated_lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
1810        assert!(updated_lock.find(alias).is_none());
1811        assert!(!current_packages_dir(root).join(alias).exists());
1812    }
1813
1814    #[test]
1815    fn install_resolves_git_tag_dependency_and_records_tag() {
1816        let (_repo_tmp, repo, _branch) = create_git_package_repo();
1817        let project_tmp = tempfile::tempdir().unwrap();
1818        let root = project_tmp.path();
1819        let workspace = TestWorkspace::new(root);
1820        fs::create_dir_all(root.join(".git")).unwrap();
1821        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1822        fs::write(
1823            root.join(MANIFEST),
1824            format!(
1825                r#"
1826    [package]
1827    name = "workspace"
1828    version = "0.1.0"
1829
1830    [dependencies]
1831    acme-lib = {{ git = "{git}", tag = "v1.0.0" }}
1832    "#
1833            ),
1834        )
1835        .unwrap();
1836
1837        let installed = install_packages_in(workspace.env(), false, None, false).unwrap();
1838
1839        assert_eq!(installed, 1);
1840        let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
1841        let entry = lock.find("acme-lib").unwrap();
1842        assert_eq!(entry.tag.as_deref(), Some("v1.0.0"));
1843        assert_eq!(entry.rev_request.as_deref(), Some("v1.0.0"));
1844        assert!(entry.commit.as_deref().is_some_and(is_full_git_sha));
1845        assert!(entry.content_hash.as_deref().is_some());
1846        assert!(current_packages_dir(root)
1847            .join("acme-lib")
1848            .join("lib.harn")
1849            .is_file());
1850    }
1851
1852    #[test]
1853    fn concurrent_materialization_serializes_package_tree_updates() {
1854        let (_repo_tmp, repo, _branch) = create_git_package_repo();
1855        let project_tmp = tempfile::tempdir().unwrap();
1856        let root = project_tmp.path();
1857        let workspace = TestWorkspace::new(root);
1858        fs::create_dir_all(root.join(".git")).unwrap();
1859        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1860        fs::write(
1861            root.join(MANIFEST),
1862            format!(
1863                r#"
1864    [package]
1865    name = "workspace"
1866    version = "0.1.0"
1867
1868    [dependencies]
1869    acme-lib = {{ git = "{git}", tag = "v1.0.0" }}
1870    "#
1871            ),
1872        )
1873        .unwrap();
1874
1875        install_packages_in(workspace.env(), false, None, false).unwrap();
1876        fs::write(
1877            current_packages_dir(root).join("acme-lib").join("lib.harn"),
1878            "pub fn value() -> string { return \"stale\" }\n",
1879        )
1880        .unwrap();
1881
1882        let ctx = workspace.env().load_manifest_context().unwrap();
1883        let lock = LockFile::load(&ctx.lock_path()).unwrap().unwrap();
1884        let workspace_env = workspace.env().clone();
1885        let handles = (0..8)
1886            .map(|_| {
1887                let workspace_env = workspace_env.clone();
1888                let ctx = ctx.clone();
1889                let lock = lock.clone();
1890                std::thread::spawn(move || {
1891                    materialize_dependencies_from_lock(&workspace_env, &ctx, &lock, None, false)
1892                })
1893            })
1894            .collect::<Vec<_>>();
1895
1896        for handle in handles {
1897            handle.join().unwrap().unwrap();
1898        }
1899
1900        let materialized =
1901            fs::read_to_string(current_packages_dir(root).join("acme-lib").join("lib.harn"))
1902                .unwrap();
1903        assert!(materialized.contains("return \"v1\""));
1904    }
1905
1906    #[test]
1907    fn install_resolves_registry_version_range_to_highest_matching_tag() {
1908        let (_repo_tmp, repo, _branch) = create_git_package_repo();
1909        fs::write(
1910            repo.join("lib.harn"),
1911            "pub fn value() -> string { return \"v0.1.1\" }\n",
1912        )
1913        .unwrap();
1914        run_git(&repo, &["add", "."]);
1915        run_git(&repo, &["commit", "-m", "v0.1.1"]);
1916        run_git(&repo, &["tag", "v0.1.1"]);
1917        fs::write(
1918            repo.join("lib.harn"),
1919            "pub fn value() -> string { return \"v0.2.0\" }\n",
1920        )
1921        .unwrap();
1922        run_git(&repo, &["add", "."]);
1923        run_git(&repo, &["commit", "-m", "v0.2.0"]);
1924        run_git(&repo, &["tag", "v0.2.0"]);
1925
1926        let project_tmp = tempfile::tempdir().unwrap();
1927        let root = project_tmp.path();
1928        let registry_path = root.join("index.toml");
1929        let workspace =
1930            TestWorkspace::new(root).with_registry_source(registry_path.display().to_string());
1931        fs::create_dir_all(root.join(".git")).unwrap();
1932        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
1933        fs::write(
1934            &registry_path,
1935            format!(
1936                r#"
1937version = 1
1938
1939[[package]]
1940name = "acme-lib"
1941repository = "{git}"
1942
1943[[package.version]]
1944version = "0.1.0"
1945git = "{git}"
1946tag = "v1.0.0"
1947
1948[[package.version]]
1949version = "0.1.1"
1950git = "{git}"
1951tag = "v0.1.1"
1952
1953[[package.version]]
1954version = "0.2.0"
1955git = "{git}"
1956tag = "v0.2.0"
1957"#
1958            ),
1959        )
1960        .unwrap();
1961        fs::write(
1962            root.join(MANIFEST),
1963            r#"
1964    [package]
1965    name = "workspace"
1966    version = "0.1.0"
1967
1968    [dependencies]
1969    acme-lib = { version = ">=0.1,<0.2" }
1970    "#,
1971        )
1972        .unwrap();
1973
1974        let installed = install_packages_in(workspace.env(), false, None, false).unwrap();
1975
1976        assert_eq!(installed, 1);
1977        let lock_path = root.join(LOCK_FILE);
1978        let lock = LockFile::load(&lock_path).unwrap().unwrap();
1979        let entry = lock.find("acme-lib").unwrap();
1980        assert_eq!(entry.tag.as_deref(), Some("v0.1.1"));
1981        assert_eq!(entry.rev_request.as_deref(), Some("v0.1.1"));
1982        assert_eq!(
1983            entry
1984                .registry
1985                .as_ref()
1986                .map(|registry| registry.version.as_str()),
1987            Some("0.1.1")
1988        );
1989        let source =
1990            fs::read_to_string(current_packages_dir(root).join("acme-lib").join("lib.harn"))
1991                .unwrap();
1992        assert!(source.contains("v0.1.1"), "{source}");
1993
1994        let original_lock = fs::read_to_string(&lock_path).unwrap();
1995        fs::remove_dir_all(current_packages_dir(root)).unwrap();
1996        fs::remove_dir_all(&repo).unwrap();
1997        fs::remove_file(&registry_path).unwrap();
1998
1999        let reinstalled = install_packages_in(workspace.env(), true, None, true).unwrap();
2000        assert_eq!(reinstalled, 1);
2001        assert_eq!(fs::read_to_string(&lock_path).unwrap(), original_lock);
2002        assert!(current_packages_dir(root)
2003            .join("acme-lib")
2004            .join("lib.harn")
2005            .is_file());
2006    }
2007
2008    #[test]
2009    fn registry_archive_dependency_materializes_and_reinstalls_offline() {
2010        let package_tmp = tempfile::tempdir().unwrap();
2011        let package_root = package_tmp.path().join("acme-rules");
2012        fs::create_dir_all(package_root.join("rules")).unwrap();
2013        fs::write(
2014            package_root.join(MANIFEST),
2015            r#"
2016    [package]
2017    name = "acme-rules"
2018    version = "1.0.0"
2019
2020    [rules]
2021    ruleDirs = ["rules"]
2022    "#,
2023        )
2024        .unwrap();
2025        fs::write(
2026            package_root.join("rules/no_todo.harn"),
2027            "pub fn rule() -> string { return \"no todo\" }\n",
2028        )
2029        .unwrap();
2030        let checksum = compute_content_hash(&package_root).unwrap();
2031
2032        let project_tmp = tempfile::tempdir().unwrap();
2033        let root = project_tmp.path();
2034        let registry_path = root.join("index.toml");
2035        let archive_path = root.join("acme-rules-1.0.0.tar.gz");
2036        write_tar_gz_package_archive(&package_root, &archive_path);
2037        let archive = normalize_archive_url(archive_path.to_string_lossy().as_ref()).unwrap();
2038        let workspace =
2039            TestWorkspace::new(root).with_registry_source(registry_path.display().to_string());
2040        fs::create_dir_all(root.join(".git")).unwrap();
2041        fs::write(
2042            &registry_path,
2043            format!(
2044                r#"
2045version = 1
2046
2047[[package]]
2048name = "@acme/rules"
2049description = "Rule pack"
2050repository = "https://github.com/acme/rules"
2051
2052[package.rule_pack]
2053rule_count = 1
2054languages = ["harn"]
2055safety_summary = ["advisory:1"]
2056
2057[[package.version]]
2058version = "1.0.0"
2059archive = "{archive}"
2060package = "acme-rules"
2061checksum = "{checksum}"
2062"#
2063            ),
2064        )
2065        .unwrap();
2066        fs::write(
2067            root.join(MANIFEST),
2068            r#"
2069    [package]
2070    name = "workspace"
2071    version = "0.1.0"
2072    "#,
2073        )
2074        .unwrap();
2075
2076        let (alias, installed) = add_package_to(
2077            workspace.env(),
2078            "@acme/rules@1.0.0",
2079            None,
2080            None,
2081            None,
2082            None,
2083            None,
2084            None,
2085            None,
2086        )
2087        .unwrap();
2088
2089        assert_eq!(alias, "acme-rules");
2090        assert_eq!(installed, 1);
2091        let manifest = fs::read_to_string(root.join(MANIFEST)).unwrap();
2092        assert!(manifest.contains("archive = "));
2093        assert!(manifest.contains(&format!("checksum = \"{checksum}\"")));
2094        assert!(manifest.contains("registry_name = \"@acme/rules\""));
2095        let lock_path = root.join(LOCK_FILE);
2096        let lock = LockFile::load(&lock_path).unwrap().unwrap();
2097        let entry = lock.find("acme-rules").unwrap();
2098        assert!(entry.source.starts_with("archive+file://"));
2099        assert_eq!(entry.content_hash.as_deref(), Some(checksum.as_str()));
2100        assert!(entry.commit.is_none());
2101        assert_eq!(
2102            entry
2103                .registry
2104                .as_ref()
2105                .map(|registry| registry.name.as_str()),
2106            Some("@acme/rules")
2107        );
2108        assert!(current_packages_dir(root)
2109            .join("acme-rules")
2110            .join("rules/no_todo.harn")
2111            .is_file());
2112
2113        let original_lock = fs::read_to_string(&lock_path).unwrap();
2114        fs::remove_dir_all(current_packages_dir(root)).unwrap();
2115        fs::remove_file(&archive_path).unwrap();
2116        let reinstalled = install_packages_in(workspace.env(), true, None, true).unwrap();
2117        assert_eq!(reinstalled, 1);
2118        assert_eq!(fs::read_to_string(&lock_path).unwrap(), original_lock);
2119        assert!(current_packages_dir(root)
2120            .join("acme-rules")
2121            .join("rules/no_todo.harn")
2122            .is_file());
2123    }
2124
2125    #[test]
2126    fn update_branch_dependency_refreshes_locked_commit() {
2127        let (_repo_tmp, repo, branch) = create_git_package_repo();
2128        let project_tmp = tempfile::tempdir().unwrap();
2129        let root = project_tmp.path();
2130        let workspace = TestWorkspace::new(root);
2131        fs::create_dir_all(root.join(".git")).unwrap();
2132        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
2133        fs::write(
2134            root.join(MANIFEST),
2135            format!(
2136                r#"
2137    [package]
2138    name = "workspace"
2139    version = "0.1.0"
2140
2141    [dependencies]
2142    acme-lib = {{ git = "{git}", branch = "{branch}" }}
2143    "#
2144            ),
2145        )
2146        .unwrap();
2147
2148        let installed = install_packages_in(workspace.env(), false, None, false).unwrap();
2149        assert_eq!(installed, 1);
2150        let first_lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
2151        let first_commit = first_lock
2152            .find("acme-lib")
2153            .and_then(|entry| entry.commit.clone())
2154            .unwrap();
2155
2156        fs::write(
2157            repo.join("lib.harn"),
2158            "pub fn value() -> string { return \"v2\" }\n",
2159        )
2160        .unwrap();
2161        run_git(&repo, &["add", "."]);
2162        run_git(&repo, &["commit", "-m", "update"]);
2163
2164        update_packages_in(workspace.env(), Some("acme-lib"), false).unwrap();
2165        let second_lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
2166        let second_commit = second_lock
2167            .find("acme-lib")
2168            .and_then(|entry| entry.commit.clone())
2169            .unwrap();
2170        assert_ne!(first_commit, second_commit);
2171    }
2172
2173    #[test]
2174    fn add_positional_local_path_dependency_uses_manifest_name_and_live_link() {
2175        let dependency_tmp = tempfile::tempdir().unwrap();
2176        let dependency_root = dependency_tmp.path().join("harn-openapi");
2177        fs::create_dir_all(&dependency_root).unwrap();
2178        fs::write(
2179            dependency_root.join(MANIFEST),
2180            r#"
2181    [package]
2182    name = "openapi"
2183    version = "0.1.0"
2184    "#,
2185        )
2186        .unwrap();
2187        fs::write(
2188            dependency_root.join("lib.harn"),
2189            "pub fn version() -> string { return \"v1\" }\n",
2190        )
2191        .unwrap();
2192
2193        let project_tmp = tempfile::tempdir().unwrap();
2194        let root = project_tmp.path();
2195        let workspace = TestWorkspace::new(root);
2196        fs::create_dir_all(root.join(".git")).unwrap();
2197        fs::write(
2198            root.join(MANIFEST),
2199            r#"
2200    [package]
2201    name = "workspace"
2202    version = "0.1.0"
2203    "#,
2204        )
2205        .unwrap();
2206
2207        add_package_to(
2208            workspace.env(),
2209            dependency_root.to_string_lossy().as_ref(),
2210            None,
2211            None,
2212            None,
2213            None,
2214            None,
2215            None,
2216            None,
2217        )
2218        .unwrap();
2219
2220        let manifest = fs::read_to_string(root.join(MANIFEST)).unwrap();
2221        assert!(
2222            manifest.contains("openapi = { path = "),
2223            "manifest should use package.name as alias: {manifest}"
2224        );
2225        let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
2226        let entry = lock.find("openapi").expect("openapi lock entry");
2227        assert!(entry.source.starts_with("path+file://"));
2228        let materialized = current_packages_dir(root).join("openapi");
2229        assert!(materialized.join("lib.harn").is_file());
2230
2231        #[cfg(unix)]
2232        assert!(
2233            fs::symlink_metadata(&materialized)
2234                .unwrap()
2235                .file_type()
2236                .is_symlink(),
2237            "path dependencies should be live-linked on Unix"
2238        );
2239
2240        #[cfg(windows)]
2241        let materialized_is_link = fs::symlink_metadata(&materialized)
2242            .unwrap()
2243            .file_type()
2244            .is_symlink();
2245
2246        fs::write(
2247            dependency_root.join("lib.harn"),
2248            "pub fn version() -> string { return \"v2\" }\n",
2249        )
2250        .unwrap();
2251        #[cfg(unix)]
2252        {
2253            let live_source = fs::read_to_string(materialized.join("lib.harn")).unwrap();
2254            assert!(
2255                live_source.contains("v2"),
2256                "materialized path dependency should reflect sibling repo edits"
2257            );
2258        }
2259        #[cfg(windows)]
2260        {
2261            let materialized_source = fs::read_to_string(materialized.join("lib.harn")).unwrap();
2262            if materialized_is_link {
2263                assert!(
2264                    materialized_source.contains("v2"),
2265                    "Windows path dependency symlink should reflect sibling repo edits"
2266                );
2267            } else {
2268                assert!(
2269                    materialized_source.contains("v1"),
2270                    "Windows path dependency copy fallback should keep the copied contents"
2271                );
2272            }
2273        }
2274
2275        remove_package_in(workspace.env(), "openapi").unwrap();
2276        assert!(!materialized.exists());
2277        assert!(dependency_root.join("lib.harn").exists());
2278    }
2279
2280    #[test]
2281    fn frozen_install_errors_when_lockfile_is_missing() {
2282        let (_repo_tmp, repo, _branch) = create_git_package_repo();
2283        let project_tmp = tempfile::tempdir().unwrap();
2284        let root = project_tmp.path();
2285        let workspace = TestWorkspace::new(root);
2286        fs::create_dir_all(root.join(".git")).unwrap();
2287        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
2288        fs::write(
2289            root.join(MANIFEST),
2290            format!(
2291                r#"
2292    [package]
2293    name = "workspace"
2294    version = "0.1.0"
2295
2296    [dependencies]
2297    acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
2298    "#
2299            ),
2300        )
2301        .unwrap();
2302
2303        let error = install_packages_in(workspace.env(), true, None, false).unwrap_err();
2304        assert!(error.to_string().contains(LOCK_FILE));
2305    }
2306
2307    #[test]
2308    fn frozen_install_tolerates_provenance_stamp_drift() {
2309        let (_repo_tmp, repo, _branch) = create_git_package_repo();
2310        let project_tmp = tempfile::tempdir().unwrap();
2311        let root = project_tmp.path();
2312        let workspace = TestWorkspace::new(root);
2313        fs::create_dir_all(root.join(".git")).unwrap();
2314        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
2315        fs::write(
2316            root.join(MANIFEST),
2317            format!(
2318                r#"
2319    [package]
2320    name = "workspace"
2321    version = "0.1.0"
2322
2323    [dependencies]
2324    acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
2325    "#
2326            ),
2327        )
2328        .unwrap();
2329
2330        let installed = install_packages_in(workspace.env(), false, None, false).unwrap();
2331        assert_eq!(installed, 1);
2332
2333        // Simulate a lock written by an older Harn release: identical
2334        // resolution, stale provenance stamps. A release bump must not
2335        // break `harn install --locked`.
2336        let lock_path = root.join(LOCK_FILE);
2337        let stale = fs::read_to_string(&lock_path)
2338            .unwrap()
2339            .replace(
2340                &format!("generator_version = \"{}\"", current_generator_version()),
2341                "generator_version = \"0.0.1\"",
2342            )
2343            .replace(
2344                &format!(
2345                    "protocol_artifact_version = \"{}\"",
2346                    current_protocol_artifact_version()
2347                ),
2348                "protocol_artifact_version = \"0.0.1\"",
2349            );
2350        assert!(
2351            stale.contains("generator_version = \"0.0.1\""),
2352            "test should have rewritten the provenance stamps: {stale}"
2353        );
2354        fs::write(&lock_path, stale).unwrap();
2355
2356        let installed = install_packages_in(workspace.env(), true, None, false).unwrap();
2357        assert_eq!(installed, 1);
2358
2359        // Frozen install must not rewrite the lock (the stale stamps stay
2360        // until a non-frozen install refreshes provenance).
2361        let after = fs::read_to_string(&lock_path).unwrap();
2362        assert!(after.contains("generator_version = \"0.0.1\""));
2363    }
2364
2365    #[test]
2366    fn frozen_install_errors_when_manifest_dropped_all_dependencies() {
2367        let (_repo_tmp, repo, _branch) = create_git_package_repo();
2368        let project_tmp = tempfile::tempdir().unwrap();
2369        let root = project_tmp.path();
2370        let workspace = TestWorkspace::new(root);
2371        fs::create_dir_all(root.join(".git")).unwrap();
2372        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
2373        fs::write(
2374            root.join(MANIFEST),
2375            format!(
2376                r#"
2377    [package]
2378    name = "workspace"
2379    version = "0.1.0"
2380
2381    [dependencies]
2382    acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
2383    "#
2384            ),
2385        )
2386        .unwrap();
2387
2388        install_packages_in(workspace.env(), false, None, false).unwrap();
2389
2390        // Manifest drops its dependencies but the stale lock still pins
2391        // them: frozen mode must flag the pending lock change instead of
2392        // silently succeeding.
2393        fs::write(
2394            root.join(MANIFEST),
2395            r#"
2396    [package]
2397    name = "workspace"
2398    version = "0.1.0"
2399    "#,
2400        )
2401        .unwrap();
2402
2403        let error = install_packages_in(workspace.env(), true, None, false).unwrap_err();
2404        assert!(error.to_string().contains("would need to change"));
2405
2406        // An empty lock (no packages) is fine in frozen mode.
2407        LockFile::default().save(&root.join(LOCK_FILE)).unwrap();
2408        let installed = install_packages_in(workspace.env(), true, None, false).unwrap();
2409        assert_eq!(installed, 0);
2410    }
2411
2412    #[test]
2413    fn offline_locked_install_materializes_from_cache_without_source_repo() {
2414        let (_repo_tmp, repo, _branch) = create_git_package_repo();
2415        let project_tmp = tempfile::tempdir().unwrap();
2416        let root = project_tmp.path();
2417        let workspace = TestWorkspace::new(root);
2418        fs::create_dir_all(root.join(".git")).unwrap();
2419        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
2420        fs::write(
2421            root.join(MANIFEST),
2422            format!(
2423                r#"
2424    [package]
2425    name = "workspace"
2426    version = "0.1.0"
2427
2428    [dependencies]
2429    acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
2430    "#
2431            ),
2432        )
2433        .unwrap();
2434
2435        let installed = install_packages_in(workspace.env(), false, None, false).unwrap();
2436        assert_eq!(installed, 1);
2437        fs::remove_dir_all(current_packages_dir(root)).unwrap();
2438        fs::remove_dir_all(&repo).unwrap();
2439
2440        let installed = install_packages_in(workspace.env(), true, None, true).unwrap();
2441        assert_eq!(installed, 1);
2442        assert!(current_packages_dir(root)
2443            .join("acme-lib")
2444            .join("lib.harn")
2445            .is_file());
2446    }
2447
2448    #[test]
2449    fn offline_locked_install_fails_when_cache_is_missing() {
2450        let (_repo_tmp, repo, _branch) = create_git_package_repo();
2451        let project_tmp = tempfile::tempdir().unwrap();
2452        let root = project_tmp.path();
2453        let workspace = TestWorkspace::new(root);
2454        let cache_dir = workspace.cache_dir();
2455        fs::create_dir_all(root.join(".git")).unwrap();
2456        let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
2457        fs::write(
2458            root.join(MANIFEST),
2459            format!(
2460                r#"
2461    [package]
2462    name = "workspace"
2463    version = "0.1.0"
2464
2465    [dependencies]
2466    acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
2467    "#
2468            ),
2469        )
2470        .unwrap();
2471
2472        install_packages_in(workspace.env(), false, None, false).unwrap();
2473        fs::remove_dir_all(cache_dir.join("git")).unwrap();
2474        let error = install_packages_in(workspace.env(), true, None, true).unwrap_err();
2475        assert!(error.to_string().contains("offline mode"));
2476    }
2477
2478    #[test]
2479    fn add_github_shorthand_requires_version_or_ref() {
2480        let error = normalize_add_request(
2481            "github.com/burin-labs/harn-openapi",
2482            None,
2483            None,
2484            None,
2485            None,
2486            None,
2487            None,
2488            None,
2489        )
2490        .unwrap_err();
2491        assert!(error
2492            .to_string()
2493            .contains("must specify `tag`, `rev`, or `branch`"));
2494    }
2495
2496    #[test]
2497    fn add_github_shorthand_with_ref_writes_git_dependency() {
2498        let (alias, dependency) = normalize_add_request(
2499            "github.com/burin-labs/harn-openapi@v1.2.3",
2500            None,
2501            None,
2502            None,
2503            None,
2504            None,
2505            None,
2506            None,
2507        )
2508        .unwrap();
2509        assert_eq!(alias, "harn-openapi");
2510        assert_eq!(
2511            render_dependency_line(&alias, &dependency).unwrap(),
2512            "harn-openapi = { git = \"https://github.com/burin-labs/harn-openapi\", rev = \"v1.2.3\" }"
2513        );
2514    }
2515    #[test]
2516    fn install_resolves_transitive_git_dependencies_from_clean_cache() {
2517        let (_sdk_tmp, sdk_repo, _branch) = create_git_package_repo_with(
2518            "notion-sdk-harn",
2519            "",
2520            "pub fn sdk_value() -> string { return \"sdk\" }\n",
2521        );
2522        let sdk_git = normalize_git_url(sdk_repo.to_string_lossy().as_ref()).unwrap();
2523        let connector_tail = format!(
2524            r#"
2525
2526    [dependencies]
2527    notion-sdk-harn = {{ git = "{sdk_git}", rev = "v1.0.0" }}
2528    "#
2529        );
2530        let (_connector_tmp, connector_repo, _branch) = create_git_package_repo_with(
2531            "notion-connector-harn",
2532            &connector_tail,
2533            r#"
2534    import "notion-sdk-harn"
2535
2536    pub fn connector_value() -> string {
2537      return "connector"
2538    }
2539    "#,
2540        );
2541
2542        let project_tmp = tempfile::tempdir().unwrap();
2543        let root = project_tmp.path();
2544        let workspace = TestWorkspace::new(root);
2545        fs::create_dir_all(root.join(".git")).unwrap();
2546        let connector_git = normalize_git_url(connector_repo.to_string_lossy().as_ref()).unwrap();
2547        fs::write(
2548            root.join(MANIFEST),
2549            format!(
2550                r#"
2551    [package]
2552    name = "workspace"
2553    version = "0.1.0"
2554
2555    [dependencies]
2556    notion-connector-harn = {{ git = "{connector_git}", rev = "v1.0.0" }}
2557    "#
2558            ),
2559        )
2560        .unwrap();
2561
2562        let installed = install_packages_in(workspace.env(), false, None, false).unwrap();
2563        assert_eq!(installed, 2);
2564
2565        let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
2566        assert!(lock.find("notion-connector-harn").is_some());
2567        assert!(lock.find("notion-sdk-harn").is_some());
2568        assert!(current_packages_dir(root)
2569            .join("notion-connector-harn")
2570            .join("lib.harn")
2571            .is_file());
2572        assert!(current_packages_dir(root)
2573            .join("notion-sdk-harn")
2574            .join("lib.harn")
2575            .is_file());
2576
2577        let mut vm = test_vm();
2578        let exports = futures::executor::block_on(
2579            vm.load_module_exports(
2580                &current_packages_dir(root)
2581                    .join("notion-connector-harn")
2582                    .join("lib.harn"),
2583            ),
2584        )
2585        .expect("transitive import should load from the workspace package root");
2586        assert!(exports.contains_key("connector_value"));
2587    }
2588
2589    #[test]
2590    fn git_packages_reject_transitive_path_dependencies() {
2591        let connector_tail = r#"
2592
2593    [dependencies]
2594    local-helper = { path = "../helper" }
2595    "#;
2596        let (_connector_tmp, connector_repo, _branch) = create_git_package_repo_with(
2597            "notion-connector-harn",
2598            connector_tail,
2599            "pub fn connector_value() -> string { return \"connector\" }\n",
2600        );
2601
2602        let project_tmp = tempfile::tempdir().unwrap();
2603        let root = project_tmp.path();
2604        let workspace = TestWorkspace::new(root);
2605        fs::create_dir_all(root.join(".git")).unwrap();
2606        let connector_git = normalize_git_url(connector_repo.to_string_lossy().as_ref()).unwrap();
2607        fs::write(
2608            root.join(MANIFEST),
2609            format!(
2610                r#"
2611    [package]
2612    name = "workspace"
2613    version = "0.1.0"
2614
2615    [dependencies]
2616    notion-connector-harn = {{ git = "{connector_git}", rev = "v1.0.0" }}
2617    "#
2618            ),
2619        )
2620        .unwrap();
2621
2622        let error = install_packages_in(workspace.env(), false, None, false).unwrap_err();
2623        assert!(error
2624            .to_string()
2625            .contains("path dependencies are not supported inside remote-installed packages"));
2626    }
2627
2628    #[test]
2629    fn package_alias_validation_rejects_path_traversal_names() {
2630        for alias in [
2631            "../evil",
2632            "nested/evil",
2633            "nested\\evil",
2634            ".",
2635            "..",
2636            "bad alias",
2637        ] {
2638            assert!(
2639                validate_package_alias(alias).is_err(),
2640                "{alias:?} should be rejected"
2641            );
2642        }
2643        validate_package_alias("acme-lib_1.2").expect("ordinary alias should be accepted");
2644    }
2645
2646    #[test]
2647    fn add_package_rejects_aliases_that_escape_packages_dir() {
2648        let error = normalize_add_request(
2649            "ignored",
2650            Some("../evil"),
2651            None,
2652            None,
2653            None,
2654            None,
2655            Some("./dep"),
2656            None,
2657        )
2658        .unwrap_err();
2659        assert!(error.to_string().contains("invalid dependency alias"));
2660    }
2661
2662    #[test]
2663    fn rendered_dependency_values_are_toml_escaped() {
2664        let path = "dep\" \nmalicious = true";
2665        let line = render_dependency_line(
2666            "safe",
2667            &Dependency::Table(Box::new(DepTable {
2668                path: Some(path.to_string()),
2669                ..DepTable::default()
2670            })),
2671        )
2672        .expect("dependency line");
2673        let parsed: Manifest = toml::from_str(&format!("[dependencies]\n{line}\n")).unwrap();
2674        assert_eq!(parsed.dependencies.len(), 1);
2675        assert_eq!(
2676            parsed
2677                .dependencies
2678                .get("safe")
2679                .and_then(Dependency::local_path),
2680            Some(path)
2681        );
2682    }
2683
2684    #[test]
2685    fn materialization_rejects_lock_alias_path_traversal_before_removing_paths() {
2686        let tmp = tempfile::tempdir().unwrap();
2687        let dep = tmp.path().join("dep");
2688        fs::create_dir_all(&dep).unwrap();
2689        fs::write(dep.join("lib.harn"), "pub fn dep() { 1 }\n").unwrap();
2690        let victim = tmp.path().join("victim");
2691        fs::create_dir_all(&victim).unwrap();
2692        fs::write(victim.join("keep.txt"), "keep").unwrap();
2693
2694        let manifest: Manifest = toml::from_str("[package]\nname = \"root\"\n").unwrap();
2695        let ctx = ManifestContext {
2696            manifest,
2697            dir: tmp.path().to_path_buf(),
2698        };
2699        let workspace = TestWorkspace::new(tmp.path());
2700        let lock = LockFile {
2701            packages: vec![LockEntry {
2702                name: "../victim".to_string(),
2703                source: path_source_uri(&dep).unwrap(),
2704                ..LockEntry::default()
2705            }],
2706            ..LockFile::default()
2707        };
2708
2709        let error = materialize_dependencies_from_lock(workspace.env(), &ctx, &lock, None, false)
2710            .unwrap_err();
2711        assert!(error.to_string().contains("invalid dependency alias"));
2712        assert!(
2713            victim.join("keep.txt").exists(),
2714            "malicious alias should not remove paths outside the materialization root"
2715        );
2716    }
2717}