Skip to main content

harn_cli/package/
lockfile.rs

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}