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