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