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