use super::errors::PackageError;
use super::*;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct LockFile {
pub(crate) version: u32,
#[serde(default, rename = "package")]
pub(crate) packages: Vec<LockEntry>,
}
impl Default for LockFile {
fn default() -> Self {
Self {
version: LOCK_FILE_VERSION,
packages: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct LockEntry {
pub(crate) name: String,
pub(crate) source: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) rev_request: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) commit: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) content_hash: Option<String>,
}
impl LockFile {
pub(crate) fn load(path: &Path) -> Result<Option<Self>, PackageError> {
let content = match fs::read_to_string(path) {
Ok(s) => s,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(format!("failed to read {}: {error}", path.display()).into()),
};
match toml::from_str::<Self>(&content) {
Ok(mut lock) => {
if lock.version != LOCK_FILE_VERSION {
return Err(format!(
"unsupported {} version {} (expected {})",
path.display(),
lock.version,
LOCK_FILE_VERSION
)
.into());
}
lock.sort_entries();
Ok(Some(lock))
}
Err(_) => {
let legacy = toml::from_str::<LegacyLockFile>(&content)
.map_err(|error| format!("failed to parse {}: {error}", path.display()))?;
let mut lock = Self {
version: LOCK_FILE_VERSION,
packages: legacy
.packages
.into_iter()
.map(|entry| LockEntry {
name: entry.name,
source: entry
.path
.map(|path| format!("path+{path}"))
.or_else(|| entry.git.map(|git| format!("git+{git}")))
.unwrap_or_default(),
rev_request: entry.rev_request.or(entry.tag),
commit: entry.commit,
content_hash: None,
})
.collect(),
};
lock.sort_entries();
Ok(Some(lock))
}
}
}
fn save(&self, path: &Path) -> Result<(), PackageError> {
let mut normalized = self.clone();
normalized.version = LOCK_FILE_VERSION;
normalized.sort_entries();
let body = toml::to_string_pretty(&normalized)
.map_err(|error| format!("failed to encode {}: {error}", path.display()))?;
let mut out = String::from("# This file is auto-generated by Harn. Do not edit.\n\n");
out.push_str(&body);
harn_vm::atomic_io::atomic_write(path, out.as_bytes()).map_err(|error| {
PackageError::Lockfile(format!("failed to write {}: {error}", path.display()))
})
}
pub(crate) fn sort_entries(&mut self) {
self.packages
.sort_by(|left, right| left.name.cmp(&right.name));
}
pub(crate) fn find(&self, name: &str) -> Option<&LockEntry> {
self.packages.iter().find(|entry| entry.name == name)
}
fn replace(&mut self, entry: LockEntry) {
if let Some(existing) = self.packages.iter_mut().find(|pkg| pkg.name == entry.name) {
*existing = entry;
} else {
self.packages.push(entry);
}
self.sort_entries();
}
fn remove(&mut self, name: &str) {
self.packages.retain(|entry| entry.name != name);
}
}
#[derive(Debug, Deserialize)]
pub(crate) struct LegacyLockFile {
#[serde(default, rename = "package")]
packages: Vec<LegacyLockEntry>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct LegacyLockEntry {
pub(crate) name: String,
#[serde(default)]
git: Option<String>,
#[serde(default)]
tag: Option<String>,
#[serde(default)]
pub(crate) rev_request: Option<String>,
#[serde(default)]
pub(crate) commit: Option<String>,
#[serde(default)]
path: Option<String>,
}
pub(crate) fn compatible_locked_entry(
alias: &str,
dependency: &Dependency,
lock: &LockEntry,
manifest_dir: &Path,
) -> Result<bool, PackageError> {
if lock.name != alias {
return Ok(false);
}
if let Some(path) = dependency.local_path() {
let source = path_source_uri(&resolve_path_dependency_source(manifest_dir, path)?)?;
return Ok(lock.source == source);
}
if let Some(url) = dependency.git_url() {
let source = format!("git+{}", normalize_git_url(url)?);
let requested = dependency
.branch()
.map(str::to_string)
.or_else(|| dependency.rev().map(str::to_string));
return Ok(lock.source == source
&& lock.rev_request == requested
&& lock.commit.is_some()
&& lock.content_hash.is_some());
}
Ok(false)
}
#[derive(Debug, Clone)]
pub(crate) struct PendingDependency {
alias: String,
dependency: Dependency,
manifest_dir: PathBuf,
parent: Option<String>,
parent_is_git: bool,
}
pub(crate) fn git_rev_request(
alias: &str,
dependency: &Dependency,
) -> Result<String, PackageError> {
dependency
.branch()
.or_else(|| dependency.rev())
.map(str::to_string)
.ok_or_else(|| {
PackageError::Lockfile(format!(
"git dependency {alias} must specify `rev` or `branch`; use `harn add <url>@<tag-or-sha>` or add `rev = \"...\"` to {MANIFEST}"
))
})
}
pub(crate) fn dependency_manifest_dir(source: &Path) -> Option<PathBuf> {
if source.is_dir() {
return Some(source.to_path_buf());
}
source.parent().map(Path::to_path_buf)
}
pub(crate) fn read_package_manifest_from_dir(dir: &Path) -> Result<Option<Manifest>, PackageError> {
let manifest_path = dir.join(MANIFEST);
if !manifest_path.exists() {
return Ok(None);
}
read_manifest_from_path(&manifest_path).map(Some)
}
pub(crate) fn dependency_conflict_message(
existing: &LockEntry,
candidate: &LockEntry,
) -> PackageError {
PackageError::Lockfile(format!(
"dependency alias '{}' resolves to multiple packages ({} and {}); use distinct aliases in {MANIFEST}",
candidate.name, existing.source, candidate.source
))
}
pub(crate) fn replace_lock_entry(
lock: &mut LockFile,
candidate: LockEntry,
) -> Result<bool, PackageError> {
validate_package_alias(&candidate.name)?;
if let Some(existing) = lock.find(&candidate.name) {
if existing == &candidate {
return Ok(false);
}
return Err(dependency_conflict_message(existing, &candidate));
}
lock.replace(candidate);
Ok(true)
}
pub(crate) fn enqueue_manifest_dependencies(
pending: &mut Vec<PendingDependency>,
manifest: Manifest,
manifest_dir: PathBuf,
parent: String,
parent_is_git: bool,
) {
let mut aliases: Vec<String> = manifest.dependencies.keys().cloned().collect();
aliases.sort();
for alias in aliases.into_iter().rev() {
if let Some(dependency) = manifest.dependencies.get(&alias).cloned() {
pending.push(PendingDependency {
alias,
dependency,
manifest_dir: manifest_dir.clone(),
parent: Some(parent.clone()),
parent_is_git,
});
}
}
}
pub(crate) fn build_lockfile(
ctx: &ManifestContext,
existing: Option<&LockFile>,
refresh_alias: Option<&str>,
refresh_all: bool,
allow_resolve: bool,
offline: bool,
) -> Result<LockFile, PackageError> {
if manifest_has_git_dependencies(&ctx.manifest) {
ensure_git_available()?;
}
let mut lock = LockFile::default();
let mut pending: Vec<PendingDependency> = Vec::new();
let mut aliases: Vec<String> = ctx.manifest.dependencies.keys().cloned().collect();
aliases.sort();
for alias in aliases.into_iter().rev() {
let dependency = ctx
.manifest
.dependencies
.get(&alias)
.ok_or_else(|| format!("dependency {alias} disappeared while locking"))?
.clone();
pending.push(PendingDependency {
alias,
dependency,
manifest_dir: ctx.dir.clone(),
parent: None,
parent_is_git: false,
});
}
while let Some(next) = pending.pop() {
let alias = next.alias;
validate_package_alias(&alias)?;
let dependency = next.dependency;
if dependency.local_path().is_some() && next.parent_is_git {
let parent = next.parent.as_deref().unwrap_or("a git package");
return Err(format!(
"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`"
).into());
}
if dependency.git_url().is_some() {
ensure_git_available()?;
git_rev_request(&alias, &dependency)?;
}
let refresh = refresh_all || refresh_alias == Some(alias.as_str());
if let Some(existing_lock) = existing.and_then(|lock| lock.find(&alias)) {
if !refresh
&& compatible_locked_entry(&alias, &dependency, existing_lock, &next.manifest_dir)?
{
let mut entry = existing_lock.clone();
if entry.source.starts_with("git+") && entry.content_hash.is_none() {
let url = entry.source.trim_start_matches("git+");
let commit = entry
.commit
.as_deref()
.ok_or_else(|| format!("missing locked commit for {alias}"))?;
entry.content_hash = Some(ensure_git_cache_populated(
url,
&entry.source,
commit,
None,
false,
offline,
)?);
}
let inserted = replace_lock_entry(&mut lock, entry.clone())?;
if entry.source.starts_with("git+") {
let url = entry.source.trim_start_matches("git+");
let commit = entry
.commit
.as_deref()
.ok_or_else(|| format!("missing locked commit for {alias}"))?;
let expected_hash = entry
.content_hash
.as_deref()
.ok_or_else(|| format!("missing content hash for {alias}"))?;
ensure_git_cache_populated(
url,
&entry.source,
commit,
Some(expected_hash),
false,
offline,
)?;
if inserted {
let cache_dir = git_cache_dir(&entry.source, commit)?;
if let Some(manifest) = read_package_manifest_from_dir(&cache_dir)? {
enqueue_manifest_dependencies(
&mut pending,
manifest,
cache_dir,
alias,
true,
);
}
}
} else if inserted && entry.source.starts_with("path+") {
let source = path_from_source_uri(&entry.source)?;
if let Some(manifest_dir) = dependency_manifest_dir(&source) {
if let Some(manifest) = read_package_manifest_from_dir(&manifest_dir)? {
enqueue_manifest_dependencies(
&mut pending,
manifest,
manifest_dir,
alias,
false,
);
}
}
}
continue;
}
}
if !allow_resolve {
return Err(format!("{} would need to change", ctx.lock_path().display()).into());
}
if let Some(path) = dependency.local_path() {
let source = resolve_path_dependency_source(&next.manifest_dir, path)?;
let package_alias = alias.clone();
let entry = LockEntry {
name: alias.clone(),
source: path_source_uri(&source)?,
rev_request: None,
commit: None,
content_hash: None,
};
let inserted = replace_lock_entry(&mut lock, entry)?;
if inserted {
if let Some(manifest_dir) = dependency_manifest_dir(&source) {
if let Some(manifest) = read_package_manifest_from_dir(&manifest_dir)? {
enqueue_manifest_dependencies(
&mut pending,
manifest,
manifest_dir,
package_alias,
false,
);
}
}
}
continue;
}
if let Some(url) = dependency.git_url() {
let rev_request = git_rev_request(&alias, &dependency)?;
let normalized_url = normalize_git_url(url)?;
let source = format!("git+{normalized_url}");
let commit =
resolve_git_commit(&normalized_url, dependency.rev(), dependency.branch())?;
let content_hash = ensure_git_cache_populated(
&normalized_url,
&source,
&commit,
None,
false,
offline,
)?;
let entry = LockEntry {
name: alias.clone(),
source: source.clone(),
rev_request: Some(rev_request),
commit: Some(commit.clone()),
content_hash: Some(content_hash),
};
let inserted = replace_lock_entry(&mut lock, entry)?;
if inserted {
let cache_dir = git_cache_dir(&source, &commit)?;
if let Some(manifest) = read_package_manifest_from_dir(&cache_dir)? {
enqueue_manifest_dependencies(&mut pending, manifest, cache_dir, alias, true);
}
}
continue;
}
return Err(format!("dependency {alias} is missing a git or path source").into());
}
Ok(lock)
}
pub(crate) fn materialize_dependencies_from_lock(
ctx: &ManifestContext,
lock: &LockFile,
refetch: Option<&str>,
offline: bool,
) -> Result<usize, PackageError> {
let packages_dir = ctx.packages_dir();
fs::create_dir_all(&packages_dir)
.map_err(|error| format!("failed to create {}: {error}", packages_dir.display()))?;
let mut installed = 0usize;
for entry in &lock.packages {
let alias = &entry.name;
validate_package_alias(alias)?;
if entry.source.starts_with("path+") {
let source = path_from_source_uri(&entry.source)?;
materialize_path_dependency(&source, &packages_dir, alias)?;
installed += 1;
continue;
}
let commit = entry
.commit
.as_deref()
.ok_or_else(|| format!("missing locked commit for {alias}"))?;
let expected_hash = entry
.content_hash
.as_deref()
.ok_or_else(|| format!("missing content hash for {alias}"))?;
let source = entry.source.clone();
let url = source.trim_start_matches("git+");
let refetch_this = refetch == Some("all") || refetch == Some(alias.as_str());
ensure_git_cache_populated(
url,
&source,
commit,
Some(expected_hash),
refetch_this,
offline,
)?;
let cache_dir = git_cache_dir(&source, commit)?;
let dest_dir = packages_dir.join(alias);
if !dest_dir.exists() || !materialized_hash_matches(&dest_dir, expected_hash) {
remove_materialized_package(&packages_dir, alias)?;
copy_dir_recursive(&cache_dir, &dest_dir)?;
write_cached_content_hash(&dest_dir, expected_hash)?;
}
installed += 1;
}
Ok(installed)
}
pub(crate) fn validate_lock_matches_manifest(
ctx: &ManifestContext,
lock: &LockFile,
) -> Result<(), PackageError> {
for (alias, dependency) in &ctx.manifest.dependencies {
validate_package_alias(alias)?;
let entry = lock.find(alias).ok_or_else(|| {
format!(
"{} is missing an entry for {alias}",
ctx.lock_path().display()
)
})?;
if !compatible_locked_entry(alias, dependency, entry, &ctx.dir)? {
return Err(format!(
"{} is out of date for {alias}; run `harn install`",
ctx.lock_path().display()
)
.into());
}
}
Ok(())
}
pub fn ensure_dependencies_materialized(anchor: &Path) -> Result<(), PackageError> {
let Some((manifest, dir)) = find_nearest_manifest(anchor) else {
return Ok(());
};
if manifest.dependencies.is_empty() {
return Ok(());
}
let ctx = ManifestContext { manifest, dir };
let lock = LockFile::load(&ctx.lock_path())?.ok_or_else(|| {
format!(
"{} is missing; run `harn install`",
ctx.lock_path().display()
)
})?;
validate_lock_matches_manifest(&ctx, &lock)?;
materialize_dependencies_from_lock(&ctx, &lock, None, false)?;
Ok(())
}
pub(crate) fn dependency_section_bounds(lines: &[String]) -> Option<(usize, usize)> {
let start = lines
.iter()
.position(|line| line.trim() == "[dependencies]")?;
let end = lines
.iter()
.enumerate()
.skip(start + 1)
.find(|(_, line)| line.trim_start().starts_with('['))
.map(|(index, _)| index)
.unwrap_or(lines.len());
Some((start, end))
}
pub(crate) fn render_dependency_line(
alias: &str,
dependency: &Dependency,
) -> Result<String, PackageError> {
validate_package_alias(alias)?;
match dependency {
Dependency::Path(path) => Ok(format!(
"{alias} = {{ path = {} }}",
toml_string_literal(path)?
)),
Dependency::Table(table) => {
let mut fields = Vec::new();
if let Some(path) = table.path.as_deref() {
fields.push(format!("path = {}", toml_string_literal(path)?));
}
if let Some(git) = table.git.as_deref() {
fields.push(format!("git = {}", toml_string_literal(git)?));
}
if let Some(branch) = table.branch.as_deref() {
fields.push(format!("branch = {}", toml_string_literal(branch)?));
} else if let Some(rev) = table.rev.as_deref().or(table.tag.as_deref()) {
fields.push(format!("rev = {}", toml_string_literal(rev)?));
}
if let Some(package) = table.package.as_deref() {
fields.push(format!("package = {}", toml_string_literal(package)?));
}
Ok(format!("{alias} = {{ {} }}", fields.join(", ")))
}
}
}
pub(crate) fn ensure_manifest_exists(manifest_path: &Path) -> Result<String, PackageError> {
if manifest_path.exists() {
return fs::read_to_string(manifest_path).map_err(|error| {
PackageError::Lockfile(format!(
"failed to read {}: {error}",
manifest_path.display()
))
});
}
Ok("[package]\nname = \"my-project\"\nversion = \"0.1.0\"\n".to_string())
}
pub(crate) fn upsert_dependency_in_manifest(
manifest_path: &Path,
alias: &str,
dependency: &Dependency,
) -> Result<(), PackageError> {
let content = ensure_manifest_exists(manifest_path)?;
let mut lines: Vec<String> = content.lines().map(|line| line.to_string()).collect();
if dependency_section_bounds(&lines).is_none() {
if !lines.is_empty() && !lines.last().is_some_and(|line| line.is_empty()) {
lines.push(String::new());
}
lines.push("[dependencies]".to_string());
}
let (start, end) = dependency_section_bounds(&lines).ok_or_else(|| {
format!(
"failed to locate [dependencies] in {}",
manifest_path.display()
)
})?;
let rendered = render_dependency_line(alias, dependency)?;
if let Some((index, _)) = lines
.iter()
.enumerate()
.skip(start + 1)
.take(end - start - 1)
.find(|(_, line)| {
line.split('=')
.next()
.is_some_and(|key| key.trim() == alias)
})
{
lines[index] = rendered;
} else {
lines.insert(end, rendered);
}
write_manifest_content(manifest_path, &(lines.join("\n") + "\n"))
}
pub(crate) fn remove_dependency_from_manifest(
manifest_path: &Path,
alias: &str,
) -> Result<bool, PackageError> {
let content = fs::read_to_string(manifest_path)
.map_err(|error| format!("failed to read {}: {error}", manifest_path.display()))?;
let mut lines: Vec<String> = content.lines().map(|line| line.to_string()).collect();
let Some((start, end)) = dependency_section_bounds(&lines) else {
return Ok(false);
};
let mut removed = false;
lines = lines
.into_iter()
.enumerate()
.filter_map(|(index, line)| {
if index <= start || index >= end {
return Some(line);
}
let matches = line
.split('=')
.next()
.is_some_and(|key| key.trim() == alias);
if matches {
removed = true;
None
} else {
Some(line)
}
})
.collect();
if removed {
write_manifest_content(manifest_path, &(lines.join("\n") + "\n"))?;
}
Ok(removed)
}
pub(crate) fn install_packages_impl(
frozen: bool,
refetch: Option<&str>,
offline: bool,
) -> Result<usize, PackageError> {
let ctx = load_current_manifest_context()?;
let existing = LockFile::load(&ctx.lock_path())?;
if ctx.manifest.dependencies.is_empty() {
if !frozen {
LockFile::default().save(&ctx.lock_path())?;
}
return Ok(0);
}
if (frozen || offline) && existing.is_none() {
return Err(format!("{} is missing", ctx.lock_path().display()).into());
}
let desired = build_lockfile(
&ctx,
existing.as_ref(),
None,
false,
!frozen && !offline,
offline,
)?;
if frozen || offline {
if existing.as_ref() != Some(&desired) {
return Err(format!("{} would need to change", ctx.lock_path().display()).into());
}
} else {
desired.save(&ctx.lock_path())?;
}
materialize_dependencies_from_lock(&ctx, &desired, refetch, offline)
}
pub fn install_packages(frozen: bool, refetch: Option<&str>, offline: bool) {
match install_packages_impl(frozen, refetch, offline) {
Ok(0) => println!("No dependencies to install."),
Ok(installed) => println!("Installed {installed} package(s) to {PKG_DIR}/"),
Err(error) => {
eprintln!("error: {error}");
process::exit(1);
}
}
}
pub fn lock_packages() {
let result = (|| -> Result<usize, PackageError> {
let ctx = load_current_manifest_context()?;
let existing = LockFile::load(&ctx.lock_path())?;
let lock = build_lockfile(&ctx, existing.as_ref(), None, true, true, false)?;
lock.save(&ctx.lock_path())?;
Ok(lock.packages.len())
})();
match result {
Ok(count) => println!("Wrote {} with {count} package(s).", LOCK_FILE),
Err(error) => {
eprintln!("error: {error}");
process::exit(1);
}
}
}
pub fn update_packages(alias: Option<&str>, all: bool) {
if !all && alias.is_none() {
eprintln!("error: specify a dependency alias or pass --all");
process::exit(1);
}
let result = (|| -> Result<usize, PackageError> {
let ctx = load_current_manifest_context()?;
if let Some(alias) = alias {
validate_package_alias(alias)?;
if !ctx.manifest.dependencies.contains_key(alias) {
return Err(format!("{alias} is not present in [dependencies]").into());
}
}
let existing = LockFile::load(&ctx.lock_path())?;
let lock = build_lockfile(&ctx, existing.as_ref(), alias, all, true, false)?;
lock.save(&ctx.lock_path())?;
materialize_dependencies_from_lock(&ctx, &lock, None, false)
})();
match result {
Ok(installed) => println!("Updated {installed} package(s)."),
Err(error) => {
eprintln!("error: {error}");
process::exit(1);
}
}
}
pub fn remove_package(alias: &str) {
let result = (|| -> Result<bool, PackageError> {
validate_package_alias(alias)?;
let ctx = load_current_manifest_context()?;
let removed = remove_dependency_from_manifest(&ctx.manifest_path(), alias)?;
if !removed {
return Ok(false);
}
let mut lock = LockFile::load(&ctx.lock_path())?.unwrap_or_default();
lock.remove(alias);
lock.save(&ctx.lock_path())?;
remove_materialized_package(&ctx.packages_dir(), alias)?;
Ok(true)
})();
match result {
Ok(true) => println!("Removed {alias} from {MANIFEST} and {LOCK_FILE}."),
Ok(false) => {
eprintln!("error: {alias} is not present in [dependencies]");
process::exit(1);
}
Err(error) => {
eprintln!("error: {error}");
process::exit(1);
}
}
}
pub(crate) fn normalize_add_request(
name_or_spec: &str,
alias: Option<&str>,
git_url: Option<&str>,
tag: Option<&str>,
rev: Option<&str>,
branch: Option<&str>,
local_path: Option<&str>,
registry: Option<&str>,
) -> Result<(String, Dependency), PackageError> {
if local_path.is_some() && (rev.is_some() || tag.is_some() || branch.is_some()) {
return Err("path dependencies do not accept --rev, --tag, or --branch"
.to_string()
.into());
}
if git_url.is_none()
&& local_path.is_none()
&& rev.is_none()
&& tag.is_none()
&& branch.is_none()
{
if let Some(path) = existing_local_path_spec(name_or_spec) {
let alias = alias
.map(str::to_string)
.map(Ok)
.unwrap_or_else(|| derive_package_alias_from_path(&path))?;
validate_package_alias(&alias)?;
return Ok((
alias,
Dependency::Table(DepTable {
git: None,
tag: None,
rev: None,
branch: None,
path: Some(name_or_spec.to_string()),
package: None,
}),
));
}
if parse_registry_package_spec(name_or_spec).is_some() {
return registry_dependency_from_spec(name_or_spec, alias, registry);
}
}
if git_url.is_some() || local_path.is_some() {
if let Some(path) = local_path {
let alias = alias
.map(str::to_string)
.unwrap_or_else(|| name_or_spec.to_string());
validate_package_alias(&alias)?;
return Ok((
alias,
Dependency::Table(DepTable {
git: None,
tag: None,
rev: None,
branch: None,
path: Some(path.to_string()),
package: None,
}),
));
}
let alias = alias.unwrap_or(name_or_spec).to_string();
validate_package_alias(&alias)?;
if rev.is_none() && tag.is_none() && branch.is_none() {
return Err(format!(
"git dependency {alias} must specify `rev` or `branch`; use `harn add <url>@<tag-or-sha>` or pass `--rev`/`--branch`"
).into());
}
let git = normalize_git_url(git_url.ok_or_else(|| "missing --git URL".to_string())?)?;
let package_name = derive_repo_name_from_source(&git)?;
return Ok((
alias.clone(),
Dependency::Table(DepTable {
git: Some(git),
tag: None,
rev: rev.or(tag).map(str::to_string),
branch: branch.map(str::to_string),
path: None,
package: (alias != package_name).then_some(package_name),
}),
));
}
if rev.is_some() && tag.is_some() {
return Err("use only one of --rev or --tag".to_string().into());
}
let (raw_source, inline_ref) = parse_positional_git_spec(name_or_spec);
if inline_ref.is_some() && (rev.is_some() || tag.is_some() || branch.is_some()) {
return Err(
"specify the git ref either inline as @ref or via --rev/--branch"
.to_string()
.into(),
);
}
let git = normalize_git_url(raw_source)?;
let package_name = derive_repo_name_from_source(&git)?;
let alias = alias.unwrap_or(package_name.as_str()).to_string();
validate_package_alias(&alias)?;
if inline_ref.is_none() && rev.is_none() && tag.is_none() && branch.is_none() {
return Err(format!(
"git dependency {alias} must specify `rev` or `branch`; use `harn add {raw_source}@<tag-or-sha>` or pass `--rev`/`--branch`"
).into());
}
Ok((
alias.clone(),
Dependency::Table(DepTable {
git: Some(git),
tag: None,
rev: inline_ref.or(rev).or(tag).map(str::to_string),
branch: branch.map(str::to_string),
path: None,
package: (alias != package_name).then_some(package_name),
}),
))
}
#[cfg(test)]
pub fn add_package(
name_or_spec: &str,
alias: Option<&str>,
git_url: Option<&str>,
tag: Option<&str>,
rev: Option<&str>,
branch: Option<&str>,
local_path: Option<&str>,
) {
add_package_with_registry(
name_or_spec,
alias,
git_url,
tag,
rev,
branch,
local_path,
None,
)
}
pub fn add_package_with_registry(
name_or_spec: &str,
alias: Option<&str>,
git_url: Option<&str>,
tag: Option<&str>,
rev: Option<&str>,
branch: Option<&str>,
local_path: Option<&str>,
registry: Option<&str>,
) {
let result = (|| -> Result<(String, usize), PackageError> {
let manifest_path = std::env::current_dir()
.map_err(|error| format!("failed to read cwd: {error}"))?
.join(MANIFEST);
let (alias, dependency) = normalize_add_request(
name_or_spec,
alias,
git_url,
tag,
rev,
branch,
local_path,
registry,
)?;
upsert_dependency_in_manifest(&manifest_path, &alias, &dependency)?;
let installed = install_packages_impl(false, None, false)?;
Ok((alias, installed))
})();
match result {
Ok((alias, installed)) => {
println!("Added {alias} to {MANIFEST}.");
println!("Installed {installed} package(s).");
}
Err(error) => {
eprintln!("error: {error}");
process::exit(1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::package::test_support::*;
#[test]
fn lock_file_round_trips_typed_schema() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join(LOCK_FILE);
let lock = LockFile {
version: LOCK_FILE_VERSION,
packages: vec![LockEntry {
name: "acme-lib".to_string(),
source: "git+https://github.com/acme/acme-lib".to_string(),
rev_request: Some("v1.0.0".to_string()),
commit: Some("0123456789abcdef0123456789abcdef01234567".to_string()),
content_hash: Some("sha256:deadbeef".to_string()),
}],
};
lock.save(&path).unwrap();
let loaded = LockFile::load(&path).unwrap().unwrap();
assert_eq!(loaded, lock);
}
#[test]
fn add_and_remove_git_dependency_round_trip() {
let (_repo_tmp, repo, _branch) = create_git_package_repo();
let project_tmp = tempfile::tempdir().unwrap();
let root = project_tmp.path();
let cache_dir = root.join(".cache");
fs::create_dir_all(root.join(".git")).unwrap();
fs::write(
root.join(MANIFEST),
r#"
[package]
name = "workspace"
version = "0.1.0"
"#,
)
.unwrap();
with_test_env(root, &cache_dir, || {
let spec = format!("{}@v1.0.0", repo.display());
add_package(&spec, None, None, None, None, None, None);
let alias = "acme-lib";
let manifest = fs::read_to_string(root.join(MANIFEST)).unwrap();
assert!(manifest.contains("acme-lib"));
assert!(manifest.contains("rev = \"v1.0.0\""));
let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
let entry = lock.find(alias).unwrap();
assert_eq!(lock.version, LOCK_FILE_VERSION);
assert!(entry.source.starts_with("git+file://"));
assert!(entry.commit.as_deref().is_some_and(is_full_git_sha));
assert!(entry
.content_hash
.as_deref()
.is_some_and(|hash| hash.starts_with("sha256:")));
assert!(root.join(PKG_DIR).join(alias).join("lib.harn").is_file());
remove_package(alias);
let updated_manifest = fs::read_to_string(root.join(MANIFEST)).unwrap();
assert!(!updated_manifest.contains("acme-lib ="));
let updated_lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
assert!(updated_lock.find(alias).is_none());
assert!(!root.join(PKG_DIR).join(alias).exists());
});
}
#[test]
fn update_branch_dependency_refreshes_locked_commit() {
let (_repo_tmp, repo, branch) = create_git_package_repo();
let project_tmp = tempfile::tempdir().unwrap();
let root = project_tmp.path();
let cache_dir = root.join(".cache");
fs::create_dir_all(root.join(".git")).unwrap();
let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
fs::write(
root.join(MANIFEST),
format!(
r#"
[package]
name = "workspace"
version = "0.1.0"
[dependencies]
acme-lib = {{ git = "{git}", branch = "{branch}" }}
"#
),
)
.unwrap();
with_test_env(root, &cache_dir, || {
let installed = install_packages_impl(false, None, false).unwrap();
assert_eq!(installed, 1);
let first_lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
let first_commit = first_lock
.find("acme-lib")
.and_then(|entry| entry.commit.clone())
.unwrap();
fs::write(
repo.join("lib.harn"),
"pub fn value() -> string { return \"v2\" }\n",
)
.unwrap();
run_git(&repo, &["add", "."]);
run_git(&repo, &["commit", "-m", "update"]);
update_packages(Some("acme-lib"), false);
let second_lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
let second_commit = second_lock
.find("acme-lib")
.and_then(|entry| entry.commit.clone())
.unwrap();
assert_ne!(first_commit, second_commit);
});
}
#[test]
fn add_positional_local_path_dependency_uses_manifest_name_and_live_link() {
let dependency_tmp = tempfile::tempdir().unwrap();
let dependency_root = dependency_tmp.path().join("harn-openapi");
fs::create_dir_all(&dependency_root).unwrap();
fs::write(
dependency_root.join(MANIFEST),
r#"
[package]
name = "openapi"
version = "0.1.0"
"#,
)
.unwrap();
fs::write(
dependency_root.join("lib.harn"),
"pub fn version() -> string { return \"v1\" }\n",
)
.unwrap();
let project_tmp = tempfile::tempdir().unwrap();
let root = project_tmp.path();
let cache_dir = root.join(".cache");
fs::create_dir_all(root.join(".git")).unwrap();
fs::write(
root.join(MANIFEST),
r#"
[package]
name = "workspace"
version = "0.1.0"
"#,
)
.unwrap();
with_test_env(root, &cache_dir, || {
add_package(
dependency_root.to_string_lossy().as_ref(),
None,
None,
None,
None,
None,
None,
);
let manifest = fs::read_to_string(root.join(MANIFEST)).unwrap();
assert!(
manifest.contains("openapi = { path = "),
"manifest should use package.name as alias: {manifest}"
);
let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
let entry = lock.find("openapi").expect("openapi lock entry");
assert!(entry.source.starts_with("path+file://"));
let materialized = root.join(PKG_DIR).join("openapi");
assert!(materialized.join("lib.harn").is_file());
#[cfg(unix)]
assert!(
fs::symlink_metadata(&materialized)
.unwrap()
.file_type()
.is_symlink(),
"path dependencies should be live-linked on Unix"
);
#[cfg(windows)]
let materialized_is_link = fs::symlink_metadata(&materialized)
.unwrap()
.file_type()
.is_symlink();
fs::write(
dependency_root.join("lib.harn"),
"pub fn version() -> string { return \"v2\" }\n",
)
.unwrap();
#[cfg(unix)]
{
let live_source = fs::read_to_string(materialized.join("lib.harn")).unwrap();
assert!(
live_source.contains("v2"),
"materialized path dependency should reflect sibling repo edits"
);
}
#[cfg(windows)]
{
let materialized_source =
fs::read_to_string(materialized.join("lib.harn")).unwrap();
if materialized_is_link {
assert!(
materialized_source.contains("v2"),
"Windows path dependency symlink should reflect sibling repo edits"
);
} else {
assert!(
materialized_source.contains("v1"),
"Windows path dependency copy fallback should keep the copied contents"
);
}
}
remove_package("openapi");
assert!(!materialized.exists());
assert!(dependency_root.join("lib.harn").exists());
});
}
#[test]
fn frozen_install_errors_when_lockfile_is_missing() {
let (_repo_tmp, repo, _branch) = create_git_package_repo();
let project_tmp = tempfile::tempdir().unwrap();
let root = project_tmp.path();
let cache_dir = root.join(".cache");
fs::create_dir_all(root.join(".git")).unwrap();
let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
fs::write(
root.join(MANIFEST),
format!(
r#"
[package]
name = "workspace"
version = "0.1.0"
[dependencies]
acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
"#
),
)
.unwrap();
with_test_env(root, &cache_dir, || {
let error = install_packages_impl(true, None, false).unwrap_err();
assert!(error.to_string().contains(LOCK_FILE));
});
}
#[test]
fn offline_locked_install_materializes_from_cache_without_source_repo() {
let (_repo_tmp, repo, _branch) = create_git_package_repo();
let project_tmp = tempfile::tempdir().unwrap();
let root = project_tmp.path();
let cache_dir = root.join(".cache");
fs::create_dir_all(root.join(".git")).unwrap();
let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
fs::write(
root.join(MANIFEST),
format!(
r#"
[package]
name = "workspace"
version = "0.1.0"
[dependencies]
acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
"#
),
)
.unwrap();
with_test_env(root, &cache_dir, || {
let installed = install_packages_impl(false, None, false).unwrap();
assert_eq!(installed, 1);
fs::remove_dir_all(root.join(PKG_DIR)).unwrap();
fs::remove_dir_all(&repo).unwrap();
let installed = install_packages_impl(true, None, true).unwrap();
assert_eq!(installed, 1);
assert!(root
.join(PKG_DIR)
.join("acme-lib")
.join("lib.harn")
.is_file());
});
}
#[test]
fn offline_locked_install_fails_when_cache_is_missing() {
let (_repo_tmp, repo, _branch) = create_git_package_repo();
let project_tmp = tempfile::tempdir().unwrap();
let root = project_tmp.path();
let cache_dir = root.join(".cache");
fs::create_dir_all(root.join(".git")).unwrap();
let git = normalize_git_url(repo.to_string_lossy().as_ref()).unwrap();
fs::write(
root.join(MANIFEST),
format!(
r#"
[package]
name = "workspace"
version = "0.1.0"
[dependencies]
acme-lib = {{ git = "{git}", rev = "v1.0.0" }}
"#
),
)
.unwrap();
with_test_env(root, &cache_dir, || {
install_packages_impl(false, None, false).unwrap();
fs::remove_dir_all(cache_dir.join("git")).unwrap();
let error = install_packages_impl(true, None, true).unwrap_err();
assert!(error.to_string().contains("offline mode"));
});
}
#[test]
fn add_github_shorthand_requires_version_or_ref() {
let error = normalize_add_request(
"github.com/burin-labs/harn-openapi",
None,
None,
None,
None,
None,
None,
None,
)
.unwrap_err();
assert!(error.to_string().contains("must specify `rev` or `branch`"));
}
#[test]
fn add_github_shorthand_with_ref_writes_git_dependency() {
let (alias, dependency) = normalize_add_request(
"github.com/burin-labs/harn-openapi@v1.2.3",
None,
None,
None,
None,
None,
None,
None,
)
.unwrap();
assert_eq!(alias, "harn-openapi");
assert_eq!(
render_dependency_line(&alias, &dependency).unwrap(),
"harn-openapi = { git = \"https://github.com/burin-labs/harn-openapi\", rev = \"v1.2.3\" }"
);
}
#[test]
fn install_resolves_transitive_git_dependencies_from_clean_cache() {
let (_sdk_tmp, sdk_repo, _branch) = create_git_package_repo_with(
"notion-sdk-harn",
"",
"pub fn sdk_value() -> string { return \"sdk\" }\n",
);
let sdk_git = normalize_git_url(sdk_repo.to_string_lossy().as_ref()).unwrap();
let connector_tail = format!(
r#"
[dependencies]
notion-sdk-harn = {{ git = "{sdk_git}", rev = "v1.0.0" }}
"#
);
let (_connector_tmp, connector_repo, _branch) = create_git_package_repo_with(
"notion-connector-harn",
&connector_tail,
r#"
import "notion-sdk-harn"
pub fn connector_value() -> string {
return "connector"
}
"#,
);
let project_tmp = tempfile::tempdir().unwrap();
let root = project_tmp.path();
let cache_dir = root.join(".cache");
fs::create_dir_all(root.join(".git")).unwrap();
let connector_git = normalize_git_url(connector_repo.to_string_lossy().as_ref()).unwrap();
fs::write(
root.join(MANIFEST),
format!(
r#"
[package]
name = "workspace"
version = "0.1.0"
[dependencies]
notion-connector-harn = {{ git = "{connector_git}", rev = "v1.0.0" }}
"#
),
)
.unwrap();
with_test_env(root, &cache_dir, || {
let installed = install_packages_impl(false, None, false).unwrap();
assert_eq!(installed, 2);
let lock = LockFile::load(&root.join(LOCK_FILE)).unwrap().unwrap();
assert!(lock.find("notion-connector-harn").is_some());
assert!(lock.find("notion-sdk-harn").is_some());
assert!(root
.join(PKG_DIR)
.join("notion-connector-harn")
.join("lib.harn")
.is_file());
assert!(root
.join(PKG_DIR)
.join("notion-sdk-harn")
.join("lib.harn")
.is_file());
let mut vm = test_vm();
let exports = futures::executor::block_on(
vm.load_module_exports(
&root
.join(PKG_DIR)
.join("notion-connector-harn")
.join("lib.harn"),
),
)
.expect("transitive import should load from the workspace package root");
assert!(exports.contains_key("connector_value"));
});
}
#[test]
fn git_packages_reject_transitive_path_dependencies() {
let connector_tail = r#"
[dependencies]
local-helper = { path = "../helper" }
"#;
let (_connector_tmp, connector_repo, _branch) = create_git_package_repo_with(
"notion-connector-harn",
connector_tail,
"pub fn connector_value() -> string { return \"connector\" }\n",
);
let project_tmp = tempfile::tempdir().unwrap();
let root = project_tmp.path();
let cache_dir = root.join(".cache");
fs::create_dir_all(root.join(".git")).unwrap();
let connector_git = normalize_git_url(connector_repo.to_string_lossy().as_ref()).unwrap();
fs::write(
root.join(MANIFEST),
format!(
r#"
[package]
name = "workspace"
version = "0.1.0"
[dependencies]
notion-connector-harn = {{ git = "{connector_git}", rev = "v1.0.0" }}
"#
),
)
.unwrap();
with_test_env(root, &cache_dir, || {
let error = install_packages_impl(false, None, false).unwrap_err();
assert!(error
.to_string()
.contains("path dependencies are not supported inside git-installed packages"));
});
}
#[test]
fn package_alias_validation_rejects_path_traversal_names() {
for alias in [
"../evil",
"nested/evil",
"nested\\evil",
".",
"..",
"bad alias",
] {
assert!(
validate_package_alias(alias).is_err(),
"{alias:?} should be rejected"
);
}
validate_package_alias("acme-lib_1.2").expect("ordinary alias should be accepted");
}
#[test]
fn add_package_rejects_aliases_that_escape_packages_dir() {
let error = normalize_add_request(
"ignored",
Some("../evil"),
None,
None,
None,
None,
Some("./dep"),
None,
)
.unwrap_err();
assert!(error.to_string().contains("invalid dependency alias"));
}
#[test]
fn rendered_dependency_values_are_toml_escaped() {
let path = "dep\" \nmalicious = true";
let line = render_dependency_line(
"safe",
&Dependency::Table(DepTable {
git: None,
tag: None,
rev: None,
branch: None,
path: Some(path.to_string()),
package: None,
}),
)
.expect("dependency line");
let parsed: Manifest = toml::from_str(&format!("[dependencies]\n{line}\n")).unwrap();
assert_eq!(parsed.dependencies.len(), 1);
assert_eq!(
parsed
.dependencies
.get("safe")
.and_then(Dependency::local_path),
Some(path)
);
}
#[test]
fn materialization_rejects_lock_alias_path_traversal_before_removing_paths() {
let tmp = tempfile::tempdir().unwrap();
let dep = tmp.path().join("dep");
fs::create_dir_all(&dep).unwrap();
fs::write(dep.join("lib.harn"), "pub fn dep() { 1 }\n").unwrap();
let victim = tmp.path().join("victim");
fs::create_dir_all(&victim).unwrap();
fs::write(victim.join("keep.txt"), "keep").unwrap();
let manifest: Manifest = toml::from_str("[package]\nname = \"root\"\n").unwrap();
let ctx = ManifestContext {
manifest,
dir: tmp.path().to_path_buf(),
};
let lock = LockFile {
version: LOCK_FILE_VERSION,
packages: vec![LockEntry {
name: "../victim".to_string(),
source: path_source_uri(&dep).unwrap(),
rev_request: None,
commit: None,
content_hash: None,
}],
};
let error = materialize_dependencies_from_lock(&ctx, &lock, None, false).unwrap_err();
assert!(error.to_string().contains("invalid dependency alias"));
assert!(
victim.join("keep.txt").exists(),
"malicious alias should not remove paths outside .harn/packages"
);
}
}