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