Skip to main content

acorn/io/
config.rs

1//! Application configuration functions and data structures
2//!
3//! ACORN is configured by a JSON, JSONC, or YAML file (typically named `.acorn.json`)
4//!
5//! The ACORN configuration file configures what buckets should be downloaded, readability analysis, <span title="Large Language Model">LLM</span> settings, and more.
6//!
7use 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/// Authentication requirement for a configured model download source
41#[derive(Clone, Debug, Default, Display, Eq, PartialEq, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum AuthenticationRequirement {
44    /// Authentication is not expected for this model source
45    None,
46    /// Authentication may be used when a token is configured
47    #[default]
48    Optional,
49    /// Authentication is required for this model source
50    Required,
51}
52/// Model config entry — either a bare selector string or a detailed download entry
53/// ### Notes
54/// Models can be specified in several ways in the `models` array:
55///
56/// **Bare selector string** — a model ID, path, or URL:
57/// ```json
58/// {
59///     "models": [
60///         "meta-llama/Llama-2-7b-hf",
61///         "microsoft/phi-2"
62///     ]
63/// }
64/// ```
65///
66/// **Detailed entry with Hugging Face source** (simple location string):
67/// ```json
68/// {
69///     "models": [
70///         {
71///             "name": "tiny-bert",
72///             "source": {
73///                 "provider": "huggingface",
74///                 "location": "https://huggingface.co/hf-internal-testing/tiny-random-bert"
75///             }
76///         }
77///     ]
78/// }
79/// ```
80///
81/// **Detailed entry with revision and auth**:
82/// ```json
83/// {
84///     "models": [
85///         {
86///             "name": "tiny-bert",
87///             "source": {
88///                 "provider": "huggingface",
89///                 "location": "https://huggingface.co/hf-internal-testing/tiny-random-bert"
90///             },
91///             "revision": "refs/pr/1",
92///             "auth": "required",
93///             "filter": ["Q4_K_M.*\\.gguf$"],
94///             "ignore": ["Q2_", "Q3_"]
95///         }
96///     ]
97/// }
98/// ```
99///
100/// **Detailed entry with Hugging Face source** (detailed location with scheme and revision):
101/// ```json
102/// {
103///     "models": [
104///         {
105///             "name": "tiny-bert",
106///             "source": {
107///                 "provider": "huggingface",
108///                 "location": {
109///                     "scheme": "https",
110///                     "uri": "https://huggingface.co/hf-internal-testing/tiny-random-bert",
111///                     "revision": "main"
112///                 }
113///             }
114///         }
115///     ]
116/// }
117/// ```
118///
119/// **Detailed entry with local Git repository source**:
120/// ```json
121/// {
122///     "models": [
123///         {
124///             "name": "qwen-local",
125///             "source": {
126///                 "provider": "git",
127///                 "location": "file:./models/qwen.gguf"
128///             }
129///         }
130///     ]
131/// }
132/// ```
133///
134/// **Mixed array** — bare strings and detailed entries together:
135/// ```json
136/// {
137///     "models": [
138///         "meta-llama/Llama-2-7b-hf",
139///         {
140///             "name": "qwen-local",
141///             "source": {
142///                 "provider": "git",
143///                 "location": "file:./models/qwen.gguf"
144///             }
145///         }
146///     ]
147/// }
148/// ```
149#[derive(Clone, Debug, Serialize, Deserialize)]
150#[serde(untagged)]
151pub enum ModelEntry {
152    /// Bare model ID / path / URL string
153    Selector(String),
154    /// Detailed model download entry
155    Entry(ModelEntryOptions),
156}
157/// Runner status for CI/CD pipelines
158#[derive(Clone, Debug, Default, Display, Serialize, Deserialize)]
159#[serde(rename_all = "snake_case")]
160pub enum RunnerStatus {
161    /// Online and available to run jobs
162    #[default]
163    Online,
164    /// Offline and/or unavailable to run jobs
165    Offline,
166    /// Runner has not contacted the server for a while
167    Stale,
168    /// Runner has never contacted the server
169    NeverContacted,
170    /// Deprecated
171    Active,
172    /// Deprecated
173    Paused,
174}
175/// Runner types for CI/CD pipelines
176///
177/// Mostly for GitLab as GitHub only has two types: hosted (by GitHub) and self-hosted
178#[derive(Clone, Debug, Default, Serialize, Deserialize)]
179pub enum RunnerType {
180    /// Accessible to a specific group and its projects/subgroups
181    #[default]
182    #[serde(rename = "group_type", alias = "group")]
183    Group,
184    /// Available to all projects and groups within an instance
185    /// > Also called "shared runners" in GitLab
186    #[serde(rename = "instance_type", alias = "instance")]
187    Instance,
188    /// Available to a specific project
189    #[serde(rename = "project_type", alias = "project")]
190    Project,
191}
192/// Struct for application configuration
193/// ### Example `.acorn` configuration file
194/// ```json
195/// {
196///     "buckets": [
197///         {
198///             "name": "big-science",
199///             "repository": {
200///                 "provider": "github",
201///                 "uri": "https://github.com/username/example"
202///             }
203///         },
204///         {
205///             "name": "does-it-scale",
206///             "repository": {
207///                 "provider": "gitlab",
208///                 "id": 12345,
209///                 "uri": "https://gitlab.com/username/example"
210///             }
211///         }
212///     ],
213///     "models": [
214///         "meta-llama/Llama-2-7b-hf",
215///         "microsoft/phi-2"
216///     ],
217///     "runners": [
218///         {
219///             "name": "ACORN Runner B",
220///             "repository": {
221///                 "provider": "gitlab",
222///                 "id": 24758,
223///                 "uri": "https://code.ornl.gov/research-enablement"
224///             },
225///             "type": "group",
226///             "runUntagged": true
227///         }
228///     ]
229/// }
230/// ```
231#[derive(Clone, Debug, Default, Serialize, eserde::Deserialize)]
232pub struct ApplicationConfiguration {
233    /// CST root for JSONC comment-preserving round-trips
234    #[serde(skip)]
235    pub cst: Option<CstRootNode>,
236    /// List of buckets
237    #[eserde(compat)]
238    pub buckets: Option<Vec<Bucket>>,
239    /// Synchronization targets for llama-swap, OpenCode, VS Code, and Goose
240    #[eserde(compat)]
241    pub config: Option<sync::Config>,
242    /// List of endpoints
243    #[eserde(compat)]
244    pub endpoints: Option<Vec<Endpoint>>,
245    /// List of models to download — bare IDs/paths or detailed download entries
246    #[eserde(compat)]
247    pub models: Option<Vec<ModelEntry>>,
248    /// List of runners
249    #[eserde(compat)]
250    pub runners: Option<Vec<RunnerDetails>>,
251    /// Lookup object for whitelisted downloadable items
252    #[eserde(compat)]
253    pub whitelist: Option<WhitelistLookup>,
254}
255/// Detailed model download entry for use in `ApplicationConfiguration.models`
256#[skip_serializing_none]
257#[derive(Builder, Clone, Debug, Serialize, Deserialize)]
258#[serde(rename_all = "camelCase")]
259#[builder(start_fn = init)]
260pub struct ModelEntryOptions {
261    /// User-facing model name
262    pub name: String,
263    /// Model weight source (local path, URL, or Hugging Face repository)
264    pub source: Repository,
265    /// Optional repository revision, branch, or tag to resolve
266    #[serde(default)]
267    pub revision: Option<String>,
268    /// Authentication requirement for this model source
269    #[serde(default)]
270    pub auth: Option<AuthenticationRequirement>,
271    /// Regex pattern(s) used to include model files for this entry
272    #[serde(default)]
273    pub filter: Option<Vec<String>>,
274    /// Regex pattern(s) used to exclude model files for this entry
275    #[serde(default)]
276    pub ignore: Option<Vec<String>>,
277    /// Ordered exact GGUF quantization allowlist
278    #[serde(default)]
279    pub quantization: Option<OneOrMany<Quantization>>,
280    /// Maximum GPU memory available for model weights
281    #[serde(default)]
282    pub gpu_memory: Option<Memory>,
283    /// Copy local model files into the model directory instead of referencing in place
284    #[serde(default)]
285    pub copy: Option<bool>,
286    /// Symlink local model files into the model directory instead of referencing in place
287    #[serde(default)]
288    pub symlink: Option<bool>,
289}
290/// Struct for bucket data
291#[skip_serializing_none]
292#[derive(Builder, Clone, Debug, Serialize, Deserialize)]
293#[serde(rename_all = "camelCase")]
294#[builder(start_fn = init)]
295pub struct Bucket {
296    /// Bucket name
297    ///
298    /// See <https://schema.org/name>
299    pub name: Option<String>,
300    /// Bucket description
301    ///
302    /// See <https://schema.org/description>
303    pub description: Option<String>,
304    /// Code repository data of bucket
305    ///
306    /// See <https://schema.org/codeRepository>
307    #[serde(alias = "repository")]
308    pub code_repository: Repository,
309}
310/// Options for bucket file operations (copy and download)
311#[derive(Builder, Clone, Debug)]
312#[builder(start_fn = init)]
313pub struct BucketOptions {
314    /// Path to output directory
315    pub output: Option<PathBuf>,
316    /// Number of threads used for parallel processing
317    #[builder(default = 10)]
318    pub threads: usize,
319    /// Suppress progress output
320    #[builder(default)]
321    pub quiet: bool,
322    /// Regex pattern(s) of files to ignore
323    #[builder(default)]
324    pub ignore: Vec<String>,
325    /// Regex pattern(s) of files to include
326    #[builder(default)]
327    pub filter: Vec<String>,
328    /// Save transferred files directly beneath the output directory
329    #[builder(default)]
330    pub flatten: bool,
331    /// Replace existing files or directories at selected output paths
332    #[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/// Files successfully transferred from one configured bucket.
390#[derive(Clone, Debug, Eq, PartialEq)]
391pub struct TransferManifest {
392    /// Configured bucket name.
393    pub bucket: Option<String>,
394    /// Repository location used for the transfer.
395    pub repository: String,
396    /// Relative paths written beneath the output directory.
397    pub files: Vec<PathBuf>,
398}
399/// Struct for filtering files based on regex patterns
400#[derive(Clone, Debug)]
401pub struct FilterSet {
402    /// Regex patterns to ignore
403    pub ignore: Vec<Regex>,
404    /// Regex patterns to include
405    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")]
410/// CI/CD runner configuration entry
411pub struct RunnerDetails {
412    /// Code repository project/group where runner will be used
413    #[builder(start_fn)]
414    #[serde(alias = "repository")]
415    pub code_repository: Repository,
416    /// Runner name
417    /// ### Note
418    /// Primarily for identification in the GitLab UI
419    pub name: Option<String>,
420    /// Runner type (e.g., group, instance, project)
421    #[builder(default, with = |method: &str| RunnerType::from(method))]
422    #[serde(rename = "type")]
423    pub runner_type: RunnerType,
424    /// Optional description of runner
425    pub description: Option<String>,
426    /// Runner executor type (e.g., Docker, Kubernetes, Shell)
427    #[builder(default = Executor::Docker)]
428    #[serde(default = "default_executor")]
429    pub executor: Executor,
430    /// Does the runner need GPU capabilities?
431    #[builder(default)]
432    #[serde(default, alias = "gpu")]
433    pub gpu_enabled: bool,
434    /// List of tags associated with the runner
435    #[serde(default, alias = "tag_list")]
436    pub tags: Option<Vec<String>>,
437    /// Whether the runner runs untagged jobs (GitLab-specific)
438    #[builder(default)]
439    #[serde(default, alias = "run_untagged")]
440    pub run_untagged: bool,
441    /// Optional GitLab host/domain override (for self-managed instances)
442    pub host: Option<String>,
443    /// Default Docker image for the runner itself
444    #[builder(default = String::from("gitlab/gitlab-runner:latest"))]
445    #[serde(default = "default_docker_image")]
446    pub docker_image: String,
447    /// Runner identifier assigned by GitLab during creation
448    #[serde(default)]
449    pub identifier: Option<u64>,
450    /// Runner authentication token returned from GitLab API
451    #[serde(default)]
452    pub token: Option<String>,
453}
454/// Whitelist entry for downloadable items
455/// ### Note
456/// An empty whitelist is interpreted to mean there are no restrictions and any item will be allowed
457#[skip_serializing_none]
458#[derive(Builder, Clone, Debug, Serialize, Deserialize)]
459#[serde(rename_all = "camelCase")]
460#[builder(start_fn = init)]
461pub struct WhitelistLookup {
462    /// Whitelisted buckets
463    pub buckets: Option<Vec<String>>,
464    /// Whitelisted model IDs/URLs, or one URL to a JSON whitelist document
465    pub models: Option<OneOrMany<String>>,
466}
467impl InputOutput for ApplicationConfiguration {
468    /// Read and parse application configuration file (JSON, JSONC, or YAML)
469    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    /// Read configuration (e.g., `.acorn.json`) using Serde and [`ApplicationConfiguration`] struct
482    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    /// Read configuration (e.g., `.acorn.jsonc`) using JSONC parser
506    ///
507    /// Supports comments (`//` and `/* */`) and trailing commas.
508    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    /// Read configuration (e.g., `.acorn.yml`) using Serde and [`ApplicationConfiguration`] struct
523    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    /// Write configuration to specified path (detects JSON, JSONC, or YAML from extension)
538    ///
539    /// JSONC files are written as strict JSON (no comments generated).
540    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    /// Write configuration as JSON to specified path
552    ///
553    /// If the config was parsed from JSONC, preserves comments via CST.
554    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    /// Write configuration as YAML to specified path
564    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    /// Load explicit or discovered application configuration, rejecting a missing explicit path.
573    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    /// Merge configured synchronization settings with runtime overrides.
580    pub fn resolve_sync_config(&self, overrides: sync::Config) -> sync::Config {
581        self.config.clone().unwrap_or_default().merge(overrides)
582    }
583    /// Return configured model entries and their download whitelist.
584    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    /// Synchronize selected model entries with configured local inference targets.
591    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    /// Synchronize selected models and add their unique identifiers to ACORN configuration.
610    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    /// Resolve application configuration path
698    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    /// Resolve an application configuration path relative to an explicit directory.
703    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    /// Parse ACORN configuration in JSON, JSONC, or YAML format
712    /// ### Note
713    /// If the content is valid JSON, it is parsed as JSON.
714    /// If it is not valid JSON but starts with `{` or `[`, it is first attempted to be parsed as JSON, and if that fails, it is attempted to be parsed as YAML.
715    /// If the content does not start with `{` or `[`, it is parsed as YAML.
716    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    /// Get hosting domain from bucket struct
771    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    /// Copy files from (local) bucket to local directory
837    /// ### Notes
838    /// - Ignores files listed in [`IGNORE`]
839    /// - Only copies files from local repositories
840    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    /// Download files from bucket to local directory
911    ///
912    /// Ignores files listed in [`IGNORE`]
913    ///
914    /// Downloads files concurrently using buffered streams
915    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    /// Set the output path
1069    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    /// Compile ignore and filter regex patterns into a filter set
1078    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    /// Filter items based on ignore and filter regex patterns
1088    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    /// Determine whether a value satisfies the compiled include and ignore patterns
1101    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    /// Normalize model entries into unique synchronization requests
1109    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    /// Resolve model entries from a local models directory, skipping entries that cannot be resolved.
1125    pub fn resolve(entries: &[Self], options: &sync::ModelRequestOptions<'_>) -> ApiResult<Vec<ModelDetails>> {
1126        Self::resolve_using(entries, options, false, |_| Vec::new())
1127    }
1128    /// Resolve model entries using fallback repository metadata from the local model database.
1129    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    /// Set the runner identifier (assigned by GitLab after creation)
1200    pub fn with_id(self, value: u64) -> Self {
1201        Self {
1202            identifier: Some(value),
1203            ..self
1204        }
1205    }
1206    /// Set the runner name (used as Docker container name)
1207    pub fn with_name(self, value: String) -> Self {
1208        Self { name: Some(value), ..self }
1209    }
1210    /// Set the runner authentication token
1211    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    /// Return the legacy reported count of transferred JSON and image files.
1231    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    /// Ingest transferred RAD files into the canonical research activity table.
1236    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}