1use std::path::{Component, Path, PathBuf};
5
6use sha2::{Digest, Sha256};
7
8use crate::client::{Manifest, RegistryClient};
9use crate::lockfile::{LockEntry, Lockfile};
10use crate::{CliError, Result, parse_handle};
11
12#[derive(Debug)]
14pub struct InstallSummary {
15 pub handle: String,
16 pub target: String,
17 pub version: String,
18 pub written: Vec<String>,
19 pub skipped: bool,
21 pub unpinned: bool,
23}
24
25pub async fn install(
29 client: &RegistryClient,
30 handle: &str,
31 target: &str,
32 dir: &Path,
33 force: bool,
34 lock_path: &Path,
35) -> Result<InstallSummary> {
36 let (ns, slug) = parse_handle(handle)?;
37 let manifest = client.manifest(&ns, &slug, target).await?;
38 let mut lock = Lockfile::load(lock_path)?;
39
40 let dests: Vec<PathBuf> = manifest
41 .files
42 .iter()
43 .map(|f| safe_dest(dir, &f.dest_path))
44 .collect::<Result<_>>()?;
45 let all_exist = !dests.is_empty() && dests.iter().all(|p| p.exists());
46 let up_to_date = lock
47 .get(&manifest.handle, target)
48 .is_some_and(|e| e.version == manifest.version)
49 && all_exist;
50
51 if up_to_date && !force {
52 return Ok(InstallSummary {
53 handle: manifest.handle,
54 target: target.to_string(),
55 version: manifest.version,
56 written: Vec::new(),
57 skipped: true,
58 unpinned: !manifest.pinned,
59 });
60 }
61
62 if !force {
63 let conflicts: Vec<String> = dests
64 .iter()
65 .filter(|p| p.exists())
66 .map(|p| p.display().to_string())
67 .collect();
68 if !conflicts.is_empty() {
69 return Err(CliError::WouldOverwrite(conflicts));
70 }
71 }
72
73 let (written, content_hash) = apply_manifest(client, &manifest, dir).await?;
74 lock.upsert(LockEntry {
75 handle: manifest.handle.clone(),
76 version: manifest.version.clone(),
77 target: target.to_string(),
78 content_hash,
79 files: written.clone(),
80 });
81 lock.save(lock_path)?;
82
83 Ok(InstallSummary {
84 handle: manifest.handle,
85 target: target.to_string(),
86 version: manifest.version,
87 written,
88 skipped: false,
89 unpinned: !manifest.pinned,
90 })
91}
92
93#[derive(Debug)]
95pub struct UpdateChange {
96 pub handle: String,
97 pub target: String,
98 pub from: String,
99 pub to: String,
100}
101
102#[derive(Debug)]
104pub struct UpdateSkip {
105 pub handle: String,
106 pub target: String,
107 pub reason: String,
108}
109
110#[derive(Debug)]
112pub struct UpdateReport {
113 pub changes: Vec<UpdateChange>,
114 pub skipped: Vec<UpdateSkip>,
117 pub applied: bool,
118}
119
120pub async fn update(
129 client: &RegistryClient,
130 dir: &Path,
131 lock_path: &Path,
132 apply: bool,
133 force: bool,
134) -> Result<UpdateReport> {
135 let mut lock = Lockfile::load(lock_path)?;
136 let entries = lock.artifacts.clone();
137 let mut changes = Vec::new();
138 let mut skipped = Vec::new();
139 let mut wrote_any = false;
140
141 for entry in &entries {
142 let (ns, slug) = parse_handle(&entry.handle)?;
143 let manifest = client.manifest(&ns, &slug, &entry.target).await?;
144 if manifest.version == entry.version {
145 continue;
146 }
147 changes.push(UpdateChange {
148 handle: entry.handle.clone(),
149 target: entry.target.clone(),
150 from: entry.version.clone(),
151 to: manifest.version.clone(),
152 });
153 if !apply {
154 continue;
155 }
156 if !force {
157 let paths: Vec<(String, PathBuf)> = entry
158 .files
159 .iter()
160 .map(|f| safe_dest(dir, f).map(|p| (f.clone(), p)))
161 .collect::<Result<_>>()?;
162 match verify_unmodified(entry, &paths) {
163 Ok(()) => {}
164 Err(CliError::LocallyModified { details, .. }) => {
165 skipped.push(UpdateSkip {
166 handle: entry.handle.clone(),
167 target: entry.target.clone(),
168 reason: details.join("; ").trim().to_string(),
169 });
170 continue;
171 }
172 Err(e) => return Err(e),
173 }
174 }
175 let (written, content_hash) = apply_manifest(client, &manifest, dir).await?;
176 lock.upsert(LockEntry {
177 handle: manifest.handle.clone(),
178 version: manifest.version.clone(),
179 target: entry.target.clone(),
180 content_hash,
181 files: written,
182 });
183 wrote_any = true;
184 }
185
186 if wrote_any {
187 lock.save(lock_path)?;
188 }
189
190 Ok(UpdateReport {
191 changes,
192 skipped,
193 applied: apply,
194 })
195}
196
197#[derive(Debug)]
199pub struct UninstallSummary {
200 pub handle: String,
201 pub target: String,
202 pub version: String,
203 pub removed: Vec<String>,
205 pub missing: Vec<String>,
207}
208
209pub fn uninstall(
218 dir: &Path,
219 lock_path: &Path,
220 handle: &str,
221 target: &str,
222 force: bool,
223) -> Result<UninstallSummary> {
224 let (ns, slug) = parse_handle(handle)?;
225 let handle = format!("@{ns}/{slug}");
226 let mut lock = Lockfile::load(lock_path)?;
227 let entry = lock
228 .get(&handle, target)
229 .ok_or_else(|| CliError::NotInstalled {
230 handle: handle.clone(),
231 target: target.to_string(),
232 })?
233 .clone();
234
235 let paths: Vec<(String, PathBuf)> = entry
236 .files
237 .iter()
238 .map(|f| safe_dest(dir, f).map(|p| (f.clone(), p)))
239 .collect::<Result<_>>()?;
240
241 if !force {
242 verify_unmodified(&entry, &paths)?;
243 }
244
245 let mut removed = Vec::new();
246 let mut missing = Vec::new();
247 for (rel, path) in &paths {
248 match std::fs::remove_file(path) {
249 Ok(()) => removed.push(rel.clone()),
250 Err(e) if e.kind() == std::io::ErrorKind::NotFound => missing.push(rel.clone()),
251 Err(e) => return Err(e.into()),
252 }
253 prune_empty_dirs(dir, path);
254 }
255
256 lock.remove(&handle, target);
257 lock.save(lock_path)?;
258
259 Ok(UninstallSummary {
260 handle,
261 target: target.to_string(),
262 version: entry.version,
263 removed,
264 missing,
265 })
266}
267
268fn verify_unmodified(entry: &LockEntry, paths: &[(String, PathBuf)]) -> Result<()> {
272 let mut hasher = Sha256::new();
273 let mut details = Vec::new();
274 for (rel, path) in paths {
275 match std::fs::read(path) {
276 Ok(bytes) => hasher.update(&bytes),
277 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
278 details.push(format!(" {rel} (already deleted)"));
279 }
280 Err(e) => return Err(e.into()),
281 }
282 }
283 if details.is_empty() {
284 if hex::encode(hasher.finalize()) == entry.content_hash {
285 return Ok(());
286 }
287 details.push(" contents differ from the installed version".to_string());
288 }
289 Err(CliError::LocallyModified {
290 handle: entry.handle.clone(),
291 details,
292 })
293}
294
295fn prune_empty_dirs(root: &Path, file: &Path) {
300 let mut current = file.parent();
301 while let Some(dir) = current {
302 if dir == root || !dir.starts_with(root) || std::fs::remove_dir(dir).is_err() {
303 return;
304 }
305 current = dir.parent();
306 }
307}
308
309fn safe_dest(dir: &Path, rel: &str) -> Result<PathBuf> {
314 let path = Path::new(rel);
315 let escapes = path.components().any(|c| {
316 matches!(
317 c,
318 Component::ParentDir | Component::RootDir | Component::Prefix(_)
319 )
320 });
321 if rel.is_empty() || escapes {
322 return Err(CliError::UnsafePath(rel.to_string()));
323 }
324 Ok(dir.join(path))
325}
326
327async fn apply_manifest(
334 client: &RegistryClient,
335 manifest: &Manifest,
336 dir: &Path,
337) -> Result<(Vec<String>, String)> {
338 let mut files: Vec<&crate::client::ManifestFile> = manifest.files.iter().collect();
339 files.sort_by(|a, b| a.dest_path.cmp(&b.dest_path));
340
341 let mut hasher = Sha256::new();
342 let mut written = Vec::new();
343 for file in files {
344 let path = safe_dest(dir, &file.dest_path)?;
345 let bytes = client.fetch_file(&file.source_url).await?;
346 hasher.update(&bytes);
347 if let Some(parent) = path.parent() {
348 std::fs::create_dir_all(parent)?;
349 }
350 std::fs::write(&path, &bytes)?;
351 written.push(file.dest_path.clone());
352 }
353 Ok((written, hex::encode(hasher.finalize())))
354}