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