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