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