1use crate::io::api::{github, gitlab, Configuration, Endpoint};
8use crate::io::bagit::Bag;
9use crate::io::database::schema::{ModelRow, Table};
10use crate::io::database::{resolve_database_path, Database, Provenance, ResearchActivityCandidate, Row};
11use crate::io::http::download_with_progress;
12use crate::io::{
13 files_all, parse_jsonc_cst, read_file, sync, uri_to_path, with_progress, write_file, write_file_bytes, ApiResult, ArchiveCandidate, CstRootNode,
14 CstValue, Executor, FromPath, InputOutput, ProgressType, Source, TemporaryDirectory,
15};
16use crate::prelude::{self, env, exit, Arc, ErrorKind, HashMap, HashSet, Mutex, Path, PathBuf};
17use crate::schema::pid::{Identifier, PID};
18use crate::schema::research_activity::ResearchActivity;
19use crate::schema::OneOrMany;
20use crate::schema::{
21 agent::{ModelDetails, Quantization},
22 hardware::memory::Memory,
23};
24use crate::util::constants::app::{DEFAULT_CONFIG_FILENAMES, IGNORE, SUPPORTED_RAD_FILETYPES};
25use crate::util::{detect_json, is_filetype, suffix, text_diff_changes_with_color, Label, MimeType, StringConversion};
26use crate::{Location, Repository, Scheme};
27use bon::Builder;
28use color_eyre::eyre::{eyre, Report};
29use core::fmt::{self, Debug};
30use core::future::Future;
31use core::iter::once;
32use derive_more::Display;
33use fancy_regex::Regex;
34use futures::{future, stream, StreamExt, TryStreamExt};
35use itertools::Itertools;
36use jiff::Timestamp;
37use owo_colors::OwoColorize;
38use serde::{Deserialize, Serialize};
39use serde_with::skip_serializing_none;
40use std::path::Component;
41use tracing::{error, info, warn};
42
43#[derive(Clone, Debug, Default, Display, Eq, PartialEq, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum AuthenticationRequirement {
47 None,
49 #[default]
51 Optional,
52 Required,
54}
55#[derive(Clone, Debug, Serialize, Deserialize)]
153#[serde(untagged)]
154pub enum ModelEntry {
155 Selector(String),
157 Entry(ModelEntryOptions),
159}
160#[derive(Clone, Debug, Default, Display, Serialize, Deserialize)]
162#[serde(rename_all = "snake_case")]
163pub enum RunnerStatus {
164 #[default]
166 Online,
167 Offline,
169 Stale,
171 NeverContacted,
173 Active,
175 Paused,
177}
178#[derive(Clone, Debug, Default, Serialize, Deserialize)]
182pub enum RunnerType {
183 #[default]
185 #[serde(rename = "group_type", alias = "group")]
186 Group,
187 #[serde(rename = "instance_type", alias = "instance")]
190 Instance,
191 #[serde(rename = "project_type", alias = "project")]
193 Project,
194}
195#[derive(Clone, Copy, Debug, Eq, PartialEq)]
197pub enum TransferPolicy {
198 Download,
200 Import,
202}
203#[derive(Clone, Debug, Default, Serialize, eserde::Deserialize)]
243pub struct ApplicationConfiguration {
244 #[serde(skip)]
246 pub cst: Option<CstRootNode>,
247 #[eserde(compat)]
249 pub buckets: Option<Vec<Bucket>>,
250 #[eserde(compat)]
252 pub config: Option<sync::Config>,
253 #[eserde(compat)]
255 pub endpoints: Option<Vec<Endpoint>>,
256 #[eserde(compat)]
258 pub models: Option<Vec<ModelEntry>>,
259 #[eserde(compat)]
261 pub runners: Option<Vec<RunnerDetails>>,
262 #[eserde(compat)]
264 pub whitelist: Option<WhitelistLookup>,
265}
266#[skip_serializing_none]
268#[derive(Builder, Clone, Debug, Serialize, Deserialize)]
269#[serde(rename_all = "camelCase")]
270#[builder(start_fn = init)]
271pub struct Bucket {
272 pub name: Option<String>,
276 pub description: Option<String>,
280 #[serde(alias = "repository")]
284 pub code_repository: Repository,
285}
286#[derive(Builder, Clone, Debug)]
288#[builder(start_fn = init)]
289pub struct BucketOptions {
290 pub output: Option<PathBuf>,
292 pub database_path: Option<PathBuf>,
294 #[builder(default)]
296 pub no_local_database: bool,
297 #[builder(default)]
299 pub offline: bool,
300 #[builder(default = 10)]
302 pub threads: usize,
303 #[builder(default)]
305 pub quiet: bool,
306 #[builder(default)]
308 pub ignore: Vec<String>,
309 #[builder(default)]
311 pub filter: Vec<String>,
312 #[builder(default)]
314 pub flatten: bool,
315 #[builder(default)]
317 pub clobber: bool,
318}
319#[derive(Clone, Debug, Default)]
321pub struct Buckets(Vec<Bucket>);
322#[derive(Clone, Debug)]
324pub struct FilterSet {
325 pub ignore: Vec<Regex>,
327 pub filter: Vec<Regex>,
329}
330#[skip_serializing_none]
332#[derive(Builder, Clone, Debug, Serialize, Deserialize)]
333#[serde(rename_all = "camelCase")]
334#[builder(start_fn = init)]
335pub struct ModelEntryOptions {
336 pub name: String,
338 pub source: Repository,
340 #[serde(default)]
342 pub revision: Option<String>,
343 #[serde(default)]
345 pub auth: Option<AuthenticationRequirement>,
346 #[serde(default)]
348 pub filter: Option<Vec<String>>,
349 #[serde(default)]
351 pub ignore: Option<Vec<String>>,
352 #[serde(default)]
354 pub quantization: Option<OneOrMany<Quantization>>,
355 #[serde(default)]
357 pub gpu_memory: Option<Memory>,
358 #[serde(default)]
360 pub copy: Option<bool>,
361 #[serde(default)]
363 pub symlink: Option<bool>,
364}
365#[derive(Builder, Clone, Debug, Serialize, Deserialize)]
366#[builder(start_fn = at, on(String, into))]
367#[serde(rename_all = "camelCase")]
368pub struct RunnerDetails {
370 #[builder(start_fn)]
372 #[serde(alias = "repository")]
373 pub code_repository: Repository,
374 pub name: Option<String>,
378 #[builder(default, with = |method: &str| RunnerType::from(method))]
380 #[serde(rename = "type")]
381 pub runner_type: RunnerType,
382 pub description: Option<String>,
384 #[builder(default = Executor::Docker)]
386 #[serde(default = "default_executor")]
387 pub executor: Executor,
388 #[builder(default)]
390 #[serde(default, alias = "gpu")]
391 pub gpu_enabled: bool,
392 #[serde(default, alias = "tag_list")]
394 pub tags: Option<Vec<String>>,
395 #[builder(default)]
397 #[serde(default, alias = "run_untagged")]
398 pub run_untagged: bool,
399 pub host: Option<String>,
401 #[builder(default = String::from("gitlab/gitlab-runner:latest"))]
403 #[serde(default = "default_docker_image")]
404 pub docker_image: String,
405 #[serde(default)]
407 pub identifier: Option<u64>,
408 #[serde(default)]
410 pub token: Option<String>,
411}
412#[derive(Clone, Debug, Eq, PartialEq)]
413struct TransferItem {
414 source: PathBuf,
415 destination: PathBuf,
416}
417#[derive(Clone, Debug, Eq, PartialEq)]
419pub struct TransferManifest {
420 pub bucket: Option<String>,
422 pub repository: String,
424 pub files: Vec<PathBuf>,
426}
427#[skip_serializing_none]
431#[derive(Builder, Clone, Debug, Serialize, Deserialize)]
432#[serde(rename_all = "camelCase")]
433#[builder(start_fn = init)]
434pub struct WhitelistLookup {
435 pub buckets: Option<Vec<String>>,
437 pub models: Option<OneOrMany<String>>,
439}
440impl ApplicationConfiguration {
441 pub fn load(path: &Option<PathBuf>) -> ApiResult<Self> {
443 match path {
444 | Some(path) if !path.is_file() => Err(eyre!("Configuration file does not exist — {}", path.display())),
445 | _ => Self::resolve(path).map_or_else(|| Ok(Self::default()), Self::read),
446 }
447 }
448 pub fn resolve_sync_config(&self, overrides: sync::Config) -> sync::Config {
450 self.config.clone().unwrap_or_default().merge(overrides)
451 }
452 pub fn model_entries_and_whitelist(&self) -> (Vec<ModelEntry>, Option<OneOrMany<String>>) {
454 (
455 self.models.clone().unwrap_or_default(),
456 self.whitelist.as_ref().and_then(|lookup| lookup.models.clone()),
457 )
458 }
459 pub fn sync(&self, options: sync::Options<'_>) -> ApiResult<()> {
461 let sync_config = self.config.clone().unwrap_or_default();
462 sync_config.resolve_models_dir(options.models_dir).and_then(|models_dir| {
463 info!("{} Resolving selected models for synchronization", Label::run());
464 let request_options = sync::ModelRequestOptions {
465 models_dir: &models_dir,
466 assume_models: options.assume_models,
467 fallbacks: Vec::new(),
468 };
469 ModelEntry::resolve(options.entries, &request_options).and_then(|resolved| {
470 sync_config.sync(sync::Options {
471 models: &resolved,
472 models_dir: Some(&models_dir),
473 ..options
474 })
475 })
476 })
477 }
478 pub fn sync_and_update(&self, path: &Option<PathBuf>, options: sync::Options<'_>) -> ApiResult<()> {
480 let path = Self::resolve(path)
481 .or_else(|| path.clone())
482 .unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG_FILENAMES[0]));
483 self.sync(options)
484 .and_then(|()| self.with_models(options.entries))
485 .and_then(|configuration| configuration.write_or_preview(&path, options.dry_run, options.no_color))
486 }
487 fn with_models(&self, entries: &[ModelEntry]) -> ApiResult<Self> {
488 self.models
489 .clone()
490 .unwrap_or_default()
491 .into_iter()
492 .chain(entries.iter().cloned())
493 .try_fold((HashSet::new(), Vec::new()), |(mut identifiers, mut models), entry| {
494 sync::ModelRequest::try_from(&entry).map(|request| {
495 if identifiers.insert(request.id().to_string()) {
496 models.push(entry);
497 }
498 (identifiers, models)
499 })
500 })
501 .and_then(|(_, models)| {
502 let mut configuration = self.clone();
503 configuration.models = Some(models);
504 match configuration.cst.clone() {
505 | Some(cst) => serde_json::to_value(&configuration.models)
506 .map_err(|why| eyre!("Failed to serialize ACORN model configuration — {why}"))
507 .map(|models| {
508 let root = cst.object_value_or_set();
509 match root.get("models") {
510 | Some(property) => property.set_value(CstValue(&models).into()),
511 | None => {
512 root.append("models", CstValue(&models).into());
513 }
514 }
515 root.array_value_or_set("models").ensure_multiline();
516 configuration
517 }),
518 | None => Ok(configuration),
519 }
520 })
521 }
522 fn write_or_preview(&self, path: &Path, dry_run: bool, no_color: bool) -> ApiResult<()> {
523 let before = path
524 .is_file()
525 .then(|| read_file(path))
526 .transpose()
527 .map(|content| content.unwrap_or_default());
528 before.and_then(|before| {
529 self.render(path).and_then(|content| match (dry_run, before == content) {
530 | (_, true) => {
531 info!("=> {} No changes for {}", Label::CAUTION, path.display());
532 Ok(())
533 }
534 | (true, false) => {
535 match no_color {
536 | true => println!("\n{}", path.display()),
537 | false => println!("\n{}", path.display().cyan().bold()),
538 }
539 text_diff_changes_with_color(&before, &content, !no_color)
540 .iter()
541 .for_each(|(_, line)| print!("{line}"));
542 Ok(())
543 }
544 | (false, false) => self
545 .write(path)
546 .inspect(|()| info!("=> {} Updated {}", Label::pass(), path.display().cyan())),
547 })
548 })
549 }
550 fn render(&self, path: &Path) -> ApiResult<String> {
551 match path.file_name().and_then(|name| name.to_str()) {
552 | Some(".acorn") => self.render_json(),
553 | _ => match MimeType::from_path(path) {
554 | MimeType::Json | MimeType::Jsonc => self.render_json(),
555 | MimeType::Yaml => serde_norway::to_string(self).map_err(|why| eyre!("Failed to serialize YAML config — {why}")),
556 | _ => Err(eyre!("Unsupported configuration file extension")),
557 },
558 }
559 }
560 fn render_json(&self) -> ApiResult<String> {
561 match &self.cst {
562 | Some(cst) => Ok(cst.to_string()),
563 | None => serde_json::to_string_pretty(self).map_err(|why| eyre!("Failed to serialize JSON config — {why}")),
564 }
565 }
566 pub fn resolve(path: &Option<PathBuf>) -> Option<PathBuf> {
568 let directory = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
569 Self::resolve_in(path, &directory)
570 }
571 pub fn resolve_in(path: &Option<PathBuf>, directory: &Path) -> Option<PathBuf> {
573 path.as_ref().filter(|value| value.is_file()).cloned().or_else(|| {
574 DEFAULT_CONFIG_FILENAMES
575 .iter()
576 .map(|name| directory.join(name))
577 .find(|candidate| candidate.exists())
578 })
579 }
580 pub fn parse(content: impl AsRef<str>) -> ApiResult<Self> {
586 let trimmed = content.as_ref().trim();
587 if detect_json(trimmed) {
588 match Self::parse_json(trimmed) {
589 | Ok(value) => Ok(value),
590 | Err(json_errors) => match Self::parse_jsonc(trimmed) {
591 | Ok(value) => Ok(value),
592 | Err(_) => {
593 let details: Vec<String> = json_errors
594 .iter()
595 .map(|e| format!("{}: {}", e.path().map_or("root".into(), |p| p.to_string()), e.message()))
596 .collect();
597 Err(eyre!("{}", details.join("\n")))
598 }
599 },
600 }
601 } else if trimmed.starts_with('{') || trimmed.starts_with('[') {
602 match Self::parse_json(trimmed) {
603 | Ok(value) => Ok(value),
604 | Err(json_errors) => match Self::parse_yaml(trimmed) {
605 | Ok(value) => Ok(value),
606 | Err(why) => {
607 let details: Vec<String> = json_errors
608 .iter()
609 .map(|e| format!("{}: {}", e.path().map_or("root".into(), |p| p.to_string()), e.message()))
610 .collect();
611 Err(eyre!(
612 "Failed to parse ACORN configuration as JSON or YAML.\nJSON errors:\n{}\nYAML error: {why}",
613 details.join("\n")
614 ))
615 }
616 },
617 }
618 } else {
619 match Self::parse_yaml(trimmed) {
620 | Ok(value) => Ok(value),
621 | Err(why) => Err(eyre!("Failed to parse ACORN configuration YAML — {why}")),
622 }
623 }
624 }
625 fn parse_json(content: impl AsRef<str>) -> Result<Self, eserde::DeserializationErrors> {
626 eserde::json::from_str(content.as_ref())
627 }
628 fn parse_jsonc(content: impl AsRef<str>) -> ApiResult<Self> {
629 parse_jsonc_cst::<ApplicationConfiguration>(content.as_ref()).map(|(mut config, cst)| {
630 config.cst = Some(cst);
631 config
632 })
633 }
634 fn parse_yaml(content: impl AsRef<str>) -> serde_norway::Result<Self> {
635 serde_norway::from_str(content.as_ref())
636 }
637}
638impl InputOutput for ApplicationConfiguration {
639 fn read(path: impl Into<PathBuf>) -> ApiResult<Self> {
641 let source = path.into();
642 match source.file_name().and_then(|name| name.to_str()) {
643 | Some(".acorn") => Self::read_jsonc(source),
644 | _ => match MimeType::from_path(&source) {
645 | MimeType::Json => Self::read_json(source.clone()),
646 | MimeType::Jsonc => Self::read_jsonc(source.clone()),
647 | MimeType::Yaml => Self::read_yaml(source.clone()),
648 | _ => Err(eyre!("Unsupported configuration file extension")),
649 },
650 }
651 }
652 fn read_json(path: PathBuf) -> ApiResult<Self> {
654 let content = match read_file(path.clone()) {
655 | Ok(value) if !value.is_empty() => value,
656 | Ok(_) | Err(_) => {
657 error!(
658 path = path.to_string_lossy().to_string(),
659 "=> {} ACORN configuration JSON content",
660 Label::fail()
661 );
662 "{}".to_owned()
663 }
664 };
665 match Self::parse_json(content) {
666 | Ok(config) => Ok(config),
667 | Err(errors) => {
668 let details: Vec<String> = errors
669 .iter()
670 .map(|e| format!("{}: {}", e.path().map_or("root".into(), |p| p.to_string()), e.message()))
671 .collect();
672 Err(eyre!("{}", details.join("\n")))
673 }
674 }
675 }
676 fn read_jsonc(path: PathBuf) -> ApiResult<Self> {
680 let content = match read_file(path.clone()) {
681 | Ok(value) if !value.is_empty() => value,
682 | Ok(_) | Err(_) => {
683 error!(
684 path = path.to_string_lossy().to_string(),
685 "=> {} ACORN configuration JSONC content",
686 Label::fail()
687 );
688 "{}".to_owned()
689 }
690 };
691 Self::parse_jsonc(&content).map_err(|why| eyre!("Failed to read JSONC config `{}` — {}", path.display(), why))
692 }
693 fn read_yaml(path: PathBuf) -> ApiResult<Self> {
695 let content = match read_file(path.clone()) {
696 | Ok(value) => value,
697 | Err(_) => {
698 error!(
699 path = path.to_string_lossy().to_string(),
700 "=> {} ACORN configuration YAML content",
701 Label::fail()
702 );
703 "".to_owned()
704 }
705 };
706 Self::parse_yaml(content).map_err(|why| eyre!("Failed to parse YAML config — {why}"))
707 }
708 fn write(&self, path: impl Into<PathBuf>) -> ApiResult<()> {
712 let target = path.into();
713 match target.file_name().and_then(|name| name.to_str()) {
714 | Some(".acorn") => self.write_json(&target),
715 | _ => match MimeType::from_path(&target) {
716 | MimeType::Json | MimeType::Jsonc => self.write_json(&target),
717 | MimeType::Yaml => self.write_yaml(&target),
718 | _ => Err(eyre!("Unsupported configuration file extension")),
719 },
720 }
721 }
722 fn write_json(&self, path: impl Into<PathBuf>) -> ApiResult<()> {
726 let target = path.into();
727 match &self.cst {
728 | Some(cst) => write_file(target, cst.to_string()),
729 | None => serde_json::to_string_pretty(&self)
730 .map_err(|why| eyre!("Failed to serialize JSON config — {why}"))
731 .and_then(|content| write_file(target, content)),
732 }
733 }
734 fn write_yaml(&self, path: impl Into<PathBuf>) -> ApiResult<()> {
736 let target = path.into();
737 serde_norway::to_string(&self)
738 .map_err(|why| eyre!("Failed to serialize YAML config — {why}"))
739 .and_then(|content| write_file(target.clone(), content))
740 }
741}
742impl Bucket {
743 pub async fn archive(source: &str, format: Option<MimeType>, temporary: &TemporaryDirectory, offline: bool) -> ApiResult<(Self, String)> {
745 let local = uri_to_path(source);
746 let archive_path = match local.is_file() {
747 | true => Ok(local),
748 | false if offline => Err(eyre!("Offline mode cannot import remote archive: {source}")),
749 | false => {
750 let path = temporary.path().join("archive");
751 download_with_progress(source, &path, |_, _| {}, None, None, None, None)
752 .await
753 .map(|_| path)
754 }
755 };
756 archive_path
757 .and_then(|path| ArchiveCandidate::from(path).extract(Some(temporary.path().join("extracted")), format))
758 .and_then(|root| bag_root(&root))
759 .and_then(|root| Bag::verify(&root).map(|()| root.join("data")))
760 .map(|payload| (Self::from(payload.as_path()), source.to_string()))
761 }
762 pub async fn transfer(self, options: &BucketOptions, policy: TransferPolicy, provenance: Option<&str>) -> ApiResult<usize> {
764 let local = self.code_repository.is_local();
765 match (policy, local, options.offline) {
766 | (TransferPolicy::Download, true, _) => Err(eyre!("Bucket download requires a remote repository source")),
767 | (_, false, true) => Err(eyre!("Offline mode cannot transfer remote bucket sources")),
768 | _ => match local {
769 | true => self.copy_files(options).await,
770 | false => self.download_files(options).await,
771 }
772 .and_then(|manifest| {
773 let manifest = match provenance {
774 | Some(source) => TransferManifest {
775 repository: source.to_string(),
776 ..manifest
777 },
778 | None => manifest,
779 };
780 let count = manifest.count();
781 manifest
782 .ingest(options, &options.database_path, options.no_local_database)
783 .map(|()| count)
784 }),
785 }
786 }
787 pub async fn import(self, options: &BucketOptions, provenance: Option<&str>) -> ApiResult<usize> {
789 self.transfer(options, TransferPolicy::Import, provenance).await
790 }
791 pub(crate) fn domain(&self) -> ApiResult<String> {
793 let location = match &self.code_repository {
794 | Repository::GitHub { location } | Repository::GitLab { location, .. } => location,
795 | Repository::Git { .. } => return Err(eyre!("Domain is unsupported for generic Git repositories")),
796 | Repository::HuggingFace { .. } => return Err(eyre!("Domain is unsupported for Hugging Face repositories")),
797 };
798 match location.scheme() {
799 | Scheme::HTTPS => location.host().ok_or_else(|| eyre!("Failed to parse repository host from URI")),
800 | _ => Err(eyre!("Unsupported repository URI scheme")),
801 }
802 }
803 fn remove_destination(path: &Path) -> ApiResult<()> {
804 match path.symlink_metadata() {
805 | Ok(metadata) if metadata.file_type().is_symlink() || metadata.is_file() => {
806 prelude::remove_file(path).map_err(|why| eyre!("Failed to remove existing output path {} — {why}", path.display()))
807 }
808 | Ok(_) => prelude::remove_dir_all(path).map_err(|why| eyre!("Failed to remove existing output directory {} — {why}", path.display())),
809 | Err(why) if why.kind() == ErrorKind::NotFound => Ok(()),
810 | Err(why) => Err(eyre!("Failed to inspect existing output path {} — {why}", path.display())),
811 }
812 }
813 fn prepare_destination(output: &Path, destination: &Path) -> ApiResult<PathBuf> {
814 let target = output.join(destination);
815 destination
816 .parent()
817 .into_iter()
818 .flat_map(Path::ancestors)
819 .collect::<Vec<_>>()
820 .into_iter()
821 .rev()
822 .map(|parent| output.join(parent))
823 .try_for_each(|parent| match parent.symlink_metadata() {
824 | Ok(metadata) if metadata.is_dir() => Ok(()),
825 | Ok(_) => Self::remove_destination(&parent).and_then(|()| {
826 prelude::create_dir_all(&parent).map_err(|why| eyre!("Failed to create output directory {} — {why}", parent.display()))
827 }),
828 | Err(why) if why.kind() == ErrorKind::NotFound => {
829 prelude::create_dir_all(&parent).map_err(|why| eyre!("Failed to create output directory {} — {why}", parent.display()))
830 }
831 | Err(why) => Err(eyre!("Failed to inspect output directory {} — {why}", parent.display())),
832 })
833 .and_then(|()| Self::remove_destination(&target))
834 .map(|()| target)
835 }
836 async fn write_file<F, Fut, E>(output: &Path, destination: &Path, clobber: bool, write_lock: &Mutex<()>, get_bytes: F) -> ApiResult<()>
837 where
838 F: FnOnce() -> Fut,
839 Fut: Future<Output = Result<Vec<u8>, E>>,
840 E: Into<Report>,
841 {
842 match clobber {
843 | false => write_file_bytes(output.join(destination), get_bytes).await,
844 | true => match get_bytes().await.map_err(Into::into) {
845 | Ok(bytes) => match write_lock.lock() {
846 | Ok(_guard) => Self::prepare_destination(output, destination).and_then(|target| {
847 prelude::write(&target, bytes)
848 .map(|_| ())
849 .map_err(|why| eyre!("Failed to write output file {} — {why}", target.display()))
850 }),
851 | Err(why) => Err(eyre!("Failed to lock bucket output — {why}")),
852 },
853 | Err(why) => Err(why),
854 },
855 }
856 }
857 pub async fn copy_files(self: Bucket, options: &BucketOptions) -> ApiResult<TransferManifest> {
862 let BucketOptions { output, ignore, filter, .. } = options;
863 let output = Arc::new(output.clone().unwrap_or_default());
864 match FilterSet::compile(ignore, filter) {
865 | Ok(filters) => {
866 let Bucket { name, code_repository, .. } = self.clone();
867 match code_repository.is_local() {
868 | true => {
869 let bucket_root = match code_repository.location().local_path() {
870 | Some(value) => value.to_absolute_path(),
871 | None => {
872 return Err(eyre!(
873 "Bucket {} has no local path — cannot copy files",
874 name.as_deref().unwrap_or("unknown")
875 ))
876 }
877 };
878 let items = filter_paths(
879 files_all(PathBuf::from(&bucket_root), None::<Vec<String>>)
880 .into_iter()
881 .map(|x| x.display().to_string())
882 .collect::<Vec<String>>(),
883 &filters,
884 )
885 .into_iter()
886 .filter(|path| PathBuf::from(path).is_file())
887 .filter_map(|path| {
888 PathBuf::from(&path)
889 .strip_prefix(&bucket_root)
890 .ok()
891 .map(|relative| relative.display().to_string())
892 })
893 .collect::<Vec<String>>();
894 match TransferItem::collect(items, options.flatten) {
895 | Ok(items) => {
896 let bucket_root = Arc::new(bucket_root);
897 let clobber = options.clobber;
898 let write_lock = Arc::new(Mutex::new(()));
899 let operation = {
900 let bucket_root = Arc::clone(&bucket_root);
901 let output = Arc::clone(&output);
902 let write_lock = Arc::clone(&write_lock);
903 move |item: TransferItem| {
904 let bucket_root = Arc::clone(&bucket_root);
905 let output = Arc::clone(&output);
906 let write_lock = Arc::clone(&write_lock);
907 async move {
908 let source = PathBuf::from(bucket_root.as_str()).join(item.source);
909 Self::write_file(output.as_path(), &item.destination, clobber, write_lock.as_ref(), || async {
910 prelude::read(source)
911 })
912 .await
913 }
914 }
915 };
916 transfer_bucket_files(name, code_repository.location().to_string(), items, options, "Copying", operation).await
917 }
918 | Err(why) => Err(why),
919 }
920 }
921 | false => Ok(TransferManifest {
922 bucket: name,
923 repository: code_repository.location().to_string(),
924 files: Vec::new(),
925 }),
926 }
927 }
928 | Err(why) => Err(why),
929 }
930 }
931 pub async fn download_files(self: Bucket, options: &BucketOptions) -> ApiResult<TransferManifest> {
937 let BucketOptions { filter, ignore, .. } = options;
938 match FilterSet::compile(ignore, filter) {
939 | Ok(filters) => {
940 let name = self.name.clone();
941 let code_repository = self.code_repository.clone();
942 match self.file_paths("").await {
943 | Ok(paths) => match TransferItem::collect(filter_paths(paths, &filters), options.flatten) {
944 | Ok(items) => {
945 let repository = code_repository.location().to_string();
946 let clobber = options.clobber;
947 let write_lock = Arc::new(Mutex::new(()));
948 let operation = {
949 let code_repository = Arc::new(code_repository);
950 let output = Arc::new(options.output.clone().unwrap_or_default());
951 let write_lock = Arc::clone(&write_lock);
952 move |item: TransferItem| {
953 let output = Arc::clone(&output);
954 let repository = Arc::clone(&code_repository);
955 let write_lock = Arc::clone(&write_lock);
956 async move {
957 let source = item.source.display().to_string();
958 let bytes = match repository.as_ref() {
959 | Repository::GitLab { .. } => match (repository.domain(), repository.project_path()) {
960 | (Some(domain), Some(identifier)) => {
961 let options = gitlab::Options::from_env()
962 .with_domain(domain)
963 .with_identifier(identifier)
964 .with_path(source)
965 .with_sha("HEAD");
966 gitlab::repository_file(&options).await.and_then(|file| file.decoded_content())
967 }
968 | _ => Err(eyre!("Failed to build GitLab API request for repository path")),
969 },
970 | _ => match repository.raw_url(source) {
971 | Some(url) => Source::read_bytes(&url, false).await,
972 | None => Err(eyre!("Failed to build raw URL for repository path")),
973 },
974 };
975 Self::write_file(output.as_path(), &item.destination, clobber, write_lock.as_ref(), || async { bytes }).await
976 }
977 }
978 };
979 transfer_bucket_files(name, repository, items, options, "Downloading", operation).await
980 }
981 | Err(why) => Err(why),
982 },
983 | Err(why) => {
984 error!("=> {} Get file paths for download — {why}", Label::fail());
985 Err(why)
986 }
987 }
988 }
989 | Err(why) => Err(why),
990 }
991 }
992 async fn file_paths(&self, directory: &str) -> ApiResult<Vec<String>> {
993 let code_repository = self.code_repository.clone();
994 let bucket_name = self.name.clone().unwrap_or_else(|| "Bucket".to_string()).to_uppercase();
995 match &code_repository {
996 | Repository::Git { .. } => {
997 let path = match code_repository.location().local_path() {
998 | Some(value) => value,
999 | None => return Err(eyre!("Git repository has no local path — cannot list files")),
1000 };
1001 Ok(files_all(path, None::<Vec<String>>)
1002 .into_iter()
1003 .map(|x| x.display().to_string())
1004 .collect())
1005 }
1006 | Repository::GitHub { location } => match location.path() {
1007 | Some(path) => {
1008 let path = path.trim_start_matches('/').to_string();
1009 match self.domain() {
1010 | Ok(host) => github::tree_paths(format!("api.{}", host), path, "main")
1011 .await
1012 .map_err(|why| eyre!("Failed to get file paths for {bucket_name} bucket - {why}")),
1013 | Err(why) => Err(why),
1014 }
1015 }
1016 | None => Err(eyre!("Failed to parse GitHub URI for {bucket_name} bucket")),
1017 },
1018 | Repository::GitLab { .. } => match code_repository.id() {
1019 | Some(id) => match self.domain() {
1020 | Ok(host) => {
1021 let options = gitlab::Options::from_env().with_domain(host).with_identifier(id).with_path(directory);
1022 let mut page = 1_u32;
1023 let mut all_paths: Vec<String> = vec![];
1024 loop {
1025 let page_options = options.clone().with_page(page);
1026 match gitlab::tree_paths(&page_options).await {
1027 | Ok(response) if response.entry_count == 0 => {
1028 break Ok(all_paths.clone());
1029 }
1030 | Ok(response) => {
1031 all_paths.extend(response.paths);
1032 page = page.saturating_add(1);
1033 }
1034 | Err(why) => {
1035 break Err(eyre!("Failed to get file paths for {bucket_name} bucket — {why}"));
1036 }
1037 }
1038 }
1039 }
1040 | Err(why) => Err(why),
1041 },
1042 | None => Err(eyre!("Missing GitLab project id for {bucket_name} bucket")),
1043 },
1044 | Repository::HuggingFace { .. } => Err(eyre!("Hugging Face repositories are unsupported for bucket downloads")),
1045 }
1046 }
1047}
1048impl From<&Path> for Bucket {
1049 fn from(value: &Path) -> Self {
1050 let location = Location::Simple(format!("file:{}/", value.display()));
1051 Self::init().code_repository(Repository::Git { location }).build()
1052 }
1053}
1054impl From<&str> for Bucket {
1055 fn from(value: &str) -> Self {
1056 let location = Location::Simple(value.to_string());
1057 if location.uri().is_none() {
1058 exit(exitcode::DATAERR);
1059 }
1060 let repository = match location.is_local() {
1061 | true => Repository::Git { location },
1062 | false => {
1063 let host = match location.host() {
1064 | Some(value) => value.to_lowercase(),
1065 | None => {
1066 error!(value, "=> {} Parse URI - No host", Label::fail());
1067 exit(exitcode::DATAERR);
1068 }
1069 };
1070 if host.contains("github.com") {
1071 Repository::GitHub { location }
1072 } else {
1073 let id = None;
1074 Repository::GitLab { id, location }
1075 }
1076 }
1077 };
1078 Bucket::init().code_repository(repository).build()
1079 }
1080}
1081impl From<PathBuf> for Bucket {
1082 fn from(value: PathBuf) -> Self {
1083 Self::from(value.as_path())
1084 }
1085}
1086impl Default for BucketOptions {
1087 fn default() -> Self {
1088 Self {
1089 output: None,
1090 database_path: None,
1091 no_local_database: false,
1092 offline: false,
1093 threads: 10,
1094 quiet: false,
1095 ignore: Vec::new(),
1096 filter: Vec::new(),
1097 flatten: false,
1098 clobber: false,
1099 }
1100 }
1101}
1102impl Buckets {
1103 pub async fn transfer(self, options: &BucketOptions, policy: TransferPolicy) -> ApiResult<usize> {
1105 match options.output {
1106 | Some(_) => {
1107 stream::iter(self.0)
1108 .then(|bucket| bucket.transfer(options, policy, None))
1109 .try_fold(0_usize, |total, count| future::ready(Ok(total.saturating_add(count))))
1110 .await
1111 }
1112 | None => Ok(0),
1113 }
1114 }
1115}
1116impl From<Vec<Bucket>> for Buckets {
1117 fn from(value: Vec<Bucket>) -> Self {
1118 Self(value)
1119 }
1120}
1121impl FilterSet {
1122 pub fn compile(ignore: &[String], filter: &[String]) -> ApiResult<Self> {
1124 let compile = |patterns: &[String]| {
1125 patterns
1126 .iter()
1127 .map(|pattern| Regex::new(pattern).map_err(|why| eyre!("Invalid regex/filter pattern '{pattern}': {why}")))
1128 .collect::<ApiResult<Vec<Regex>>>()
1129 };
1130 compile(ignore).and_then(|ignore| compile(filter).map(|filter| Self { ignore, filter }))
1131 }
1132 pub fn filter<T>(
1134 items: Vec<T>,
1135 filter: &[String],
1136 ignore: &[String],
1137 value: impl Fn(&T) -> String,
1138 keep: impl Fn(&T) -> bool,
1139 ) -> ApiResult<Vec<T>> {
1140 match FilterSet::compile(ignore, filter) {
1141 | Ok(filters) => Ok(items.into_iter().filter(|item| filters.matches(&value(item)) && keep(item)).collect()),
1142 | Err(why) => Err(why),
1143 }
1144 }
1145 pub fn matches(&self, value: &str) -> bool {
1147 let ignored = self.ignore.iter().any(|pattern| pattern.is_match(value).unwrap_or(false));
1148 let filtered = self.filter.is_empty() || self.filter.iter().any(|pattern| pattern.is_match(value).unwrap_or(false));
1149 !ignored && filtered
1150 }
1151}
1152impl ModelEntry {
1153 pub fn requests(entries: &[Self]) -> ApiResult<Vec<sync::ModelRequest>> {
1155 entries
1156 .iter()
1157 .map(sync::ModelRequest::try_from)
1158 .try_fold((HashSet::new(), Vec::new()), |(mut identifiers, mut requests), request| {
1159 request.and_then(|request| match identifiers.insert(request.id().to_string()) {
1160 | true => {
1161 requests.push(request);
1162 Ok((identifiers, requests))
1163 }
1164 | false => Err(eyre!("Duplicate generated model ID '{}'", request.id())),
1165 })
1166 })
1167 .map(|(_, requests)| requests)
1168 }
1169 pub fn resolve(entries: &[Self], options: &sync::ModelRequestOptions<'_>) -> ApiResult<Vec<ModelDetails>> {
1171 Self::resolve_using(entries, options, false, |_| Vec::new())
1172 }
1173 pub fn resolve_with_fallbacks(
1175 entries: &[Self],
1176 options: &sync::ModelRequestOptions<'_>,
1177 database_path: Option<PathBuf>,
1178 ) -> ApiResult<Vec<ModelDetails>> {
1179 Self::resolve_using(entries, options, true, |model_id| {
1180 Self::fallback_repositories(model_id, database_path.as_ref())
1181 })
1182 }
1183 fn resolve_using(
1184 entries: &[Self],
1185 options: &sync::ModelRequestOptions<'_>,
1186 fallbacks_enabled: bool,
1187 fallback: impl Fn(&str) -> Vec<String>,
1188 ) -> ApiResult<Vec<ModelDetails>> {
1189 Self::requests(entries).map(|requests| {
1190 requests
1191 .into_iter()
1192 .filter_map(|request| {
1193 let id = request.id().to_string();
1194 let request_options = sync::ModelRequestOptions {
1195 fallbacks: fallback(&id),
1196 ..options.clone()
1197 };
1198 match request.resolve(&request_options) {
1199 | Ok(model) => Some(model),
1200 | Err(why) => {
1201 let reason = Self::resolution_failure_reason(&why, fallbacks_enabled, &request_options.fallbacks);
1202 warn!("=> {} Could not resolve {} {}", Label::skip(), id.yellow(), reason.dimmed());
1203 None
1204 }
1205 }
1206 })
1207 .collect()
1208 })
1209 }
1210 fn resolution_failure_reason(why: &impl fmt::Display, fallbacks_enabled: bool, fallbacks: &[String]) -> String {
1211 match (fallbacks_enabled, fallbacks.is_empty()) {
1212 | (true, true) => format!("({why}; no fallback repositories found in the local model database)"),
1213 | _ => format!("({why})"),
1214 }
1215 }
1216 fn fallback_repositories(model_id: &str, database_path: Option<&PathBuf>) -> Vec<String> {
1217 resolve_database_path(database_path)
1218 .ok()
1219 .filter(|path| path.is_file())
1220 .and_then(|path| {
1221 ModelRow::init()
1222 .model_id(model_id.to_string())
1223 .build()
1224 .select(Some(path), |row| row.model_id.as_deref() == Some(model_id))
1225 .ok()
1226 .flatten()
1227 })
1228 .and_then(|row| row.parsed_weights())
1229 .map(|weights| weights.groups().0.into_iter().map(|group| group.repository).unique().collect())
1230 .unwrap_or_default()
1231 }
1232}
1233impl RunnerDetails {
1234 pub fn with_id(self, value: u64) -> Self {
1236 Self {
1237 identifier: Some(value),
1238 ..self
1239 }
1240 }
1241 pub fn with_name(self, value: String) -> Self {
1243 Self { name: Some(value), ..self }
1244 }
1245 pub fn with_token(self, value: Option<String>) -> Self {
1247 Self { token: value, ..self }
1248 }
1249}
1250impl fmt::Display for RunnerType {
1251 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1252 let value = match self {
1253 | RunnerType::Group => "group",
1254 | RunnerType::Instance => "instance",
1255 | RunnerType::Project => "project",
1256 };
1257 formatter.write_str(value)
1258 }
1259}
1260impl From<&str> for RunnerType {
1261 fn from(value: &str) -> Self {
1262 match value.to_uppercase().as_str() {
1263 | "INSTANCE" => RunnerType::Instance,
1264 | "PROJECT" => RunnerType::Project,
1265 | _ => RunnerType::Group,
1266 }
1267 }
1268}
1269impl From<String> for RunnerType {
1270 fn from(value: String) -> Self {
1271 Self::from(value.as_str())
1272 }
1273}
1274impl From<&Path> for TransferItem {
1275 fn from(source: &Path) -> Self {
1276 Self {
1277 source: source.to_path_buf(),
1278 destination: source.to_path_buf(),
1279 }
1280 }
1281}
1282impl TransferItem {
1283 fn collect(paths: Vec<String>, flatten: bool) -> ApiResult<Vec<Self>> {
1284 let items = paths
1285 .into_iter()
1286 .map(PathBuf::from)
1287 .map(|path| Self::from(path.as_path()))
1288 .map(|item| match flatten {
1289 | true => item.flatten(),
1290 | false => Ok(item),
1291 })
1292 .collect::<ApiResult<Vec<_>>>();
1293 match items {
1294 | Ok(items) => items
1295 .iter()
1296 .try_fold(HashMap::<PathBuf, PathBuf>::new(), |mut destinations, item| {
1297 let destination = &item.destination;
1298 let safe = !destination.as_os_str().is_empty() && destination.components().all(|part| matches!(part, Component::Normal(_)));
1299 match safe {
1300 | false => Err(eyre!("Output path is unsafe — '{}'", item.destination.display())),
1301 | true => match destinations.insert(item.destination.clone(), item.source.clone()) {
1302 | Some(source) => Err(eyre!(
1303 "Output path collision for '{}' — '{}' and '{}'",
1304 item.destination.display(),
1305 source.display(),
1306 item.source.display()
1307 )),
1308 | None => Ok(destinations),
1309 },
1310 }
1311 })
1312 .map(|_| items),
1313 | Err(why) => Err(why),
1314 }
1315 }
1316 fn flatten(self) -> ApiResult<Self> {
1317 match self.source.file_name().map(PathBuf::from) {
1318 | Some(destination) => Ok(Self { destination, ..self }),
1319 | None => Err(eyre!("Cannot flatten repository path without a filename — {}", self.source.display())),
1320 }
1321 }
1322}
1323impl TransferManifest {
1324 pub fn count(&self) -> usize {
1326 let paths = self.files.iter().map(|path| path.display().to_string()).collect::<Vec<_>>();
1327 count_json_files(&paths).saturating_add(count_image_files(&paths))
1328 }
1329 pub fn ingest(&self, options: &BucketOptions, database_path: &Option<PathBuf>, no_local_database: bool) -> ApiResult<()> {
1331 match no_local_database {
1332 | true => Ok(()),
1333 | false => {
1334 let output = options.output.clone().unwrap_or_default();
1335 let database = Database::<Table>::from_path(database_path.clone());
1336 self.files
1337 .iter()
1338 .filter(is_filetype(SUPPORTED_RAD_FILETYPES))
1339 .filter(|relative| {
1340 let path = output.join(relative);
1341 MimeType::from_path(&path) != MimeType::Markdown || ResearchActivity::is_markdown(path.as_path())
1342 })
1343 .try_fold((), |(), relative| {
1344 let path = output.join(relative);
1345 ResearchActivity::read(path.clone())
1346 .and_then(|rad| {
1347 serde_json::to_value(&rad)
1348 .map_err(Report::from)
1349 .and_then(|rad_json| database.create_or_enrich(self.candidate(&rad, rad_json, relative)).map(|_| ()))
1350 })
1351 .map_err(|why| eyre!("Failed to ingest transferred RAD {} — {why}", path.display()))
1352 })
1353 }
1354 }
1355 }
1356 fn candidate(&self, rad: &ResearchActivity, rad_json: serde_json::Value, relative: &Path) -> ResearchActivityCandidate {
1357 let pairs = [
1358 (PID::DOI, rad.meta.doi.as_ref()),
1359 (PID::Handle, rad.meta.handle.as_ref()),
1360 (PID::ISBN, rad.meta.books.as_ref()),
1361 (PID::Patent, rad.meta.patents.as_ref()),
1362 (PID::RAID, rad.meta.raid.as_ref()),
1363 (PID::SWHID, rad.meta.swhid.as_ref()),
1364 ];
1365 let pid_keys = pairs.into_iter().flat_map(|(kind, values)| {
1366 values.into_iter().flatten().filter_map(move |value| {
1367 Identifier::init()
1368 .kind(kind.clone())
1369 .value(value)
1370 .build()
1371 .normalized()
1372 .map(|identifier| format!("{}:{}", identifier.kind.as_str(), identifier.value))
1373 })
1374 });
1375 let rad_key = format!("rad:{}:{}", self.repository, rad.meta.identifier);
1376 let prov = Provenance::Bucket {
1377 bucket: self.bucket.clone(),
1378 repository: self.repository.clone(),
1379 relative_path: relative.display().to_string(),
1380 observed_at: Timestamp::now().to_string(),
1381 };
1382 ResearchActivityCandidate::new(
1383 rad_json,
1384 pid_keys.chain(once(rad_key)).collect(),
1385 vec![serde_json::to_value(prov).unwrap_or_default()],
1386 )
1387 }
1388}
1389fn bag_root(root: &Path) -> ApiResult<PathBuf> {
1390 match root.join("bagit.txt").is_file() {
1391 | true => Ok(root.to_path_buf()),
1392 | false => root
1393 .read_dir()
1394 .map_err(Into::into)
1395 .map(|entries| {
1396 entries
1397 .filter_map(Result::ok)
1398 .filter_map(|entry| entry.file_type().ok().filter(|kind| kind.is_dir()).map(|_| entry.path()))
1399 .collect::<Vec<_>>()
1400 })
1401 .and_then(|directories| match directories.as_slice() {
1402 | [directory] if directory.join("bagit.txt").is_file() => Ok(directory.clone()),
1403 | _ => Err(eyre!("BagIt metadata must be at the archive root or beneath one enclosing directory")),
1404 }),
1405 }
1406}
1407fn count_image_files(paths: &[String]) -> usize {
1408 paths.iter().filter(|&x| has_image_extension(x)).count()
1409}
1410fn count_json_files(paths: &[String]) -> usize {
1411 paths.iter().filter(|&path| path.to_lowercase().ends_with(".json")).count()
1412}
1413fn default_docker_image() -> String {
1414 "gitlab/gitlab-runner:latest".to_string()
1415}
1416fn default_executor() -> Executor {
1417 Executor::Docker
1418}
1419fn filter_paths(paths: Vec<String>, filters: &FilterSet) -> Vec<String> {
1420 paths
1421 .into_iter()
1422 .filter(|path| !is_ignored_path(path, &filters.ignore) && is_filtered_path(path, &filters.filter))
1423 .collect()
1424}
1425#[allow(clippy::ptr_arg)]
1426fn has_image_extension(path: &String) -> bool {
1427 path.to_lowercase().ends_with(".png") || path.to_lowercase().ends_with(".jpg")
1428}
1429fn is_filtered_path(path: &str, filter: &[Regex]) -> bool {
1430 filter.is_empty() || filter.iter().any(|pattern| pattern.is_match(path).unwrap_or(false))
1431}
1432fn is_ignored_path(path: &str, ignore: &[Regex]) -> bool {
1433 let is_builtin_ignored = IGNORE.iter().any(|value| path.ends_with(value));
1434 let is_regex_ignored = ignore.iter().any(|pattern| pattern.is_match(path).unwrap_or(false));
1435 is_builtin_ignored || is_regex_ignored
1436}
1437fn operations_complete_message(name: Option<String>, json_count: usize, image_count: usize) -> String {
1438 let total = json_count.saturating_add(image_count);
1439 let message = if json_count != image_count {
1440 let recommendation = if json_count > image_count {
1441 "Do you need to add some images?"
1442 } else {
1443 "Do you need to add some JSON files?"
1444 };
1445 format!(
1446 " ({} data file{}, {} image{} - {})",
1447 json_count.yellow(),
1448 suffix(json_count),
1449 image_count.yellow(),
1450 suffix(image_count),
1451 recommendation.italic(),
1452 )
1453 } else {
1454 "".to_string()
1455 };
1456 let bucket_description = match name {
1457 | Some(value) => format!("{} bucket", value.to_uppercase().cyan()),
1458 | None => "<URL>".cyan().to_string(),
1459 };
1460 format!(
1461 "{}Obtained {} file{} from {bucket_description}{}",
1462 if total > 0 { Label::CHECKMARK } else { Label::CAUTION },
1463 if total > 0 {
1464 total.green().to_string()
1465 } else {
1466 total.yellow().to_string()
1467 },
1468 suffix(total),
1469 message,
1470 )
1471}
1472async fn transfer_bucket_files<F, Fut>(
1473 name: Option<String>,
1474 repository: String,
1475 items: Vec<TransferItem>,
1476 options: &BucketOptions,
1477 verb: &'static str,
1478 operation: F,
1479) -> ApiResult<TransferManifest>
1480where
1481 F: Fn(TransferItem) -> Fut,
1482 Fut: Future<Output = ApiResult<()>>,
1483{
1484 let BucketOptions { threads, quiet, .. } = options;
1485 let source_paths = items.iter().map(|item| item.source.display().to_string()).collect::<Vec<_>>();
1486 let total_data = count_json_files(&source_paths);
1487 let total_images = count_image_files(&source_paths);
1488 let message = move |item: &TransferItem| format!("{verb} {}", item.source.display());
1489 let finish_name = name.clone();
1490 let finish_message = |_| operations_complete_message(finish_name, total_data, total_images);
1491 let progress_type = match quiet {
1492 | true => ProgressType::Silent,
1493 | false => ProgressType::Bar,
1494 };
1495 let files = items.iter().map(|item| item.destination.clone()).collect::<Vec<_>>();
1496 with_progress(items, message, operation, finish_message, Some(*threads), progress_type)
1497 .await
1498 .map(|_| TransferManifest {
1499 bucket: name,
1500 repository,
1501 files,
1502 })
1503}
1504#[cfg(test)]
1505mod tests {
1506 #![allow(
1507 clippy::unwrap_used,
1508 clippy::expect_used,
1509 clippy::panic,
1510 clippy::indexing_slicing,
1511 clippy::arithmetic_side_effects
1512 )]
1513 use super::*;
1514 use crate::prelude::{create_dir_all, read_to_string, remove_dir_all, write};
1515
1516 fn temp_resolve_dir(name: &str) -> PathBuf {
1517 let nanos = std::time::SystemTime::now()
1518 .duration_since(std::time::UNIX_EPOCH)
1519 .unwrap_or(core::time::Duration::from_nanos(0))
1520 .as_nanos();
1521 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1522 .join("../..")
1523 .join("target")
1524 .join("test_artifacts")
1525 .join(format!("{name}-{nanos}"))
1526 }
1527
1528 #[tokio::test]
1529 async fn test_clobber_preserves_destination_when_source_read_fails() {
1530 let output = temp_resolve_dir("clobber-source-failure-output");
1531 create_dir_all(&output).unwrap();
1532 write(output.join("index.json"), "old").unwrap();
1533 let write_lock = Mutex::new(());
1534 let result = Bucket::write_file(&output, Path::new("index.json"), true, &write_lock, || async {
1535 Err::<Vec<u8>, Report>(eyre!("source failure"))
1536 })
1537 .await;
1538 assert!(result.is_err());
1539 assert_eq!(read_to_string(output.join("index.json")).unwrap(), "old");
1540 let _ = remove_dir_all(output);
1541 }
1542 #[tokio::test]
1543 async fn test_copy_files_clobbers_selected_path_conflicts_only() {
1544 let source = temp_resolve_dir("clobber-enabled-source");
1545 let output = temp_resolve_dir("clobber-enabled-output");
1546 create_dir_all(source.join("nested")).unwrap();
1547 create_dir_all(output.join("directory.json")).unwrap();
1548 write(source.join("existing.json"), "new file").unwrap();
1549 write(source.join("directory.json"), "new directory replacement").unwrap();
1550 write(source.join("nested/index.json"), "new nested file").unwrap();
1551 write(source.join("nested/other.json"), "new sibling file").unwrap();
1552 write(output.join("existing.json"), "old file").unwrap();
1553 write(output.join("directory.json/old.json"), "old directory content").unwrap();
1554 write(output.join("nested"), "old parent file").unwrap();
1555 write(output.join("unrelated.json"), "keep").unwrap();
1556 let bucket = Bucket::init()
1557 .code_repository(Repository::Git {
1558 location: Location::Simple(format!("file:{}", source.display())),
1559 })
1560 .build();
1561 let options = BucketOptions::init().output(output.clone()).quiet(true).clobber(true).build();
1562 let result = bucket.copy_files(&options).await;
1563 assert!(result.is_ok());
1564 assert_eq!(read_to_string(output.join("existing.json")).unwrap(), "new file");
1565 assert_eq!(read_to_string(output.join("directory.json")).unwrap(), "new directory replacement");
1566 assert_eq!(read_to_string(output.join("nested/index.json")).unwrap(), "new nested file");
1567 assert_eq!(read_to_string(output.join("nested/other.json")).unwrap(), "new sibling file");
1568 assert_eq!(read_to_string(output.join("unrelated.json")).unwrap(), "keep");
1569 let _ = remove_dir_all(source);
1570 let _ = remove_dir_all(output);
1571 }
1572 #[tokio::test]
1573 async fn test_copy_files_flattens_destinations_and_manifest() {
1574 let source = temp_resolve_dir("flatten-source");
1575 let output = temp_resolve_dir("flatten-output");
1576 create_dir_all(source.join("docs/quest")).unwrap();
1577 write(source.join("docs/quest/index.json"), "{}").unwrap();
1578 write(source.join("docs/quest/image.png"), "image").unwrap();
1579 let bucket = Bucket::init()
1580 .code_repository(Repository::Git {
1581 location: Location::Simple(format!("file:{}", source.display())),
1582 })
1583 .build();
1584 let options = BucketOptions::init().output(output.clone()).quiet(true).flatten(true).build();
1585 let manifest = bucket.copy_files(&options).await.unwrap();
1586 assert_eq!(manifest.files.len(), 2);
1587 assert!(manifest.files.contains(&PathBuf::from("index.json")));
1588 assert!(manifest.files.contains(&PathBuf::from("image.png")));
1589 assert!(output.join("index.json").is_file());
1590 assert!(output.join("image.png").is_file());
1591 assert!(!output.join("docs").exists());
1592 let _ = remove_dir_all(source);
1593 let _ = remove_dir_all(output);
1594 }
1595 #[tokio::test]
1596 async fn test_copy_files_preserves_existing_destination_without_clobber() {
1597 let source = temp_resolve_dir("clobber-disabled-source");
1598 let output = temp_resolve_dir("clobber-disabled-output");
1599 create_dir_all(&source).unwrap();
1600 create_dir_all(&output).unwrap();
1601 write(source.join("index.json"), "new").unwrap();
1602 write(output.join("index.json"), "old").unwrap();
1603 let bucket = Bucket::init()
1604 .code_repository(Repository::Git {
1605 location: Location::Simple(format!("file:{}", source.display())),
1606 })
1607 .build();
1608 let options = BucketOptions::init().output(output.clone()).quiet(true).build();
1609 assert!(bucket.copy_files(&options).await.is_err());
1610 assert_eq!(read_to_string(output.join("index.json")).unwrap(), "old");
1611 let _ = remove_dir_all(source);
1612 let _ = remove_dir_all(output);
1613 }
1614 #[test]
1615 fn test_count_image_files_counts_supported_extensions() {
1616 let paths = vec![
1617 "content/plot.png".to_string(),
1618 "content/photo.jpg".to_string(),
1619 "content/photo.jpeg".to_string(),
1620 "content/index.json".to_string(),
1621 ];
1622 assert_eq!(count_image_files(&paths), 2);
1623 }
1624 #[test]
1625 fn test_count_json_files_counts_case_insensitive_json_paths() {
1626 let paths = vec![
1627 "content/index.json".to_string(),
1628 "content/README.md".to_string(),
1629 "content/data.JSON".to_string(),
1630 ];
1631 assert_eq!(count_json_files(&paths), 2);
1632 }
1633 #[test]
1634 fn test_extensionless_config_is_last_and_read_as_jsonc() {
1635 assert_eq!(DEFAULT_CONFIG_FILENAMES.last(), Some(&".acorn"));
1636 let directory = temp_resolve_dir("extensionless-jsonc");
1637 create_dir_all(&directory).unwrap();
1638 let directory = directory.canonicalize().unwrap();
1639 let extensionless = directory.join(".acorn");
1640 write(&extensionless, "{\n // Comment\n}\n").unwrap();
1641 let config = ApplicationConfiguration::read(extensionless.clone()).unwrap();
1642 assert!(config.write(extensionless).is_ok());
1643 let _ = remove_dir_all(directory);
1644 }
1645 #[test]
1646 fn test_has_image_extension_matches_png_and_jpg() {
1647 assert!(has_image_extension(&"image.png".to_string()));
1648 assert!(has_image_extension(&"photo.JPG".to_string()));
1649 assert!(!has_image_extension(&"graphic.jpeg".to_string()));
1650 }
1651 #[test]
1652 fn test_is_filtered_path() {
1653 let filter = FilterSet::compile(&[], &[r"\.json$".to_string(), r"img/".to_string()]).unwrap().filter;
1654 assert!(is_filtered_path("/tmp/data.json", &filter));
1655 assert!(is_filtered_path("/tmp/img/photo.jpg", &filter));
1656 assert!(!is_filtered_path("/tmp/README.md", &filter));
1657 let invalid = FilterSet::compile(&[], &["[".to_string()]);
1658 assert!(invalid.is_err());
1659 let filter: Vec<Regex> = vec![];
1660 assert!(is_filtered_path("/tmp/README.md", &filter));
1661 }
1662 #[test]
1663 fn test_is_ignored_path() {
1664 let ignore = FilterSet::compile(&[r"\.jpeg$".to_string(), r"notes\.txt$".to_string()], &[])
1665 .unwrap()
1666 .ignore;
1667 assert!(is_ignored_path("/tmp/photo.jpeg", &ignore));
1668 assert!(is_ignored_path("/tmp/notes.txt", &ignore));
1669 assert!(!is_ignored_path("/tmp/index.json", &ignore));
1670 let invalid = FilterSet::compile(&["[".to_string()], &[]);
1671 assert!(invalid.is_err());
1672 let ignore: Vec<Regex> = vec![];
1673 assert!(is_ignored_path("/tmp/README.md", &ignore));
1674 }
1675 #[test]
1676 fn test_load_rejects_missing_explicit_path() {
1677 let missing = temp_resolve_dir("load-missing").join("missing.json");
1678 let result = ApplicationConfiguration::load(&Some(missing.clone()));
1679 assert!(result.is_err());
1680 assert_eq!(
1681 result.unwrap_err().to_string(),
1682 format!("Configuration file does not exist — {}", missing.display())
1683 );
1684 }
1685 #[test]
1686 fn test_model_update_preserves_jsonc_comments_and_dry_run() {
1687 let directory = temp_resolve_dir("sync-acorn-config");
1688 create_dir_all(&directory).unwrap();
1689 let path = directory.join("config.jsonc");
1690 let before = "{\n // Keep this comment\n \"models\": [\"acme/existing\"]\n}\n";
1691 write(&path, before).unwrap();
1692 let configuration = ApplicationConfiguration::read(path.clone()).unwrap();
1693 let entries = vec![ModelEntry::Selector("acme/added".to_string())];
1694 let updated = configuration.with_models(&entries).unwrap();
1695 updated.write_or_preview(&path, true, true).unwrap();
1696 assert_eq!(read_file(&path).unwrap(), before);
1697 updated.write_or_preview(&path, false, true).unwrap();
1698 let content = read_file(&path).unwrap();
1699 assert_eq!(
1700 content,
1701 "{\n // Keep this comment\n \"models\": [\n \"acme/existing\",\n \"acme/added\"\n ]\n}\n"
1702 );
1703 let _ = remove_dir_all(directory);
1704 }
1705 #[test]
1706 fn test_operations_complete_message_includes_bucket_name_and_guidance() {
1707 let message = operations_complete_message(Some("acorn".to_string()), 2, 1);
1708 assert!(message.contains("Obtained"));
1709 assert!(message.contains("ACORN"));
1710 assert!(message.contains(" bucket"));
1711 assert!(message.contains("data file"));
1712 assert!(message.contains("image"));
1713 assert!(message.contains("Do you need to add some images?"));
1714 }
1715 #[test]
1716 fn test_operations_complete_message_uses_url_placeholder_without_name() {
1717 let message = operations_complete_message(None, 0, 0);
1718 assert!(message.contains("Obtained"));
1719 assert!(message.contains("<URL>"));
1720 }
1721 #[test]
1722 fn test_parse_supports_yaml_flow_mapping_when_json_detection_fails() {
1723 let content = "{endpoints: []}";
1724 let result = ApplicationConfiguration::parse(content);
1725 assert!(result.is_ok());
1726 }
1727 #[cfg(unix)]
1728 #[test]
1729 fn test_prepare_destination_replaces_parent_symlink_without_following_it() {
1730 let output = temp_resolve_dir("clobber-symlink-output");
1731 let external = temp_resolve_dir("clobber-symlink-external");
1732 create_dir_all(&output).unwrap();
1733 create_dir_all(&external).unwrap();
1734 write(external.join("index.json"), "outside").unwrap();
1735 crate::prelude::symlink(&external, output.join("linked")).unwrap();
1736 let target = Bucket::prepare_destination(&output, Path::new("linked/index.json")).unwrap();
1737 assert_eq!(target, output.join("linked/index.json"));
1738 assert!(output.join("linked").is_dir());
1739 assert!(!output.join("linked").symlink_metadata().unwrap().file_type().is_symlink());
1740 assert_eq!(read_to_string(external.join("index.json")).unwrap(), "outside");
1741 let _ = remove_dir_all(output);
1742 let _ = remove_dir_all(external);
1743 }
1744 #[test]
1745 fn test_resolution_failure_reason_reports_fallback_lookup_status() {
1746 assert_eq!(
1747 ModelEntry::resolution_failure_reason(&"missing", true, &[]),
1748 "(missing; no fallback repositories found in the local model database)"
1749 );
1750 assert_eq!(ModelEntry::resolution_failure_reason(&"missing", false, &[]), "(missing)");
1751 assert_eq!(
1752 ModelEntry::resolution_failure_reason(&"missing", true, &["fallback/model".to_string()]),
1753 "(missing)"
1754 );
1755 }
1756
1757 #[test]
1758 fn test_resolve_falls_back_to_default_when_provided_path_missing() {
1759 let directory = temp_resolve_dir("resolve-fallback");
1760 create_dir_all(&directory).unwrap();
1761 let directory = directory.canonicalize().unwrap();
1762 let default = directory.join(".acorn.yml");
1763 write(&default, "{}\n").unwrap();
1764 let resolved = ApplicationConfiguration::resolve_in(&Some(directory.join("missing.json")), &directory);
1765 assert_eq!(resolved, Some(default));
1766 let _ = remove_dir_all(directory);
1767 }
1768 #[test]
1769 fn test_resolve_returns_explicit_existing_path() {
1770 let directory = temp_resolve_dir("resolve-explicit");
1771 create_dir_all(&directory).unwrap();
1772 let directory = directory.canonicalize().unwrap();
1773 let provided = directory.join("config.yaml");
1774 let default = directory.join(".acorn.json");
1775 write(&provided, "{}\n").unwrap();
1776 write(&default, "{}\n").unwrap();
1777 let resolved = ApplicationConfiguration::resolve(&Some(provided.clone()));
1778 assert_eq!(resolved, Some(provided));
1779 let _ = remove_dir_all(directory);
1780 }
1781 #[test]
1782 fn test_transfer_item_collect_preserves_or_flattens_paths() {
1783 let paths = vec!["docs/quest/index.json".to_string(), "docs/quest/image.png".to_string()];
1784 let preserved = TransferItem::collect(paths.clone(), false).unwrap();
1785 let flattened = TransferItem::collect(paths, true).unwrap();
1786 assert_eq!(
1787 preserved.iter().map(|item| item.destination.clone()).collect::<Vec<_>>(),
1788 vec![PathBuf::from("docs/quest/index.json"), PathBuf::from("docs/quest/image.png")]
1789 );
1790 assert_eq!(
1791 flattened.iter().map(|item| item.destination.clone()).collect::<Vec<_>>(),
1792 vec![PathBuf::from("index.json"), PathBuf::from("image.png")]
1793 );
1794 }
1795 #[test]
1796 fn test_transfer_item_collect_rejects_flattened_filename_collisions() {
1797 let result = TransferItem::collect(vec!["one/index.json".to_string(), "two/index.json".to_string()], true);
1798 let message = result.unwrap_err().to_string();
1799 assert!(message.contains("index.json"));
1800 assert!(message.contains("one/index.json"));
1801 assert!(message.contains("two/index.json"));
1802 }
1803 #[test]
1804 fn test_transfer_item_collect_rejects_unsafe_destination() {
1805 let result = TransferItem::collect(vec!["../outside.json".to_string()], false);
1806 assert!(result.unwrap_err().to_string().contains("unsafe"));
1807 }
1808 #[test]
1809 fn test_with_models_keeps_unique_identifiers() {
1810 let configuration = ApplicationConfiguration::parse(r#"{"models":["acme/existing","acme/existing"]}"#).unwrap();
1811 let entries = vec![
1812 ModelEntry::Selector("acme/existing".to_string()),
1813 ModelEntry::Selector("acme/added".to_string()),
1814 ModelEntry::Selector("acme/added".to_string()),
1815 ];
1816 let updated = configuration.with_models(&entries).unwrap();
1817 let identifiers = updated
1818 .models
1819 .unwrap_or_default()
1820 .into_iter()
1821 .filter_map(|entry| match entry {
1822 | ModelEntry::Selector(identifier) => Some(identifier),
1823 | ModelEntry::Entry(_) => None,
1824 })
1825 .collect::<Vec<_>>();
1826 assert_eq!(identifiers, vec!["acme/existing", "acme/added"]);
1827 }
1828}