use std::path::{Component, Path, PathBuf};
use sha2::{Digest, Sha256};
use crate::client::{Manifest, RegistryClient};
use crate::lockfile::{LockEntry, Lockfile};
use crate::{CliError, Result, parse_handle};
#[derive(Debug)]
pub struct InstallSummary {
pub handle: String,
pub target: String,
pub version: String,
pub written: Vec<String>,
pub skipped: bool,
pub unpinned: bool,
}
pub async fn install(
client: &RegistryClient,
handle: &str,
target: &str,
dir: &Path,
force: bool,
lock_path: &Path,
) -> Result<InstallSummary> {
let (ns, slug) = parse_handle(handle)?;
let manifest = client.manifest(&ns, &slug, target).await?;
let mut lock = Lockfile::load(lock_path)?;
let dests: Vec<PathBuf> = manifest
.files
.iter()
.map(|f| safe_dest(dir, &f.dest_path))
.collect::<Result<_>>()?;
let all_exist = !dests.is_empty() && dests.iter().all(|p| p.exists());
let up_to_date = lock
.get(&manifest.handle, target)
.is_some_and(|e| e.version == manifest.version)
&& all_exist;
if up_to_date && !force {
return Ok(InstallSummary {
handle: manifest.handle,
target: target.to_string(),
version: manifest.version,
written: Vec::new(),
skipped: true,
unpinned: !manifest.pinned,
});
}
if !force {
let conflicts: Vec<String> = dests
.iter()
.filter(|p| p.exists())
.map(|p| p.display().to_string())
.collect();
if !conflicts.is_empty() {
return Err(CliError::WouldOverwrite(conflicts));
}
}
let (written, content_hash) = apply_manifest(client, &manifest, dir).await?;
lock.upsert(LockEntry {
handle: manifest.handle.clone(),
version: manifest.version.clone(),
target: target.to_string(),
content_hash,
files: written.clone(),
});
lock.save(lock_path)?;
Ok(InstallSummary {
handle: manifest.handle,
target: target.to_string(),
version: manifest.version,
written,
skipped: false,
unpinned: !manifest.pinned,
})
}
#[derive(Debug)]
pub struct UpdateChange {
pub handle: String,
pub target: String,
pub from: String,
pub to: String,
}
#[derive(Debug)]
pub struct UpdateSkip {
pub handle: String,
pub target: String,
pub reason: String,
}
#[derive(Debug)]
pub struct UpdateReport {
pub changes: Vec<UpdateChange>,
pub skipped: Vec<UpdateSkip>,
pub applied: bool,
}
pub async fn update(
client: &RegistryClient,
dir: &Path,
lock_path: &Path,
apply: bool,
force: bool,
) -> Result<UpdateReport> {
let mut lock = Lockfile::load(lock_path)?;
let entries = lock.artifacts.clone();
let mut changes = Vec::new();
let mut skipped = Vec::new();
let mut wrote_any = false;
for entry in &entries {
let (ns, slug) = parse_handle(&entry.handle)?;
let manifest = client.manifest(&ns, &slug, &entry.target).await?;
if manifest.version == entry.version {
continue;
}
changes.push(UpdateChange {
handle: entry.handle.clone(),
target: entry.target.clone(),
from: entry.version.clone(),
to: manifest.version.clone(),
});
if !apply {
continue;
}
if !force {
let paths: Vec<(String, PathBuf)> = entry
.files
.iter()
.map(|f| safe_dest(dir, f).map(|p| (f.clone(), p)))
.collect::<Result<_>>()?;
match verify_unmodified(entry, &paths) {
Ok(()) => {}
Err(CliError::LocallyModified { details, .. }) => {
skipped.push(UpdateSkip {
handle: entry.handle.clone(),
target: entry.target.clone(),
reason: details.join("; ").trim().to_string(),
});
continue;
}
Err(e) => return Err(e),
}
}
let (written, content_hash) = apply_manifest(client, &manifest, dir).await?;
lock.upsert(LockEntry {
handle: manifest.handle.clone(),
version: manifest.version.clone(),
target: entry.target.clone(),
content_hash,
files: written,
});
wrote_any = true;
}
if wrote_any {
lock.save(lock_path)?;
}
Ok(UpdateReport {
changes,
skipped,
applied: apply,
})
}
#[derive(Debug)]
pub struct UninstallSummary {
pub handle: String,
pub target: String,
pub version: String,
pub removed: Vec<String>,
pub missing: Vec<String>,
}
pub fn uninstall(
dir: &Path,
lock_path: &Path,
handle: &str,
target: &str,
force: bool,
) -> Result<UninstallSummary> {
let (ns, slug) = parse_handle(handle)?;
let handle = format!("@{ns}/{slug}");
let mut lock = Lockfile::load(lock_path)?;
let entry = lock
.get(&handle, target)
.ok_or_else(|| CliError::NotInstalled {
handle: handle.clone(),
target: target.to_string(),
})?
.clone();
let paths: Vec<(String, PathBuf)> = entry
.files
.iter()
.map(|f| safe_dest(dir, f).map(|p| (f.clone(), p)))
.collect::<Result<_>>()?;
if !force {
verify_unmodified(&entry, &paths)?;
}
let mut removed = Vec::new();
let mut missing = Vec::new();
for (rel, path) in &paths {
match std::fs::remove_file(path) {
Ok(()) => removed.push(rel.clone()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => missing.push(rel.clone()),
Err(e) => return Err(e.into()),
}
prune_empty_dirs(dir, path);
}
lock.remove(&handle, target);
lock.save(lock_path)?;
Ok(UninstallSummary {
handle,
target: target.to_string(),
version: entry.version,
removed,
missing,
})
}
fn verify_unmodified(entry: &LockEntry, paths: &[(String, PathBuf)]) -> Result<()> {
let mut hasher = Sha256::new();
let mut details = Vec::new();
for (rel, path) in paths {
match std::fs::read(path) {
Ok(bytes) => hasher.update(&bytes),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
details.push(format!(" {rel} (already deleted)"));
}
Err(e) => return Err(e.into()),
}
}
if details.is_empty() {
if hex::encode(hasher.finalize()) == entry.content_hash {
return Ok(());
}
details.push(" contents differ from the installed version".to_string());
}
Err(CliError::LocallyModified {
handle: entry.handle.clone(),
details,
})
}
fn prune_empty_dirs(root: &Path, file: &Path) {
let mut current = file.parent();
while let Some(dir) = current {
if dir == root || !dir.starts_with(root) || std::fs::remove_dir(dir).is_err() {
return;
}
current = dir.parent();
}
}
fn safe_dest(dir: &Path, rel: &str) -> Result<PathBuf> {
let path = Path::new(rel);
let escapes = path.components().any(|c| {
matches!(
c,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
});
if rel.is_empty() || escapes {
return Err(CliError::UnsafePath(rel.to_string()));
}
Ok(dir.join(path))
}
async fn apply_manifest(
client: &RegistryClient,
manifest: &Manifest,
dir: &Path,
) -> Result<(Vec<String>, String)> {
let mut files: Vec<&crate::client::ManifestFile> = manifest.files.iter().collect();
files.sort_by(|a, b| a.dest_path.cmp(&b.dest_path));
let mut hasher = Sha256::new();
let mut written = Vec::new();
for file in files {
let path = safe_dest(dir, &file.dest_path)?;
let bytes = client.fetch_file(&file.source_url).await?;
hasher.update(&bytes);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, &bytes)?;
written.push(file.dest_path.clone());
}
Ok((written, hex::encode(hasher.finalize())))
}