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) =
178 resolve_from_cache(registry.cache_dir(), name, req.as_ref())?;
179 return Ok(Resolved {
180 version,
181 tarball_path,
182 checksum,
183 source: "cache".to_string(),
184 });
185 }
186
187 match fetch_from_network(registry, name, req.as_ref()) {
189 Ok((version, fetched)) => Ok(Resolved {
190 version,
191 tarball_path: fetched.tarball_path,
192 checksum: fetched.checksum,
193 source: registry.base_url().to_string(),
194 }),
195 Err(PkgError::Network(msg)) => {
196 if let Ok((version, tarball_path, checksum)) =
198 resolve_from_cache(registry.cache_dir(), name, req.as_ref())
199 {
200 return Ok(Resolved {
201 version,
202 tarball_path,
203 checksum,
204 source: "cache".to_string(),
205 });
206 }
207 Err(PkgError::Network(format!(
208 "{msg}\n\nhint: pass --offline to use a cached tarball, or check your network connection"
209 )))
210 }
211 Err(e) => Err(e),
212 }
213}
214
215fn fetch_from_network(
216 registry: &NetworkRegistry,
217 name: &str,
218 req: Option<&VersionReq>,
219) -> PkgResult<(Version, FetchedPackage)> {
220 let versions = registry.fetch_versions(name)?;
221 let version = pick_version(&versions.versions, req)?.unwrap_or_else(|| versions.latest.clone());
222 let fetched = registry.fetch_package(name, &version)?;
223 let parsed = crate::version::parse_version(&version)?;
224 Ok((parsed, fetched))
225}
226
227fn resolve_from_cache(
228 cache_dir: &Path,
229 name: &str,
230 req: Option<&VersionReq>,
231) -> PkgResult<(Version, PathBuf, String)> {
232 let prefix = format!("{name}-");
233 let suffix = ".tar.gz";
234 let mut candidates: Vec<(Version, PathBuf)> = Vec::new();
235
236 if cache_dir.exists() {
237 for entry in std::fs::read_dir(cache_dir).map_err(|e| PkgError::Io(e.to_string()))? {
238 let entry = entry.map_err(|e| PkgError::Io(e.to_string()))?;
239 let Some(fname) = entry.file_name().to_str().map(str::to_string) else {
240 continue;
241 };
242 if !fname.starts_with(&prefix) || !fname.ends_with(suffix) {
243 continue;
244 }
245 let ver_str = &fname[prefix.len()..fname.len() - suffix.len()];
246 if let Ok(ver) = crate::version::parse_version(ver_str) {
247 if req.is_none_or(|r| r.matches(&ver)) {
248 candidates.push((ver, entry.path()));
249 }
250 }
251 }
252 }
253
254 candidates.sort_by(|a, b| b.0.cmp(&a.0));
255 let Some((version, path)) = candidates.into_iter().next() else {
256 return Err(PkgError::PackageNotFound(format!(
257 "no cached tarball for '{name}' matches the requested version (cache: {})",
258 cache_dir.display()
259 )));
260 };
261
262 let bytes = std::fs::read(&path).map_err(|e| PkgError::Io(e.to_string()))?;
263 let checksum = crate::network::sha256_hex(&bytes);
264 Ok((version, path, checksum))
265}
266
267fn pick_version(versions: &[String], req: Option<&VersionReq>) -> PkgResult<Option<String>> {
270 let Some(req) = req else {
271 return Ok(None);
272 };
273 let mut best: Option<Version> = None;
274 for v in versions {
275 let parsed = match crate::version::parse_version(v) {
276 Ok(v) => v,
277 Err(_) => continue,
278 };
279 if req.matches(&parsed) && best.as_ref().is_none_or(|b| parsed > *b) {
280 best = Some(parsed);
281 }
282 }
283 match best {
284 Some(v) => Ok(Some(v.to_string())),
285 None => Err(PkgError::ResolutionFailed(format!(
286 "no version of this package matches `{req}`"
287 ))),
288 }
289}
290
291fn update_lockfile(
292 lock_path: &Path,
293 manifest_path: &Path,
294 new_name: &str,
295 new_version: &Version,
296 new_checksum: &str,
297 new_source: &str,
298) -> PkgResult<Lockfile> {
299 let manifest = Manifest::from_file(manifest_path)?;
300 let mut lockfile = if lock_path.exists() {
301 Lockfile::from_file(lock_path)?
302 } else {
303 Lockfile {
304 version: 1,
305 packages: Vec::new(),
306 }
307 };
308
309 lockfile.packages.retain(|p| p.name != new_name);
311 lockfile.packages.push(LockedPackage {
312 name: new_name.to_string(),
313 version: new_version.to_string(),
314 source: Some(new_source.to_string()),
315 checksum: Some(format!("sha256:{}", normalize_checksum(new_checksum))),
316 dependencies: manifest
317 .dependencies
318 .common
319 .iter()
320 .filter_map(|(dep_name, spec)| {
321 if dep_name == new_name {
322 return None;
323 }
324 match spec {
325 DependencySpec::Simple(v) => Some((dep_name.clone(), v.clone())),
326 DependencySpec::Detailed(d) => {
327 d.version.as_ref().map(|v| (dep_name.clone(), v.clone()))
328 }
329 }
330 })
331 .collect(),
332 });
333 lockfile.packages.sort_by(|a, b| a.name.cmp(&b.name));
334 Ok(lockfile)
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340 use flate2::write::GzEncoder;
341 use flate2::Compression;
342 use mockito::Server;
343
344 fn make_tarball(files: &[(&str, &[u8])]) -> Vec<u8> {
345 let mut out = Vec::new();
346 {
347 let encoder = GzEncoder::new(&mut out, Compression::default());
348 let mut builder = tar::Builder::new(encoder);
349 for (path, contents) in files {
350 let mut header = tar::Header::new_gnu();
351 header.set_size(contents.len() as u64);
352 header.set_mode(0o644);
353 header.set_cksum();
354 builder.append_data(&mut header, path, *contents).unwrap();
355 }
356 let encoder = builder.into_inner().unwrap();
357 encoder.finish().unwrap();
358 }
359 out
360 }
361
362 fn write_manifest(dir: &Path, name: &str) {
363 std::fs::write(
364 dir.join(commands::MANIFEST_FILE),
365 format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\n"),
366 )
367 .unwrap();
368 }
369
370 fn ensure_empty_token_env() {
371 std::env::remove_var(crate::network::AUTH_TOKEN_ENV);
374 }
375
376 #[test]
377 fn extract_tarball_places_files() {
378 let tmp = tempfile::tempdir().unwrap();
379 let tar_path = tmp.path().join("pkg.tar.gz");
380 let bytes = make_tarball(&[("src/main.bock", b"module main"), ("README.md", b"hi")]);
381 std::fs::write(&tar_path, &bytes).unwrap();
382
383 let target = tmp.path().join("out");
384 extract_tarball(&tar_path, &target).unwrap();
385
386 assert_eq!(
387 std::fs::read_to_string(target.join("src/main.bock")).unwrap(),
388 "module main"
389 );
390 assert_eq!(
391 std::fs::read_to_string(target.join("README.md")).unwrap(),
392 "hi"
393 );
394 }
395
396 #[test]
397 fn install_package_writes_manifest_lockfile_and_unpacks() {
398 ensure_empty_token_env();
399 let project = tempfile::tempdir().unwrap();
400 write_manifest(project.path(), "my-app");
401
402 let tarball_bytes = make_tarball(&[("src/lib.bock", b"module foo\n")]);
403 let checksum = crate::network::sha256_hex(&tarball_bytes);
404
405 let mut server = Server::new();
406 let _versions = server
407 .mock("GET", "/packages/foo")
408 .with_status(200)
409 .with_body(r#"{"versions":["1.0.0","1.2.0"],"latest":"1.2.0"}"#)
410 .create();
411 let meta_body = format!(
412 r#"{{"manifest":{{"dependencies":{{}}}},"checksum":"sha256:{checksum}","download_url":""}}"#
413 );
414 let _meta = server
415 .mock("GET", "/packages/foo/1.2.0")
416 .with_status(200)
417 .with_body(meta_body)
418 .create();
419 let _download = server
420 .mock("GET", "/packages/foo/1.2.0/download")
421 .with_status(200)
422 .with_body(tarball_bytes.clone())
423 .create();
424
425 let cache_dir = project.path().join(CACHE_SUBDIR);
426 let registry = NetworkRegistry::new(server.url(), &cache_dir).unwrap();
427
428 let installed =
429 install_package(project.path(), ®istry, "foo", &InstallOptions::default()).unwrap();
430
431 assert_eq!(installed.version.to_string(), "1.2.0");
432 assert!(installed.install_dir.ends_with(".bock/packages/foo/1.2.0"));
433 assert_eq!(
434 std::fs::read_to_string(installed.install_dir.join("src/lib.bock")).unwrap(),
435 "module foo\n"
436 );
437
438 let manifest = Manifest::from_file(&project.path().join(commands::MANIFEST_FILE)).unwrap();
440 assert_eq!(manifest.dependencies["foo"].version_req(), Some("^1.2.0"));
441
442 let lockfile = Lockfile::from_file(&project.path().join(commands::LOCKFILE)).unwrap();
444 let entry = lockfile
445 .packages
446 .iter()
447 .find(|p| p.name == "foo")
448 .expect("lockfile entry missing");
449 assert_eq!(entry.version, "1.2.0");
450 assert_eq!(
451 entry.checksum.as_deref(),
452 Some(format!("sha256:{checksum}").as_str())
453 );
454 assert_eq!(entry.source.as_deref(), Some(server.url().as_str()));
455 }
456
457 #[test]
458 fn install_respects_version_requirement() {
459 ensure_empty_token_env();
460 let project = tempfile::tempdir().unwrap();
461 write_manifest(project.path(), "my-app");
462
463 let tarball_bytes = make_tarball(&[("src/lib.bock", b"")]);
464 let checksum = crate::network::sha256_hex(&tarball_bytes);
465
466 let mut server = Server::new();
467 let _versions = server
468 .mock("GET", "/packages/foo")
469 .with_status(200)
470 .with_body(r#"{"versions":["1.0.0","1.5.0","2.0.0"],"latest":"2.0.0"}"#)
471 .create();
472 let meta_body = format!(
473 r#"{{"manifest":{{"dependencies":{{}}}},"checksum":"sha256:{checksum}","download_url":""}}"#
474 );
475 let _meta = server
476 .mock("GET", "/packages/foo/1.5.0")
477 .with_status(200)
478 .with_body(meta_body)
479 .create();
480 let _download = server
481 .mock("GET", "/packages/foo/1.5.0/download")
482 .with_status(200)
483 .with_body(tarball_bytes)
484 .create();
485
486 let cache_dir = project.path().join(CACHE_SUBDIR);
487 let registry = NetworkRegistry::new(server.url(), &cache_dir).unwrap();
488
489 let options = InstallOptions {
490 version_req: Some("^1.0".to_string()),
491 offline: false,
492 };
493 let installed = install_package(project.path(), ®istry, "foo", &options).unwrap();
494
495 assert_eq!(installed.version.to_string(), "1.5.0");
496 }
497
498 #[test]
499 fn install_uses_cache_in_offline_mode() {
500 ensure_empty_token_env();
501 let project = tempfile::tempdir().unwrap();
502 write_manifest(project.path(), "my-app");
503
504 let cache_dir = project.path().join(CACHE_SUBDIR);
505 std::fs::create_dir_all(&cache_dir).unwrap();
506
507 let tarball_bytes = make_tarball(&[("README", b"offline")]);
508 let cached = cache_dir.join("foo-1.4.0.tar.gz");
509 std::fs::write(&cached, &tarball_bytes).unwrap();
510 let checksum = crate::network::sha256_hex(&tarball_bytes);
511
512 let registry = NetworkRegistry::new("http://127.0.0.1:1/", &cache_dir).unwrap();
514 let options = InstallOptions {
515 offline: true,
516 version_req: None,
517 };
518 let installed = install_package(project.path(), ®istry, "foo", &options).unwrap();
519
520 assert_eq!(installed.version.to_string(), "1.4.0");
521 assert_eq!(installed.checksum, checksum);
522 assert_eq!(installed.source, "cache");
523 }
524
525 #[test]
526 fn install_offline_errors_when_not_cached() {
527 ensure_empty_token_env();
528 let project = tempfile::tempdir().unwrap();
529 write_manifest(project.path(), "my-app");
530 let cache_dir = project.path().join(CACHE_SUBDIR);
531
532 let registry = NetworkRegistry::new("http://127.0.0.1:1/", &cache_dir).unwrap();
533 let err = install_package(
534 project.path(),
535 ®istry,
536 "foo",
537 &InstallOptions {
538 offline: true,
539 version_req: None,
540 },
541 )
542 .unwrap_err();
543 assert!(matches!(err, PkgError::PackageNotFound(_)));
544 }
545
546 #[test]
547 fn clear_cache_removes_tarballs() {
548 let tmp = tempfile::tempdir().unwrap();
549 let cache = tmp.path().join("cache");
550 std::fs::create_dir_all(&cache).unwrap();
551 std::fs::write(cache.join("a-1.0.0.tar.gz"), b"x").unwrap();
552 std::fs::write(cache.join("b-2.0.0.tar.gz"), b"y").unwrap();
553
554 let removed = clear_cache(&cache).unwrap();
555 assert_eq!(removed, 2);
556 assert!(cache.exists());
557 assert!(std::fs::read_dir(&cache).unwrap().next().is_none());
558 }
559
560 #[test]
561 fn pick_version_selects_highest_matching() {
562 let versions = vec![
563 "0.9.0".into(),
564 "1.0.0".into(),
565 "1.2.3".into(),
566 "2.0.0".into(),
567 ];
568 let req = VersionReq::parse("^1.0").unwrap();
569 let picked = pick_version(&versions, Some(&req)).unwrap();
570 assert_eq!(picked.as_deref(), Some("1.2.3"));
571 }
572
573 #[test]
574 fn pick_version_errors_when_nothing_matches() {
575 let versions = vec!["0.1.0".into(), "0.2.0".into()];
576 let req = VersionReq::parse("^1.0").unwrap();
577 let err = pick_version(&versions, Some(&req)).unwrap_err();
578 assert!(matches!(err, PkgError::ResolutionFailed(_)));
579 }
580
581 #[test]
582 fn install_sends_bearer_auth_when_configured() {
583 ensure_empty_token_env();
584 let project = tempfile::tempdir().unwrap();
585 write_manifest(project.path(), "my-app");
586
587 let tarball_bytes = make_tarball(&[("README", b"tok")]);
588 let checksum = crate::network::sha256_hex(&tarball_bytes);
589
590 let mut server = Server::new();
591 let _v = server
592 .mock("GET", "/packages/foo")
593 .match_header("authorization", "Bearer secret-xyz")
594 .with_status(200)
595 .with_body(r#"{"versions":["1.0.0"],"latest":"1.0.0"}"#)
596 .create();
597 let meta_body = format!(
598 r#"{{"manifest":{{"dependencies":{{}}}},"checksum":"sha256:{checksum}","download_url":""}}"#
599 );
600 let _m = server
601 .mock("GET", "/packages/foo/1.0.0")
602 .match_header("authorization", "Bearer secret-xyz")
603 .with_status(200)
604 .with_body(meta_body)
605 .create();
606 let _d = server
607 .mock("GET", "/packages/foo/1.0.0/download")
608 .match_header("authorization", "Bearer secret-xyz")
609 .with_status(200)
610 .with_body(tarball_bytes)
611 .create();
612
613 let cache_dir = project.path().join(CACHE_SUBDIR);
614 let registry = NetworkRegistry::new(server.url(), &cache_dir)
615 .unwrap()
616 .with_auth_token(Some("secret-xyz".to_string()));
617 install_package(project.path(), ®istry, "foo", &InstallOptions::default()).unwrap();
618 }
619}