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