1use std::path::{Path, PathBuf};
20
21use flate2::read::GzDecoder;
22use semver::{Version, VersionReq};
23use tar::Archive;
24
25use crate::commands;
26use crate::error::{PkgError, PkgResult};
27use crate::lockfile::{LockedPackage, Lockfile};
28use crate::manifest::{DependencySpec, Manifest};
29use crate::network::{normalize_checksum, FetchedPackage, NetworkRegistry};
30use crate::version::parse_version_req;
31
32pub const PACKAGES_SUBDIR: &str = ".bock/packages";
34
35pub const CACHE_SUBDIR: &str = ".bock/cache";
37
38#[derive(Debug, Clone, Default)]
40pub struct InstallOptions {
41 pub offline: bool,
44 pub version_req: Option<String>,
47}
48
49#[derive(Debug, Clone)]
51pub struct InstalledPackage {
52 pub name: String,
54 pub version: Version,
56 pub install_dir: PathBuf,
58 pub checksum: String,
60 pub source: String,
63}
64
65pub fn install_package(
71 project_dir: &Path,
72 registry: &NetworkRegistry,
73 name: &str,
74 options: &InstallOptions,
75) -> PkgResult<InstalledPackage> {
76 let manifest_path = project_dir.join(commands::MANIFEST_FILE);
77 if !manifest_path.exists() {
78 return Err(PkgError::Io(format!(
79 "no {} found in {}",
80 commands::MANIFEST_FILE,
81 project_dir.display()
82 )));
83 }
84
85 let resolved = resolve_and_fetch(registry, name, options)?;
86
87 let install_dir = project_dir
88 .join(PACKAGES_SUBDIR)
89 .join(name)
90 .join(resolved.version.to_string());
91 extract_tarball(&resolved.tarball_path, &install_dir)?;
92
93 let version_spec = options
94 .version_req
95 .clone()
96 .unwrap_or_else(|| format!("^{}", resolved.version));
97 commands::add(&manifest_path, name, Some(&version_spec))?;
98
99 let lock_path = project_dir.join(commands::LOCKFILE);
100 let lockfile = update_lockfile(
101 &lock_path,
102 &manifest_path,
103 name,
104 &resolved.version,
105 &resolved.checksum,
106 &resolved.source,
107 )?;
108 lockfile.write_to_file(&lock_path)?;
109
110 Ok(InstalledPackage {
111 name: name.to_string(),
112 version: resolved.version,
113 install_dir,
114 checksum: resolved.checksum,
115 source: resolved.source,
116 })
117}
118
119pub fn clear_cache(cache_dir: &Path) -> PkgResult<usize> {
124 if !cache_dir.exists() {
125 return Ok(0);
126 }
127 let mut removed = 0;
128 for entry in std::fs::read_dir(cache_dir).map_err(|e| PkgError::Io(e.to_string()))? {
129 let entry = entry.map_err(|e| PkgError::Io(e.to_string()))?;
130 let path = entry.path();
131 if path.is_file() {
132 std::fs::remove_file(&path).map_err(|e| PkgError::Io(e.to_string()))?;
133 removed += 1;
134 }
135 }
136 Ok(removed)
137}
138
139pub fn extract_tarball(tarball_path: &Path, target_dir: &Path) -> PkgResult<()> {
144 if target_dir.exists() {
145 std::fs::remove_dir_all(target_dir).map_err(|e| PkgError::Io(e.to_string()))?;
146 }
147 std::fs::create_dir_all(target_dir).map_err(|e| PkgError::Io(e.to_string()))?;
148
149 let file = std::fs::File::open(tarball_path).map_err(|e| PkgError::Io(e.to_string()))?;
150 let decoder = GzDecoder::new(file);
151 let mut archive = Archive::new(decoder);
152 archive
153 .unpack(target_dir)
154 .map_err(|e| PkgError::Io(format!("extracting {}: {}", tarball_path.display(), e)))?;
155 Ok(())
156}
157
158struct Resolved {
160 version: Version,
161 tarball_path: PathBuf,
162 checksum: String,
163 source: String,
164}
165
166fn resolve_and_fetch(
167 registry: &NetworkRegistry,
168 name: &str,
169 options: &InstallOptions,
170) -> PkgResult<Resolved> {
171 let req = match &options.version_req {
172 Some(s) => Some(parse_version_req(s)?),
173 None => None,
174 };
175
176 if options.offline {
177 let (version, tarball_path, checksum) = resolve_from_cache(
178 registry.cache_dir(),
179 name,
180 req.as_ref(),
181 )?;
182 return Ok(Resolved {
183 version,
184 tarball_path,
185 checksum,
186 source: "cache".to_string(),
187 });
188 }
189
190 match fetch_from_network(registry, name, req.as_ref()) {
192 Ok((version, fetched)) => Ok(Resolved {
193 version,
194 tarball_path: fetched.tarball_path,
195 checksum: fetched.checksum,
196 source: registry.base_url().to_string(),
197 }),
198 Err(PkgError::Network(msg)) => {
199 if let Ok((version, tarball_path, checksum)) =
201 resolve_from_cache(registry.cache_dir(), name, req.as_ref())
202 {
203 return Ok(Resolved {
204 version,
205 tarball_path,
206 checksum,
207 source: "cache".to_string(),
208 });
209 }
210 Err(PkgError::Network(format!(
211 "{msg}\n\nhint: pass --offline to use a cached tarball, or check your network connection"
212 )))
213 }
214 Err(e) => Err(e),
215 }
216}
217
218fn fetch_from_network(
219 registry: &NetworkRegistry,
220 name: &str,
221 req: Option<&VersionReq>,
222) -> PkgResult<(Version, FetchedPackage)> {
223 let versions = registry.fetch_versions(name)?;
224 let version = pick_version(&versions.versions, req)?.unwrap_or_else(|| versions.latest.clone());
225 let fetched = registry.fetch_package(name, &version)?;
226 let parsed = crate::version::parse_version(&version)?;
227 Ok((parsed, fetched))
228}
229
230fn resolve_from_cache(
231 cache_dir: &Path,
232 name: &str,
233 req: Option<&VersionReq>,
234) -> PkgResult<(Version, PathBuf, String)> {
235 let prefix = format!("{name}-");
236 let suffix = ".tar.gz";
237 let mut candidates: Vec<(Version, PathBuf)> = Vec::new();
238
239 if cache_dir.exists() {
240 for entry in std::fs::read_dir(cache_dir).map_err(|e| PkgError::Io(e.to_string()))? {
241 let entry = entry.map_err(|e| PkgError::Io(e.to_string()))?;
242 let Some(fname) = entry.file_name().to_str().map(str::to_string) else {
243 continue;
244 };
245 if !fname.starts_with(&prefix) || !fname.ends_with(suffix) {
246 continue;
247 }
248 let ver_str = &fname[prefix.len()..fname.len() - suffix.len()];
249 if let Ok(ver) = crate::version::parse_version(ver_str) {
250 if req.is_none_or(|r| r.matches(&ver)) {
251 candidates.push((ver, entry.path()));
252 }
253 }
254 }
255 }
256
257 candidates.sort_by(|a, b| b.0.cmp(&a.0));
258 let Some((version, path)) = candidates.into_iter().next() else {
259 return Err(PkgError::PackageNotFound(format!(
260 "no cached tarball for '{name}' matches the requested version (cache: {})",
261 cache_dir.display()
262 )));
263 };
264
265 let bytes = std::fs::read(&path).map_err(|e| PkgError::Io(e.to_string()))?;
266 let checksum = crate::network::sha256_hex(&bytes);
267 Ok((version, path, checksum))
268}
269
270fn pick_version(versions: &[String], req: Option<&VersionReq>) -> PkgResult<Option<String>> {
273 let Some(req) = req else {
274 return Ok(None);
275 };
276 let mut best: Option<Version> = None;
277 for v in versions {
278 let parsed = match crate::version::parse_version(v) {
279 Ok(v) => v,
280 Err(_) => continue,
281 };
282 if req.matches(&parsed) && best.as_ref().is_none_or(|b| parsed > *b) {
283 best = Some(parsed);
284 }
285 }
286 match best {
287 Some(v) => Ok(Some(v.to_string())),
288 None => Err(PkgError::ResolutionFailed(format!(
289 "no version of this package matches `{req}`"
290 ))),
291 }
292}
293
294fn update_lockfile(
295 lock_path: &Path,
296 manifest_path: &Path,
297 new_name: &str,
298 new_version: &Version,
299 new_checksum: &str,
300 new_source: &str,
301) -> PkgResult<Lockfile> {
302 let manifest = Manifest::from_file(manifest_path)?;
303 let mut lockfile = if lock_path.exists() {
304 Lockfile::from_file(lock_path)?
305 } else {
306 Lockfile {
307 version: 1,
308 packages: Vec::new(),
309 }
310 };
311
312 lockfile.packages.retain(|p| p.name != new_name);
314 lockfile.packages.push(LockedPackage {
315 name: new_name.to_string(),
316 version: new_version.to_string(),
317 source: Some(new_source.to_string()),
318 checksum: Some(format!("sha256:{}", normalize_checksum(new_checksum))),
319 dependencies: manifest
320 .dependencies
321 .common
322 .iter()
323 .filter_map(|(dep_name, spec)| {
324 if dep_name == new_name {
325 return None;
326 }
327 match spec {
328 DependencySpec::Simple(v) => Some((dep_name.clone(), v.clone())),
329 DependencySpec::Detailed(d) => d
330 .version
331 .as_ref()
332 .map(|v| (dep_name.clone(), v.clone())),
333 }
334 })
335 .collect(),
336 });
337 lockfile
338 .packages
339 .sort_by(|a, b| a.name.cmp(&b.name));
340 Ok(lockfile)
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346 use flate2::write::GzEncoder;
347 use flate2::Compression;
348 use mockito::Server;
349
350 fn make_tarball(files: &[(&str, &[u8])]) -> Vec<u8> {
351 let mut out = Vec::new();
352 {
353 let encoder = GzEncoder::new(&mut out, Compression::default());
354 let mut builder = tar::Builder::new(encoder);
355 for (path, contents) in files {
356 let mut header = tar::Header::new_gnu();
357 header.set_size(contents.len() as u64);
358 header.set_mode(0o644);
359 header.set_cksum();
360 builder.append_data(&mut header, path, *contents).unwrap();
361 }
362 let encoder = builder.into_inner().unwrap();
363 encoder.finish().unwrap();
364 }
365 out
366 }
367
368 fn write_manifest(dir: &Path, name: &str) {
369 std::fs::write(
370 dir.join(commands::MANIFEST_FILE),
371 format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\n"),
372 )
373 .unwrap();
374 }
375
376 fn ensure_empty_token_env() {
377 std::env::remove_var(crate::network::AUTH_TOKEN_ENV);
380 }
381
382 #[test]
383 fn extract_tarball_places_files() {
384 let tmp = tempfile::tempdir().unwrap();
385 let tar_path = tmp.path().join("pkg.tar.gz");
386 let bytes = make_tarball(&[("src/main.bock", b"module main"), ("README.md", b"hi")]);
387 std::fs::write(&tar_path, &bytes).unwrap();
388
389 let target = tmp.path().join("out");
390 extract_tarball(&tar_path, &target).unwrap();
391
392 assert_eq!(
393 std::fs::read_to_string(target.join("src/main.bock")).unwrap(),
394 "module main"
395 );
396 assert_eq!(std::fs::read_to_string(target.join("README.md")).unwrap(), "hi");
397 }
398
399 #[test]
400 fn install_package_writes_manifest_lockfile_and_unpacks() {
401 ensure_empty_token_env();
402 let project = tempfile::tempdir().unwrap();
403 write_manifest(project.path(), "my-app");
404
405 let tarball_bytes = make_tarball(&[("src/lib.bock", b"module foo\n")]);
406 let checksum = crate::network::sha256_hex(&tarball_bytes);
407
408 let mut server = Server::new();
409 let _versions = server
410 .mock("GET", "/packages/foo")
411 .with_status(200)
412 .with_body(r#"{"versions":["1.0.0","1.2.0"],"latest":"1.2.0"}"#)
413 .create();
414 let meta_body = format!(
415 r#"{{"manifest":{{"dependencies":{{}}}},"checksum":"sha256:{checksum}","download_url":""}}"#
416 );
417 let _meta = server
418 .mock("GET", "/packages/foo/1.2.0")
419 .with_status(200)
420 .with_body(meta_body)
421 .create();
422 let _download = server
423 .mock("GET", "/packages/foo/1.2.0/download")
424 .with_status(200)
425 .with_body(tarball_bytes.clone())
426 .create();
427
428 let cache_dir = project.path().join(CACHE_SUBDIR);
429 let registry = NetworkRegistry::new(server.url(), &cache_dir).unwrap();
430
431 let installed = install_package(
432 project.path(),
433 ®istry,
434 "foo",
435 &InstallOptions::default(),
436 )
437 .unwrap();
438
439 assert_eq!(installed.version.to_string(), "1.2.0");
440 assert!(installed.install_dir.ends_with(".bock/packages/foo/1.2.0"));
441 assert_eq!(
442 std::fs::read_to_string(installed.install_dir.join("src/lib.bock")).unwrap(),
443 "module foo\n"
444 );
445
446 let manifest =
448 Manifest::from_file(&project.path().join(commands::MANIFEST_FILE)).unwrap();
449 assert_eq!(manifest.dependencies["foo"].version_req(), Some("^1.2.0"));
450
451 let lockfile = Lockfile::from_file(&project.path().join(commands::LOCKFILE)).unwrap();
453 let entry = lockfile
454 .packages
455 .iter()
456 .find(|p| p.name == "foo")
457 .expect("lockfile entry missing");
458 assert_eq!(entry.version, "1.2.0");
459 assert_eq!(
460 entry.checksum.as_deref(),
461 Some(format!("sha256:{checksum}").as_str())
462 );
463 assert_eq!(entry.source.as_deref(), Some(server.url().as_str()));
464 }
465
466 #[test]
467 fn install_respects_version_requirement() {
468 ensure_empty_token_env();
469 let project = tempfile::tempdir().unwrap();
470 write_manifest(project.path(), "my-app");
471
472 let tarball_bytes = make_tarball(&[("src/lib.bock", b"")]);
473 let checksum = crate::network::sha256_hex(&tarball_bytes);
474
475 let mut server = Server::new();
476 let _versions = server
477 .mock("GET", "/packages/foo")
478 .with_status(200)
479 .with_body(r#"{"versions":["1.0.0","1.5.0","2.0.0"],"latest":"2.0.0"}"#)
480 .create();
481 let meta_body = format!(
482 r#"{{"manifest":{{"dependencies":{{}}}},"checksum":"sha256:{checksum}","download_url":""}}"#
483 );
484 let _meta = server
485 .mock("GET", "/packages/foo/1.5.0")
486 .with_status(200)
487 .with_body(meta_body)
488 .create();
489 let _download = server
490 .mock("GET", "/packages/foo/1.5.0/download")
491 .with_status(200)
492 .with_body(tarball_bytes)
493 .create();
494
495 let cache_dir = project.path().join(CACHE_SUBDIR);
496 let registry = NetworkRegistry::new(server.url(), &cache_dir).unwrap();
497
498 let options = InstallOptions {
499 version_req: Some("^1.0".to_string()),
500 offline: false,
501 };
502 let installed =
503 install_package(project.path(), ®istry, "foo", &options).unwrap();
504
505 assert_eq!(installed.version.to_string(), "1.5.0");
506 }
507
508 #[test]
509 fn install_uses_cache_in_offline_mode() {
510 ensure_empty_token_env();
511 let project = tempfile::tempdir().unwrap();
512 write_manifest(project.path(), "my-app");
513
514 let cache_dir = project.path().join(CACHE_SUBDIR);
515 std::fs::create_dir_all(&cache_dir).unwrap();
516
517 let tarball_bytes = make_tarball(&[("README", b"offline")]);
518 let cached = cache_dir.join("foo-1.4.0.tar.gz");
519 std::fs::write(&cached, &tarball_bytes).unwrap();
520 let checksum = crate::network::sha256_hex(&tarball_bytes);
521
522 let registry = NetworkRegistry::new("http://127.0.0.1:1/", &cache_dir).unwrap();
524 let options = InstallOptions {
525 offline: true,
526 version_req: None,
527 };
528 let installed =
529 install_package(project.path(), ®istry, "foo", &options).unwrap();
530
531 assert_eq!(installed.version.to_string(), "1.4.0");
532 assert_eq!(installed.checksum, checksum);
533 assert_eq!(installed.source, "cache");
534 }
535
536 #[test]
537 fn install_offline_errors_when_not_cached() {
538 ensure_empty_token_env();
539 let project = tempfile::tempdir().unwrap();
540 write_manifest(project.path(), "my-app");
541 let cache_dir = project.path().join(CACHE_SUBDIR);
542
543 let registry = NetworkRegistry::new("http://127.0.0.1:1/", &cache_dir).unwrap();
544 let err = install_package(
545 project.path(),
546 ®istry,
547 "foo",
548 &InstallOptions { offline: true, version_req: None },
549 )
550 .unwrap_err();
551 assert!(matches!(err, PkgError::PackageNotFound(_)));
552 }
553
554 #[test]
555 fn clear_cache_removes_tarballs() {
556 let tmp = tempfile::tempdir().unwrap();
557 let cache = tmp.path().join("cache");
558 std::fs::create_dir_all(&cache).unwrap();
559 std::fs::write(cache.join("a-1.0.0.tar.gz"), b"x").unwrap();
560 std::fs::write(cache.join("b-2.0.0.tar.gz"), b"y").unwrap();
561
562 let removed = clear_cache(&cache).unwrap();
563 assert_eq!(removed, 2);
564 assert!(cache.exists());
565 assert!(std::fs::read_dir(&cache).unwrap().next().is_none());
566 }
567
568 #[test]
569 fn pick_version_selects_highest_matching() {
570 let versions = vec!["0.9.0".into(), "1.0.0".into(), "1.2.3".into(), "2.0.0".into()];
571 let req = VersionReq::parse("^1.0").unwrap();
572 let picked = pick_version(&versions, Some(&req)).unwrap();
573 assert_eq!(picked.as_deref(), Some("1.2.3"));
574 }
575
576 #[test]
577 fn pick_version_errors_when_nothing_matches() {
578 let versions = vec!["0.1.0".into(), "0.2.0".into()];
579 let req = VersionReq::parse("^1.0").unwrap();
580 let err = pick_version(&versions, Some(&req)).unwrap_err();
581 assert!(matches!(err, PkgError::ResolutionFailed(_)));
582 }
583
584 #[test]
585 fn install_sends_bearer_auth_when_configured() {
586 ensure_empty_token_env();
587 let project = tempfile::tempdir().unwrap();
588 write_manifest(project.path(), "my-app");
589
590 let tarball_bytes = make_tarball(&[("README", b"tok")]);
591 let checksum = crate::network::sha256_hex(&tarball_bytes);
592
593 let mut server = Server::new();
594 let _v = server
595 .mock("GET", "/packages/foo")
596 .match_header("authorization", "Bearer secret-xyz")
597 .with_status(200)
598 .with_body(r#"{"versions":["1.0.0"],"latest":"1.0.0"}"#)
599 .create();
600 let meta_body = format!(
601 r#"{{"manifest":{{"dependencies":{{}}}},"checksum":"sha256:{checksum}","download_url":""}}"#
602 );
603 let _m = server
604 .mock("GET", "/packages/foo/1.0.0")
605 .match_header("authorization", "Bearer secret-xyz")
606 .with_status(200)
607 .with_body(meta_body)
608 .create();
609 let _d = server
610 .mock("GET", "/packages/foo/1.0.0/download")
611 .match_header("authorization", "Bearer secret-xyz")
612 .with_status(200)
613 .with_body(tarball_bytes)
614 .create();
615
616 let cache_dir = project.path().join(CACHE_SUBDIR);
617 let registry = NetworkRegistry::new(server.url(), &cache_dir)
618 .unwrap()
619 .with_auth_token(Some("secret-xyz".to_string()));
620 install_package(
621 project.path(),
622 ®istry,
623 "foo",
624 &InstallOptions::default(),
625 )
626 .unwrap();
627 }
628}