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