1use crate::kernel::{parse, Form};
7use crate::project::{self, Project};
8use crate::tap::{self, Tap};
9use sha2::{Digest, Sha256};
10use std::fs;
11use std::path::{Path, PathBuf};
12
13pub use crate::package_catalog::{catalog_from_lock, LockedPackage};
14
15mod archive;
16mod install;
17use archive::*;
18use install::{install_archive, install_archive_at, json_string, validate_recipe};
19
20const MAX_PUBLICATION_DIAGNOSTIC_BYTES: usize = 4096;
21
22pub fn check_path(input: &Path) -> Result<(String, String), String> {
26 let project = read_project(input)?;
27 Ok((project.id, project.version.to_string()))
28}
29
30pub fn build_path(input: &Path, output: Option<&Path>) -> Result<PathBuf, String> {
31 build_path_with_package(input, output, None, None)
32}
33
34pub fn build_path_with_package(
38 input: &Path,
39 output: Option<&Path>,
40 package_name: Option<&str>,
41 profile: Option<&Path>,
42) -> Result<PathBuf, String> {
43 let mut project = read_project(input)?;
44 if let Some(name) = package_name {
45 if name.is_empty() {
46 return Err("package selection requires a non-empty semantic name".into());
47 }
48 project.package_name = Some(name.to_owned());
49 }
50 if let Some(profile) = profile {
51 project.package_profile = Some(project_relative_path(&project, profile)?);
52 }
53 if project.package_name.is_some() && project.package_profile.is_none() {
54 let default = project.root.join("config/packages.edn");
55 if default.is_file() {
56 project.package_profile = Some(PathBuf::from("config/packages.edn"));
57 } else {
58 return Err(
59 "semantic package selection requires --profile PATH or config/packages.edn".into(),
60 );
61 }
62 }
63 let destination = output.map(Path::to_path_buf).unwrap_or_else(|| {
64 let id = project
65 .package_name
66 .as_deref()
67 .filter(|_| project.package_profile.is_some())
68 .unwrap_or(&project.id);
69 project
70 .root
71 .join("target")
72 .join(format!("{}-{}.harp", archive_name(id), project.version))
73 });
74 build_archive(&project, &destination)?;
75 Ok(destination)
76}
77
78fn project_relative_path(project: &Project, path: &Path) -> Result<PathBuf, String> {
79 let root = project.root.canonicalize().map_err(|error| {
80 format!(
81 "cannot resolve project root {}: {error}",
82 project.root.display()
83 )
84 })?;
85 let candidate = if path.is_absolute() {
86 path.to_path_buf()
87 } else {
88 let project_relative = project.root.join(path);
94 if project_relative.exists() {
95 project_relative
96 } else {
97 std::env::current_dir()
98 .map_err(|error| format!("cannot resolve package profile path: {error}"))?
99 .join(path)
100 }
101 };
102 let resolved = candidate.canonicalize().map_err(|error| {
103 format!(
104 "cannot resolve package profile {}: {error}",
105 candidate.display()
106 )
107 })?;
108 match resolved.strip_prefix(&root) {
109 Ok(relative) if !relative.as_os_str().is_empty() => Ok(relative.to_path_buf()),
110 _ => Err("package profile must be inside the project root".to_owned()),
111 }
112}
113
114pub(crate) fn semantic_package_identity(name: &str) -> Result<String, String> {
118 if name.is_empty() {
119 return Err("semantic package name must be non-empty".into());
120 }
121 if name.contains(':') {
122 return project::normalize_coordinate(name);
123 }
124 let package = if name.contains('/') {
125 name.to_owned()
126 } else if let Some((owner, remainder)) = name.split_once('.') {
127 format!("{owner}/{remainder}")
128 } else {
129 format!("hara/{name}")
130 };
131 project::normalize_coordinate(&format!("hara:{package}"))
132}
133
134pub fn inspect_path(archive: &Path) -> Result<String, String> {
135 inspect_archive(archive)
136}
137
138pub fn install_path(input: &Path) -> Result<PathBuf, String> {
139 install_path_at(input, &install::dist_root())
140}
141
142pub fn install_path_at(input: &Path, distribution_root: &Path) -> Result<PathBuf, String> {
146 let archive = if input.is_dir() {
147 build_path(input, None)?
148 } else {
149 input.to_path_buf()
150 };
151 install_archive_at(&archive, distribution_root)
152}
153
154pub fn run(args: &[String]) -> Result<(), String> {
156 match args.first().map(String::as_str) {
157 Some("check") => {
158 let root = args
159 .get(1)
160 .map(PathBuf::from)
161 .unwrap_or_else(|| PathBuf::from("."));
162 let project = read_project(&root)?;
163 println!("package check: {} {}", project.id, project.version);
164 Ok(())
165 }
166 Some("build") => {
167 let parsed = parse_build_arguments(&args[1..])?;
168 let root = parsed
169 .path
170 .unwrap_or_else(|| PathBuf::from("."));
171 let output = build_path_with_package(
172 &root,
173 parsed.output.as_deref(),
174 parsed.package.as_deref(),
175 parsed.profile.as_deref(),
176 )?;
177 println!("package build: {}", output.display());
178 Ok(())
179 }
180 Some("inspect") => {
181 let archive = args
182 .get(1)
183 .ok_or_else(|| "hara package inspect requires ARCHIVE.harp".to_owned())?;
184 println!("{}", inspect_archive(Path::new(archive))?);
185 Ok(())
186 }
187 Some("profile") => {
188 let path = args
189 .get(1)
190 .map(PathBuf::from)
191 .unwrap_or_else(|| PathBuf::from("config/packages.edn"));
192 let source = fs::read_to_string(&path)
193 .map_err(|error| format!("cannot read {}: {error}", path.display()))?;
194 let definitions = crate::package_catalog::definitions_from_packages_edn(&source)?;
195 for definition in definitions {
196 let dependencies = if definition.dependencies.is_empty() {
197 String::new()
198 } else {
199 format!(" depends on {}", definition.dependencies.join(", "))
200 };
201 println!("{}{}", definition.name, dependencies);
202 }
203 Ok(())
204 }
205 Some("install") => {
206 let input = args
207 .get(1)
208 .map(PathBuf::from)
209 .unwrap_or_else(|| PathBuf::from("."));
210 let archive = if input.is_dir() {
211 let project = read_project(&input)?;
212 let output = project.root.join("target").join(format!(
213 "{}-{}.harp",
214 archive_name(&project.id),
215 project.version
216 ));
217 build_archive(&project, &output)?;
218 output
219 } else {
220 input
221 };
222 let installed = install_archive(&archive)?;
223 println!("package install: {}", installed.display());
224 Ok(())
225 }
226 Some("publish") => publish(&args[1..]),
227 Some("tap") => tap_command(&args[1..]),
228 Some("registry") => registry_command(&args[1..]),
229 Some("sync") | Some("add") | Some("remove") | Some("update") | Some("search")
230 | Some("info") => Err(format!(
231 "hara package {} requires a configured GitHub registry and identity client; local package commands available now: check, build, inspect",
232 args[0]
233 )),
234 Some("--help") | Some("-h") | None => {
235 println!(
236 "hara package <check|build|inspect|profile|sync|add|remove|update|publish|tap|search|info>\n\n\
237 check [PATH] validate project.edn and recipe\n\
238 build [PATH] [--package NAME] [--profile PATH] [--output PATH] build deterministic .harp\n\
239 inspect ARCHIVE.harp print package.edn\n\
240 profile [PATH] validate and list semantic packages\n\
241 install [PATH|ARCHIVE.harp] install into HARA_DIST_HOME or ~/.hara/dist\n\
242 tap bootstrap official install the official profile\n\
243 tap init NAME --registry PATH --identity PATH --identity-root-key ED25519_HEX\n\
244 tap add NAME --registry URL --identity URL --identity-key SHA256\n\
245 tap mirror add NAME [--registry URL] [--identity URL]\n\
246 tap list|remove NAME|verify NAME\n\
247 publish [--tap official] [--dry-run] [PATH]"
248 );
249 Ok(())
250 }
251 Some(command) => Err(format!("unknown package command: {command}")),
252 }
253}
254
255#[derive(Default)]
256struct BuildArguments {
257 path: Option<PathBuf>,
258 output: Option<PathBuf>,
259 package: Option<String>,
260 profile: Option<PathBuf>,
261}
262
263fn parse_build_arguments(args: &[String]) -> Result<BuildArguments, String> {
264 let mut parsed = BuildArguments::default();
265 let mut index = 0;
266 while index < args.len() {
267 let argument = &args[index];
268 let (option, inline) = if argument.starts_with("--") {
269 argument
270 .split_once('=')
271 .map_or((argument.as_str(), None), |(option, value)| {
272 (option, Some(value))
273 })
274 } else {
275 (argument.as_str(), None)
276 };
277 let value = |index: &mut usize, label: &str| -> Result<String, String> {
278 if let Some(value) = inline {
279 if value.is_empty() {
280 return Err(format!("{label} requires a value"));
281 }
282 return Ok(value.to_owned());
283 }
284 *index += 1;
285 args.get(*index)
286 .filter(|value| !value.starts_with('-'))
287 .cloned()
288 .ok_or_else(|| format!("{label} requires a value"))
289 };
290 match option {
291 "--output" => parsed.output = Some(PathBuf::from(value(&mut index, "--output")?)),
292 "--package" => parsed.package = Some(value(&mut index, "--package")?),
293 "--profile" => parsed.profile = Some(PathBuf::from(value(&mut index, "--profile")?)),
294 value if value.starts_with('-') => {
295 return Err(format!("unknown package build option: {value}"))
296 }
297 value => {
298 if parsed.path.replace(PathBuf::from(value)).is_some() {
299 return Err("package build accepts at most one project path".into());
300 }
301 }
302 }
303 index += 1;
304 }
305 Ok(parsed)
306}
307
308fn read_project(path: &Path) -> Result<Project, String> {
309 project::read(path)
310}
311
312fn registry_command(args: &[String]) -> Result<(), String> {
313 match args.first().map(String::as_str) {
314 Some("verify-request") => {
315 let request = PathBuf::from(required_option(args, "--request")?);
316 let identity = PathBuf::from(required_option(args, "--identity")?);
317 verify_registry_request_paths(&request, &identity)?;
318 println!("registry request verified: {}", request.display());
319 Ok(())
320 }
321 _ => {
322 Err("usage: hara package registry verify-request --request PATH --identity PATH".into())
323 }
324 }
325}
326
327pub fn verify_registry_request_paths(request: &Path, identity: &Path) -> Result<(), String> {
328 let policy = fs::read_to_string(identity)
329 .map_err(|error| format!("cannot read {}: {error}", identity.display()))?;
330 let Form::Map(policy) = parse(&policy)? else {
331 return Err("identity policy must be an EDN map".into());
332 };
333 let trust = policy
334 .iter()
335 .find(|(key, _)| matches!(key, Form::Keyword(name) if name == "identity/trust"))
336 .map(|(_, value)| value);
337 if !matches!(trust, Some(Form::Keyword(mode)) if mode == "github-governed") {
338 return Err("registry bootstrap verifier requires :identity/trust :github-governed".into());
339 }
340 let intent_path = fs::read_dir(request)
341 .map_err(|error| format!("cannot read {}: {error}", request.display()))?
342 .filter_map(Result::ok)
343 .map(|entry| entry.path())
344 .find(|path| {
345 path.file_name()
346 .and_then(|name| name.to_str())
347 .is_some_and(|name| name.ends_with(".publisher-intent.edn"))
348 })
349 .ok_or("request is missing publisher intent")?;
350 let intent = fs::read_to_string(&intent_path).map_err(io_error)?;
351 let Form::Map(entries) = parse(&intent)? else {
352 return Err("publisher intent must be an EDN map".into());
353 };
354 for key in [
355 "intent/format",
356 "tap",
357 "coordinate",
358 "version",
359 "repository",
360 "tag",
361 "commit",
362 "archive-sha256",
363 "identity-revision",
364 ] {
365 if !entries
366 .iter()
367 .any(|(candidate, _)| matches!(candidate, Form::Keyword(name) if name == key))
368 {
369 return Err(format!("publisher intent is missing :{key}"));
370 }
371 }
372 Ok(())
373}
374
375pub fn tap_command(args: &[String]) -> Result<(), String> {
376 let root = tap::config_root();
377 match args.first().map(String::as_str) {
378 Some("add") => {
379 let name = args
380 .get(1)
381 .ok_or_else(|| "tap add requires NAME".to_owned())?;
382 let registry = option_values(args, "--registry");
383 let identity = option_values(args, "--identity");
384 let identity_key = option_value(args, "--identity-key")?;
385 tap::add(
386 &root,
387 Tap {
388 name: name.clone(),
389 registry,
390 identity,
391 identity_key,
392 trust: tap::TrustMode::SignedRoot,
393 },
394 )?;
395 println!("trusted tap {name}");
396 Ok(())
397 }
398 Some("bootstrap") => {
399 let profile = args
400 .get(1)
401 .ok_or_else(|| "tap bootstrap requires PROFILE".to_owned())?;
402 let tap = tap::bootstrap(&root, profile)?;
403 println!("bootstrapped tap {} (GitHub-governed)", tap.name);
404 Ok(())
405 }
406 Some("mirror") if args.get(1).map(String::as_str) == Some("add") => {
407 let name = args
408 .get(2)
409 .ok_or_else(|| "tap mirror add requires NAME".to_owned())?;
410 let tap = tap::add_mirror(
411 &root,
412 name,
413 optional_option(args, "--registry"),
414 optional_option(args, "--identity"),
415 )?;
416 println!(
417 "updated tap {} registry={} identity={}",
418 tap.name,
419 tap.registry.join(","),
420 tap.identity.join(",")
421 );
422 Ok(())
423 }
424 Some("init") => {
425 let name = args
426 .get(1)
427 .ok_or_else(|| "tap init requires NAME".to_owned())?;
428 let registry = PathBuf::from(required_option(args, "--registry")?);
429 let identity = PathBuf::from(required_option(args, "--identity")?);
430 let root_key = required_option(args, "--identity-root-key")?;
431 let initialized = tap::initialize(name, ®istry, &identity, &root_key)?;
432 tap::add(&root, initialized.tap)?;
433 println!("initialized tap {name}");
434 println!("identity-root fingerprint: {}", initialized.fingerprint);
435 println!("scaffolded registry: {}", registry.display());
436 println!("scaffolded identity: {}", identity.display());
437 Ok(())
438 }
439 Some("remove") => {
440 let name = args
441 .get(1)
442 .ok_or_else(|| "tap remove requires NAME".to_owned())?;
443 tap::remove(&root, name)?;
444 println!("removed tap {name}");
445 Ok(())
446 }
447 Some("list") => {
448 for tap in tap::load(&root)?.values() {
449 println!(
450 "{} registry={} identity={}",
451 tap.name,
452 tap.registry.join(","),
453 tap.identity.join(",")
454 );
455 }
456 Ok(())
457 }
458 Some("verify") => {
459 let name = args
460 .get(1)
461 .ok_or_else(|| "tap verify requires NAME".to_owned())?;
462 let tap = tap::trusted(&root, name)?;
463 let scratch = scratch("verify")?;
464 let result = tap::fetch_verified_policy(&tap, &scratch);
465 let _ = fs::remove_dir_all(&scratch);
466 let policy = result?;
467 println!("tap verify: {} identity={}", tap.name, policy.revision);
468 Ok(())
469 }
470 _ => {
471 Err("usage: hara package tap <bootstrap|init|add|mirror add|remove|list|verify>".into())
472 }
473 }
474}
475
476fn publish(args: &[String]) -> Result<(), String> {
477 let tap_name = optional_option(args, "--tap")
478 .map(|name| {
479 if name == "official" {
480 "hara".into()
481 } else {
482 name
483 }
484 })
485 .unwrap_or_else(|| "hara".into());
486 let dry_run = args.iter().any(|arg| arg == "--dry-run");
487 let path = args
488 .iter()
489 .skip(1)
490 .find(|arg| !arg.starts_with('-') && *arg != &tap_name)
491 .map(PathBuf::from)
492 .unwrap_or_else(|| PathBuf::from("."));
493 println!("{}", publish_path(&path, &tap_name, dry_run)?);
494 Ok(())
495}
496
497pub fn publish_path(path: &Path, tap_name: &str, dry_run: bool) -> Result<String, String> {
498 publish_path_with_signer(path, tap_name, dry_run, false, tap::sign)
499}
500
501pub fn publish_path_with_signer<F>(
505 path: &Path,
506 tap_name: &str,
507 dry_run: bool,
508 skip_signed_tag: bool,
509 signer: F,
510) -> Result<String, String>
511where
512 F: Fn(&[u8]) -> Result<(String, String), String>,
513{
514 publish_path_with_signer_and_identity(path, tap_name, dry_run, skip_signed_tag, signer, None)
515}
516
517pub fn publish_path_with_signer_and_identity<F>(
522 path: &Path,
523 tap_name: &str,
524 dry_run: bool,
525 skip_signed_tag: bool,
526 signer: F,
527 publisher_public_key: Option<&str>,
528) -> Result<String, String>
529where
530 F: Fn(&[u8]) -> Result<(String, String), String>,
531{
532 let tap_name = if tap_name == "official" {
533 "hara"
534 } else {
535 tap_name
536 };
537 let project = read_project(path)?;
538 let coordinate = project::normalize_coordinate(&project.id)?;
539 let (coordinate_tap, _) = split_coordinate(&coordinate)?;
540 if coordinate_tap != tap_name {
541 return Err(format!(
542 "project id {} belongs to tap {coordinate_tap}, not {tap_name}",
543 project.id
544 ));
545 }
546 let trusted_tap = tap::trusted_or_builtin(&tap::config_root(), &tap_name)?;
547 let scratch = scratch("publish")?;
548 let result = publish_inner(
549 &project,
550 &trusted_tap,
551 dry_run,
552 skip_signed_tag,
553 &scratch,
554 &signer,
555 publisher_public_key,
556 );
557 let _ = fs::remove_dir_all(&scratch);
558 result
559}
560
561fn publish_inner<F>(
562 project: &Project,
563 trusted_tap: &Tap,
564 dry_run: bool,
565 skip_signed_tag: bool,
566 scratch_root: &Path,
567 signer: &F,
568 publisher_public_key: Option<&str>,
569) -> Result<String, String>
570where
571 F: Fn(&[u8]) -> Result<(String, String), String>,
572{
573 let policy = tap::fetch_verified_policy(trusted_tap, scratch_root)?;
574 let tag = project.release_tag.clone();
575 let (source_reference, commit) = source_release(&project.root, &tag, skip_signed_tag)?;
576 let repository = tap::git(&project.root, ["config", "--get", "remote.origin.url"])?;
577 let recipe = validate_recipe(project)?;
578 build_archive(project, &scratch_root.join("publish.harp"))?;
579 let project_sha256 = file_sha256(&project.root.join("project.edn"))?;
580 let recipe_sha256 = file_sha256(&recipe)?;
581 let coordinate = project::normalize_coordinate(&project.id)?;
582 let intent = tap::canonical_recipe_intent(
583 &coordinate,
584 &project.version.to_string(),
585 &repository,
586 &source_reference,
587 &commit,
588 &project_sha256,
589 &recipe_sha256,
590 &trusted_tap.name,
591 &policy.revision,
592 );
593 let (key_id, signature) = signer(intent.as_bytes())?;
594 if let Err(error) = tap::authorize(&policy, &key_id, &coordinate, intent.as_bytes(), &signature)
595 {
596 if !dry_run {
597 if let Some(public_key) = publisher_public_key {
598 crate::identity_tool::request_publisher_grant_with_signer(
599 &coordinate,
600 &intent,
601 &policy.revision,
602 public_key,
603 signer,
604 )?;
605 }
606 }
607 return Err(error);
608 }
609 if dry_run {
610 let status = if skip_signed_tag {
611 "untagged-source publish preflight (remote default head verified)"
612 } else {
613 "publish recipe verified"
614 };
615 return Ok(format!(
616 "{status}: {} {} tap={} recipe=sha256:{}",
617 coordinate, project.version, trusted_tap.name, recipe_sha256,
618 ));
619 }
620 let endpoint = trusted_tap
621 .registry
622 .first()
623 .ok_or("official tap has no publication endpoint")?;
624 let authorization = match publisher_public_key {
625 Some(public_key) => crate::identity_tool::request_publication_authorization_with_signer(
626 &coordinate,
627 &intent,
628 &policy.revision,
629 public_key,
630 signer,
631 )?,
632 None => "null".into(),
633 };
634 let body = format!(
635 "{{\"intent\":{},\"key_id\":\"{}\",\"signature\":\"{}\",\"authorization\":{}}}",
636 json_string(&intent),
637 key_id,
638 signature,
639 authorization,
640 );
641 let output = std::process::Command::new("curl")
642 .args([
643 "--fail-with-body",
644 "--silent",
645 "--show-error",
646 "-H",
647 "content-type: application/json",
648 "--data-binary",
649 &body,
650 &format!("{}/v1/publications", endpoint.trim_end_matches('/')),
651 ])
652 .output()
653 .map_err(|error| format!("cannot start publication client: {error}"))?;
654 if !output.status.success() {
655 return Err(publication_request_error(&output.stderr, &output.stdout));
656 }
657 Ok(format!(
658 "publish requested: {}",
659 String::from_utf8_lossy(&output.stdout).trim()
660 ))
661}
662
663fn publication_request_error(stderr: &[u8], response: &[u8]) -> String {
664 let transport = publication_diagnostic(stderr);
665 let body = publication_diagnostic(response);
666 match (transport.is_empty(), body.is_empty()) {
667 (true, true) => "publication request failed".into(),
668 (false, true) => format!("publication request failed: {transport}"),
669 (true, false) => format!("publication request failed: {body}"),
670 (false, false) => format!("publication request failed: {transport}: {body}"),
671 }
672}
673
674fn publication_diagnostic(bytes: &[u8]) -> String {
675 let length = bytes.len().min(MAX_PUBLICATION_DIAGNOSTIC_BYTES);
676 let text = String::from_utf8_lossy(&bytes[..length]).trim().to_owned();
677 if bytes.len() > length && !text.is_empty() {
678 format!("{text}…")
679 } else {
680 text
681 }
682}
683
684pub fn source_release_commit(
687 root: &Path,
688 tag: &str,
689 skip_signed_tag: bool,
690) -> Result<String, String> {
691 source_release(root, tag, skip_signed_tag).map(|(_, commit)| commit)
692}
693
694fn source_release(
695 root: &Path,
696 tag: &str,
697 skip_signed_tag: bool,
698) -> Result<(String, String), String> {
699 if !skip_signed_tag {
700 tap::git(root, ["tag", "-v", tag])
701 .map_err(|error| format!("publish requires a valid signed tag {tag}: {error}"))?;
702 return Ok((tag.into(), tap::git(root, ["rev-list", "-n", "1", tag])?));
703 }
704
705 let status = tap::git(root, ["status", "--porcelain", "--untracked-files=all"])?;
706 if !status.is_empty() {
707 return Err("publish without a signed tag requires a clean worktree".into());
708 }
709 let commit = tap::git(root, ["rev-parse", "HEAD"])?;
710 let remote_head =
711 tap::git(root, ["ls-remote", "--symref", "origin", "HEAD"]).map_err(|error| {
712 format!("publish without a signed tag cannot resolve origin default branch: {error}")
713 })?;
714 let branch = remote_head
715 .lines()
716 .find_map(|line| {
717 let (reference, name) = line.split_once('\t')?;
718 if name == "HEAD" {
719 reference.strip_prefix("ref: refs/heads/")
720 } else {
721 None
722 }
723 })
724 .ok_or("publish without a signed tag cannot determine origin default branch")?;
725 let remote_ref = format!("refs/heads/{branch}");
726 let remote_commit = tap::git(root, ["ls-remote", "origin", remote_ref.as_str()])
727 .map_err(|error| {
728 format!(
729 "publish without a signed tag cannot read origin default branch {branch}: {error}"
730 )
731 })?
732 .split_whitespace()
733 .next()
734 .ok_or_else(|| {
735 format!("publish without a signed tag cannot read origin default branch {branch}")
736 })?
737 .to_owned();
738 if remote_commit != commit {
739 return Err(format!(
740 "publish without a signed tag requires HEAD {commit} to match origin remote default branch {branch} at {remote_commit}"
741 ));
742 }
743 Ok((format!("untagged:{branch}"), commit))
744}
745
746fn option_value(args: &[String], flag: &str) -> Result<String, String> {
747 let index = args
748 .iter()
749 .position(|arg| arg == flag)
750 .ok_or_else(|| format!("publish requires {flag}"))?;
751 args.get(index + 1)
752 .cloned()
753 .ok_or_else(|| format!("{flag} requires a value"))
754}
755fn required_option(args: &[String], flag: &str) -> Result<String, String> {
756 let index = args
757 .iter()
758 .position(|arg| arg == flag)
759 .ok_or_else(|| format!("tap init requires {flag}"))?;
760 args.get(index + 1)
761 .cloned()
762 .ok_or_else(|| format!("{flag} requires a value"))
763}
764fn option_values(args: &[String], flag: &str) -> Vec<String> {
765 args.iter()
766 .enumerate()
767 .filter(|(_, value)| value.as_str() == flag)
768 .filter_map(|(index, _)| args.get(index + 1).cloned())
769 .collect()
770}
771fn optional_option(args: &[String], flag: &str) -> Option<String> {
772 args.iter()
773 .position(|arg| arg == flag)
774 .and_then(|index| args.get(index + 1).cloned())
775}
776fn split_coordinate(value: &str) -> Result<(&str, &str), String> {
777 let (tap, package) = value
778 .split_once(':')
779 .ok_or_else(|| format!("package coordinate must use TAP:owner/name: {value}"))?;
780 if tap.is_empty() || package.is_empty() || package.contains(':') {
781 return Err(format!("invalid tap-qualified package coordinate: {value}"));
782 }
783 Ok((tap, package))
784}
785fn scratch(label: &str) -> Result<PathBuf, String> {
786 let root = std::env::temp_dir().join(format!("hara-{label}-{}", std::process::id()));
787 if root.exists() {
788 fs::remove_dir_all(&root).map_err(io_error)?;
789 }
790 fs::create_dir_all(&root).map_err(io_error)?;
791 Ok(root)
792}
793fn file_sha256(path: &Path) -> Result<String, String> {
794 Ok(hex(&Sha256::digest(fs::read(path).map_err(io_error)?)))
795}
796
797#[cfg(test)]
798mod tests;