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