Skip to main content

acorn/io/
config.rs

1//! Application configuration functions and data structures
2//!
3//! ACORN is configured by a JSON 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;
8use crate::io::api::gitlab;
9use crate::io::api::{Configuration, Endpoint};
10use crate::io::{files_all, read_file, with_progress, write_file, write_file_bytes, FromPath, InputOutput, ProgressType, Source};
11use crate::io::{ApiResult, Executor};
12use crate::prelude::{self, env, exit, Arc, PathBuf};
13use crate::util::constants::app::DEFAULT_CONFIG_FILENAMES;
14use crate::util::{detect_json, suffix, Label, MimeType, StringConversion};
15use crate::{Location, Repository, Scheme};
16use bon::Builder;
17use color_eyre::eyre::eyre;
18use core::fmt::{self, Debug};
19use core::future::Future;
20use derive_more::Display;
21use fancy_regex::Regex;
22use owo_colors::OwoColorize;
23use serde::{Deserialize, Serialize};
24use serde_with::skip_serializing_none;
25use tracing::error;
26
27const IGNORE: [&str; 5] = [".gitignore", ".gitlab-ci.yml", ".gitkeep", ".DS_Store", "README.md"];
28
29struct FilterSet {
30    ignore: Vec<Regex>,
31    filter: Vec<Regex>,
32}
33
34/// Runner status for CI/CD pipelines
35#[derive(Clone, Debug, Default, Display, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum RunnerStatus {
38    /// Online and available to run jobs
39    #[default]
40    Online,
41    /// Offline and/or unavailable to run jobs
42    Offline,
43    /// Runner has not contacted the server for a while
44    Stale,
45    /// Runner has never contacted the server
46    NeverContacted,
47    /// Deprecated
48    Active,
49    /// Deprecated
50    Paused,
51}
52/// Runner types for CI/CD pipelines
53///
54/// Mostly for GitLab as GitHub only has two types: hosted (by GitHub) and self-hosted
55#[derive(Clone, Debug, Default, Serialize, Deserialize)]
56pub enum RunnerType {
57    /// Accessible to a specific group and its projects/subgroups
58    #[default]
59    #[serde(rename = "group_type", alias = "group")]
60    Group,
61    /// Available to all projects and groups within an instance
62    /// > Also called "shared runners" in GitLab
63    #[serde(rename = "instance_type", alias = "instance")]
64    Instance,
65    /// Available to a specific project
66    #[serde(rename = "project_type", alias = "project")]
67    Project,
68}
69/// Struct for application configuration
70///
71/// ### Example `buckets.json`
72/// ```json
73/// {
74///     "buckets": [
75///         {
76///             "name": "example",
77///             "repository": {
78///                 "provider": "github",
79///                 "uri": "https://github.com/username/example"
80///             }
81///         },
82///         {
83///             "name": "example",
84///             "repository": {
85///                 "provider": "gitlab",
86///                 "id": 12345,
87///                 "uri": "https://gitlab.com/username/example"
88///             }
89///         }
90///     ]
91/// }
92/// ```
93#[derive(Clone, Debug, Default, Serialize, eserde::Deserialize)]
94pub struct ApplicationConfiguration {
95    /// List of buckets
96    #[eserde(compat)]
97    pub buckets: Option<Vec<Bucket>>,
98    /// List of endpoints
99    #[eserde(compat)]
100    pub endpoints: Option<Vec<Endpoint>>,
101    /// List of runners
102    #[eserde(compat)]
103    pub runners: Option<Vec<RunnerDetails>>,
104}
105/// Struct for bucket data
106#[skip_serializing_none]
107#[derive(Builder, Clone, Debug, Serialize, Deserialize)]
108#[serde(rename_all = "camelCase")]
109#[builder(start_fn = init)]
110pub struct Bucket {
111    /// Bucket name
112    ///
113    /// See <https://schema.org/name>
114    pub name: Option<String>,
115    /// Bucket description
116    ///
117    /// See <https://schema.org/description>
118    pub description: Option<String>,
119    /// Code repository data of bucket
120    ///
121    /// See <https://schema.org/codeRepository>
122    #[serde(alias = "repository")]
123    pub code_repository: Repository,
124}
125/// Options for bucket file operations (copy and download)
126#[derive(Builder, Clone, Debug)]
127#[builder(start_fn = init)]
128pub struct BucketOptions {
129    /// Path to output directory
130    pub output: Option<PathBuf>,
131    /// Number of threads used for parallel processing
132    #[builder(default = 10)]
133    pub threads: usize,
134    /// Suppress progress output
135    #[builder(default)]
136    pub quiet: bool,
137    /// Regex pattern(s) of files to ignore
138    #[builder(default)]
139    pub ignore: Vec<String>,
140    /// Regex pattern(s) of files to include
141    #[builder(default)]
142    pub filter: Vec<String>,
143}
144#[derive(Builder, Clone, Debug, Serialize, Deserialize)]
145#[builder(start_fn = at, on(String, into))]
146#[serde(rename_all = "camelCase")]
147/// CI/CD runner configuration entry
148pub struct RunnerDetails {
149    /// Code repository project/group where runner will be used
150    #[builder(start_fn)]
151    #[serde(alias = "repository")]
152    pub code_repository: Repository,
153    /// Runner name
154    /// ### Note
155    /// Primarily for identification in the GitLab UI
156    pub name: Option<String>,
157    /// Runner type (e.g., group, instance, project)
158    #[builder(default, with = |method: &str| RunnerType::from(method))]
159    #[serde(rename = "type")]
160    pub runner_type: RunnerType,
161    /// Optional description of runner
162    pub description: Option<String>,
163    /// Runner executor type (e.g., Docker, Kubernetes, Shell)
164    #[builder(default = Executor::Docker)]
165    #[serde(default = "default_executor")]
166    pub executor: Executor,
167    /// Does the runner need GPU capabilities?
168    #[builder(default)]
169    #[serde(default, alias = "gpu")]
170    pub gpu_enabled: bool,
171    /// List of tags associated with the runner
172    #[serde(default, alias = "tag_list")]
173    pub tags: Option<Vec<String>>,
174    /// Whether the runner runs untagged jobs (GitLab-specific)
175    #[builder(default)]
176    #[serde(default, alias = "run_untagged")]
177    pub run_untagged: bool,
178    /// Optional GitLab host/domain override (for self-managed instances)
179    pub host: Option<String>,
180    /// Default Docker image for the runner itself
181    #[builder(default = String::from("gitlab/gitlab-runner:latest"))]
182    #[serde(default = "default_docker_image")]
183    pub docker_image: String,
184    /// Runner identifier assigned by GitLab during creation
185    #[serde(default)]
186    pub identifier: Option<u64>,
187    /// Runner authentication token returned from GitLab API
188    #[serde(default)]
189    pub token: Option<String>,
190}
191impl ApplicationConfiguration {
192    /// Resolve application configuration path
193    ///
194    /// If `path` is an existing file, it is returned.
195    /// Otherwise, the first existing default config file in the current working directory is returned.
196    pub fn resolve(path: &Option<PathBuf>) -> Option<PathBuf> {
197        path.as_ref().filter(|value| value.is_file()).cloned().or_else(|| {
198            let directory = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
199            DEFAULT_CONFIG_FILENAMES
200                .iter()
201                .map(|name| directory.join(name))
202                .find(|candidate| candidate.exists())
203        })
204    }
205    /// Parse ACORN configuration in JSON or YAML format
206    pub fn parse(content: impl AsRef<str>) -> ApiResult<Self> {
207        let content = content.as_ref();
208        if detect_json(content) {
209            match Self::parse_json(content) {
210                | Ok(value) => Ok(value),
211                | Err(errors) => {
212                    let details: Vec<String> = errors
213                        .iter()
214                        .map(|e| format!("{}: {}", e.path().map_or("root".into(), |p| p.to_string()), e.message()))
215                        .collect();
216                    Err(eyre!("{}", details.join("\n")))
217                }
218            }
219        } else {
220            match Self::parse_yaml(content) {
221                | Ok(value) => Ok(value),
222                | Err(why) => Err(eyre!("Failed to parse ACORN configuration YAML — {why}")),
223            }
224        }
225    }
226    fn parse_json(content: impl AsRef<str>) -> Result<Self, eserde::DeserializationErrors> {
227        eserde::json::from_str(content.as_ref())
228    }
229    fn parse_yaml(content: impl AsRef<str>) -> serde_norway::Result<Self> {
230        serde_norway::from_str(content.as_ref())
231    }
232}
233impl InputOutput for ApplicationConfiguration {
234    /// Read and parse application configuration file (JSON or YAML)
235    fn read(path: impl Into<PathBuf>) -> ApiResult<Self> {
236        let source = path.into();
237        match MimeType::from_path(&source) {
238            | MimeType::Json => Self::read_json(source.clone()),
239            | MimeType::Yaml => Self::read_yaml(source.clone()),
240            | _ => Err(eyre!("Unsupported configuration file extension")),
241        }
242    }
243    /// Read configuration (e.g., `.acorn.json`) using Serde and [`ApplicationConfiguration`] struct
244    fn read_json(path: PathBuf) -> ApiResult<Self> {
245        let content = match read_file(path.clone()) {
246            | Ok(value) if !value.is_empty() => value,
247            | Ok(_) | Err(_) => {
248                error!(
249                    path = path.to_string_lossy().to_string(),
250                    "=> {} ACORN configuration JSON content",
251                    Label::fail()
252                );
253                "{}".to_owned()
254            }
255        };
256        match Self::parse_json(content) {
257            | Ok(config) => Ok(config),
258            | Err(errors) => {
259                let details: Vec<String> = errors
260                    .iter()
261                    .map(|e| format!("{}: {}", e.path().map_or("root".into(), |p| p.to_string()), e.message()))
262                    .collect();
263                Err(eyre!("{}", details.join("\n")))
264            }
265        }
266    }
267    /// Read configuration (e.g., `.acorn.yml`) using Serde and [`ApplicationConfiguration`] struct
268    fn read_yaml(path: PathBuf) -> ApiResult<Self> {
269        let content = match read_file(path.clone()) {
270            | Ok(value) => value,
271            | Err(_) => {
272                error!(
273                    path = path.to_string_lossy().to_string(),
274                    "=> {} ACORN configuration YAML content",
275                    Label::fail()
276                );
277                "".to_owned()
278            }
279        };
280        Self::parse_yaml(content).map_err(|why| eyre!("Failed to parse YAML config — {why}"))
281    }
282    /// Write configuration to specified path (detects JSON or YAML from extension)
283    fn write(&self, path: impl Into<PathBuf>) -> ApiResult<()> {
284        let target = path.into();
285        match MimeType::from_path(&target) {
286            | MimeType::Json => self.write_json(&target),
287            | MimeType::Yaml => self.write_yaml(&target),
288            | _ => Err(eyre!("Unsupported configuration file extension")),
289        }
290    }
291    /// Write configuration as JSON to specified path
292    fn write_json(&self, path: impl Into<PathBuf>) -> ApiResult<()> {
293        let target = path.into();
294        serde_json::to_string_pretty(&self)
295            .map_err(|why| eyre!("Failed to serialize JSON config — {why}"))
296            .and_then(|content| write_file(target.clone(), content))
297    }
298    /// Write configuration as YAML to specified path
299    fn write_yaml(&self, path: impl Into<PathBuf>) -> ApiResult<()> {
300        let target = path.into();
301        serde_norway::to_string(&self)
302            .map_err(|why| eyre!("Failed to serialize YAML config — {why}"))
303            .and_then(|content| write_file(target.clone(), content))
304    }
305}
306impl Bucket {
307    /// Get hosting domain from bucket struct
308    pub(crate) fn domain(&self) -> ApiResult<String> {
309        let location = match &self.code_repository {
310            | Repository::GitHub { location } | Repository::GitLab { location, .. } => location,
311            | Repository::Git { .. } => return Err(eyre!("Domain is unsupported for generic Git repositories")),
312        };
313        match location.scheme() {
314            | Scheme::HTTPS => location.host().ok_or_else(|| eyre!("Failed to parse repository host from URI")),
315            | _ => Err(eyre!("Unsupported repository URI scheme")),
316        }
317    }
318    /// Copy files from (local) bucket to local directory
319    /// ### Notes
320    /// - Ignores files listed in [`IGNORE`]
321    /// - Only copies files from local repositories
322    pub async fn copy_files(self: Bucket, options: &BucketOptions) -> ApiResult<usize> {
323        let BucketOptions { output, ignore, filter, .. } = options;
324        let output = Arc::new(output.clone().unwrap_or_default());
325        match compile_filter_set(ignore, filter) {
326            | Ok(filters) => {
327                let Bucket { name, code_repository, .. } = self.clone();
328                match code_repository.is_local() {
329                    | true => {
330                        let bucket_root = match code_repository.location().path() {
331                            | Some(value) => PathBuf::from(value).to_absolute_string(),
332                            | None => {
333                                return Err(eyre!(
334                                    "Bucket {} has no local path — cannot copy files",
335                                    name.as_deref().unwrap_or("unknown")
336                                ))
337                            }
338                        };
339                        let items = filter_paths(
340                            files_all(PathBuf::from(&bucket_root), None)
341                                .into_iter()
342                                .map(|x| x.display().to_string())
343                                .collect::<Vec<String>>(),
344                            &filters,
345                        )
346                        .into_iter()
347                        .filter(|path| PathBuf::from(path).is_file())
348                        .collect::<Vec<String>>();
349                        let bucket_root = Arc::new(bucket_root);
350                        let operation = {
351                            let bucket_root = Arc::clone(&bucket_root);
352                            let output = Arc::clone(&output);
353                            move |path: String| {
354                                let bucket_root = Arc::clone(&bucket_root);
355                                let output = Arc::clone(&output);
356                                async move {
357                                    let path_buf = PathBuf::from(&path);
358                                    match path_buf.strip_prefix(PathBuf::from(bucket_root.as_str())) {
359                                        | Ok(relative) => write_file_bytes(output.join(relative), || async { prelude::read(path.clone()) }).await,
360                                        | Err(_) => Err(eyre!("Failed to strip prefix {bucket_root} from {path}")),
361                                    }
362                                }
363                            }
364                        };
365                        transfer_bucket_files(name, items, options, "Copying", operation).await
366                    }
367                    | false => Ok(0),
368                }
369            }
370            | Err(why) => Err(why),
371        }
372    }
373    /// Download files from bucket to local directory
374    ///
375    /// Ignores files listed in [`IGNORE`]
376    ///
377    /// Downloads files concurrently using buffered streams
378    pub async fn download_files(self: Bucket, options: &BucketOptions) -> ApiResult<usize> {
379        let BucketOptions { filter, ignore, .. } = options;
380        match compile_filter_set(ignore, filter) {
381            | Ok(filters) => {
382                let name = self.name.clone();
383                let code_repository = self.code_repository.clone();
384                match self.file_paths("").await {
385                    | Ok(paths) => {
386                        let items = filter_paths(paths, &filters);
387                        let operation = {
388                            let code_repository = Arc::new(code_repository);
389                            let output = Arc::new(options.output.clone().unwrap_or_default());
390                            move |path: String| {
391                                let output = Arc::clone(&output);
392                                let repository = Arc::clone(&code_repository);
393                                async move {
394                                    match repository.raw_url(path.clone()) {
395                                        | Some(url) => {
396                                            let output_filepath = output.join(path);
397                                            write_file_bytes(output_filepath, || async move { Source::read_bytes(&url, false).await }).await
398                                        }
399                                        | None => Err(eyre!("Failed to build raw URL for repository path")),
400                                    }
401                                }
402                            }
403                        };
404                        transfer_bucket_files(name, items, options, "Downloading", operation).await
405                    }
406                    | Err(why) => {
407                        error!("=> {} Get file paths for download — {why}", Label::fail());
408                        Err(why)
409                    }
410                }
411            }
412            | Err(why) => Err(why),
413        }
414    }
415    async fn file_paths(&self, directory: &str) -> ApiResult<Vec<String>> {
416        let code_repository = self.code_repository.clone();
417        let bucket_name = self.name.clone().unwrap_or_else(|| "Bucket".to_string()).to_uppercase();
418        match &code_repository {
419            | Repository::Git { .. } => {
420                let path = match code_repository.location().path() {
421                    | Some(value) => PathBuf::from(value),
422                    | None => return Err(eyre!("Git repository has no local path — cannot list files")),
423                };
424                Ok(files_all(path, None).into_iter().map(|x| x.display().to_string()).collect())
425            }
426            | Repository::GitHub { location } => match location.path() {
427                | Some(path) => {
428                    let path = path.trim_start_matches('/').to_string();
429                    match self.domain() {
430                        | Ok(host) => github::tree_paths(format!("api.{}", host), path, "main")
431                            .await
432                            .map_err(|why| eyre!("Failed to get file paths for {bucket_name} bucket - {why}")),
433                        | Err(why) => Err(why),
434                    }
435                }
436                | None => Err(eyre!("Failed to parse GitHub URI for {bucket_name} bucket")),
437            },
438            | Repository::GitLab { .. } => match code_repository.id() {
439                | Some(id) => match self.domain() {
440                    | Ok(host) => {
441                        let options = gitlab::Options::from_env().with_domain(host).with_identifier(id).with_path(directory);
442                        let mut page = 1_u32;
443                        let mut all_paths: Vec<String> = vec![];
444                        loop {
445                            let page_options = options.clone().with_page(page);
446                            match gitlab::tree_paths(&page_options).await {
447                                | Ok(response) if response.paths.is_empty() => {
448                                    break Ok(all_paths.clone());
449                                }
450                                | Ok(response) => {
451                                    all_paths.extend(response.paths);
452                                    page = page.saturating_add(1);
453                                }
454                                | Err(why) => {
455                                    break Err(eyre!("Failed to get file paths for {bucket_name} bucket — {why}"));
456                                }
457                            }
458                        }
459                    }
460                    | Err(why) => Err(why),
461                },
462                | None => Err(eyre!("Missing GitLab project id for {bucket_name} bucket")),
463            },
464        }
465    }
466}
467impl From<&str> for Bucket {
468    fn from(value: &str) -> Self {
469        let location = Location::Simple(value.to_string());
470        if location.uri().is_none() {
471            exit(exitcode::DATAERR);
472        }
473        let repository = match location.scheme() {
474            | Scheme::File => Repository::Git { location },
475            | _ => {
476                let host = match location.host() {
477                    | Some(value) => value.to_lowercase(),
478                    | None => {
479                        error!(value, "=> {} Parse URI - No host", Label::fail());
480                        exit(exitcode::DATAERR);
481                    }
482                };
483                if host.contains("github.com") {
484                    Repository::GitHub { location }
485                } else {
486                    let id = None;
487                    Repository::GitLab { id, location }
488                }
489            }
490        };
491        Bucket::init().code_repository(repository).build()
492    }
493}
494impl Default for BucketOptions {
495    fn default() -> Self {
496        Self {
497            output: None,
498            threads: 10,
499            quiet: false,
500            ignore: Vec::new(),
501            filter: Vec::new(),
502        }
503    }
504}
505impl BucketOptions {
506    /// Set the output path
507    pub fn with_output(self, output: impl Into<PathBuf>) -> Self {
508        Self {
509            output: Some(output.into()),
510            ..self
511        }
512    }
513}
514impl fmt::Display for RunnerType {
515    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
516        let value = match self {
517            | RunnerType::Group => "group",
518            | RunnerType::Instance => "instance",
519            | RunnerType::Project => "project",
520        };
521        formatter.write_str(value)
522    }
523}
524impl RunnerDetails {
525    /// Set the runner identifier (assigned by GitLab after creation)
526    pub fn with_id(self, value: u64) -> Self {
527        Self {
528            identifier: Some(value),
529            ..self
530        }
531    }
532    /// Set the runner name (used as Docker container name)
533    pub fn with_name(self, value: String) -> Self {
534        Self { name: Some(value), ..self }
535    }
536    /// Set the runner authentication token
537    pub fn with_token(self, value: Option<String>) -> Self {
538        Self { token: value, ..self }
539    }
540}
541impl From<&str> for RunnerType {
542    fn from(value: &str) -> Self {
543        match value.to_uppercase().as_str() {
544            | "GROUP" => RunnerType::Group,
545            | "INSTANCE" => RunnerType::Instance,
546            | "PROJECT" => RunnerType::Project,
547            | _ => RunnerType::Group,
548        }
549    }
550}
551impl From<String> for RunnerType {
552    fn from(value: String) -> Self {
553        Self::from(value.as_str())
554    }
555}
556fn compile_filter_set(ignore: &[String], filter: &[String]) -> ApiResult<FilterSet> {
557    compile_regex_patterns(ignore).and_then(|ignore| compile_regex_patterns(filter).map(|filter| FilterSet { ignore, filter }))
558}
559fn compile_regex_patterns(patterns: &[String]) -> ApiResult<Vec<Regex>> {
560    patterns
561        .iter()
562        .map(|pattern| Regex::new(pattern).map_err(|why| eyre!("Invalid regex/filter pattern '{pattern}': {why}")))
563        .collect()
564}
565fn count_json_files(paths: &[String]) -> usize {
566    paths.iter().filter(|&path| path.to_lowercase().ends_with(".json")).count()
567}
568fn count_image_files(paths: &[String]) -> usize {
569    paths.iter().filter(|&x| has_image_extension(x)).count()
570}
571fn default_docker_image() -> String {
572    "gitlab/gitlab-runner:latest".to_string()
573}
574fn default_executor() -> Executor {
575    Executor::Docker
576}
577fn filter_paths(paths: Vec<String>, filters: &FilterSet) -> Vec<String> {
578    paths
579        .into_iter()
580        .filter(|path| !is_ignored_path(path, &filters.ignore) && is_filtered_path(path, &filters.filter))
581        .collect()
582}
583#[allow(clippy::ptr_arg)]
584fn has_image_extension(path: &String) -> bool {
585    path.to_lowercase().ends_with(".png") || path.to_lowercase().ends_with(".jpg")
586}
587fn is_ignored_path(path: &str, ignore: &[Regex]) -> bool {
588    let is_builtin_ignored = IGNORE.iter().any(|value| path.ends_with(value));
589    let is_regex_ignored = ignore.iter().any(|pattern| pattern.is_match(path).unwrap_or(false));
590    is_builtin_ignored || is_regex_ignored
591}
592fn is_filtered_path(path: &str, filter: &[Regex]) -> bool {
593    filter.is_empty() || filter.iter().any(|pattern| pattern.is_match(path).unwrap_or(false))
594}
595fn operations_complete_message(name: Option<String>, json_count: usize, image_count: usize) -> String {
596    let total = json_count.saturating_add(image_count);
597    let message = if json_count != image_count {
598        let recommendation = if json_count > image_count {
599            "Do you need to add some images?"
600        } else {
601            "Do you need to add some JSON files?"
602        };
603        format!(
604            " ({} data file{}, {} image{} - {})",
605            json_count.yellow(),
606            suffix(json_count),
607            image_count.yellow(),
608            suffix(image_count),
609            recommendation.italic(),
610        )
611    } else {
612        "".to_string()
613    };
614    let bucket_description = match name {
615        | Some(value) => format!("{} bucket", value.to_uppercase().cyan()),
616        | None => "<URL>".cyan().to_string(),
617    };
618    format!(
619        "{}Obtained {} file{} from {bucket_description}{}",
620        if total > 0 { Label::CHECKMARK } else { Label::CAUTION },
621        if total > 0 {
622            total.green().to_string()
623        } else {
624            total.yellow().to_string()
625        },
626        suffix(total),
627        message,
628    )
629}
630async fn transfer_bucket_files<F, Fut>(
631    name: Option<String>,
632    items: Vec<String>,
633    options: &BucketOptions,
634    verb: &'static str,
635    operation: F,
636) -> ApiResult<usize>
637where
638    F: Fn(String) -> Fut,
639    Fut: Future<Output = ApiResult<()>>,
640{
641    let BucketOptions { threads, quiet, .. } = options;
642    let total_data = count_json_files(&items);
643    let total_images = count_image_files(&items);
644    let total = total_data.saturating_add(total_images);
645    let message = move |path: &String| format!("{verb} {path}");
646    let finish_message = |_| operations_complete_message(name, total_data, total_images);
647    let progress_type = match quiet {
648        | true => ProgressType::Silent,
649        | false => ProgressType::Bar,
650    };
651    with_progress(items, message, operation, finish_message, Some(*threads), progress_type)
652        .await
653        .map(|_| total)
654}
655
656#[cfg(test)]
657mod tests {
658    #![allow(
659        clippy::unwrap_used,
660        clippy::expect_used,
661        clippy::panic,
662        clippy::indexing_slicing,
663        clippy::arithmetic_side_effects
664    )]
665    use super::*;
666    use crate::prelude::{create_dir_all, remove_dir_all, write};
667
668    fn temp_resolve_dir(name: &str) -> PathBuf {
669        let nanos = std::time::SystemTime::now()
670            .duration_since(std::time::UNIX_EPOCH)
671            .unwrap_or(core::time::Duration::from_nanos(0))
672            .as_nanos();
673        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
674            .join("..")
675            .join("target")
676            .join("test_artifacts")
677            .join(format!("{name}-{nanos}"))
678    }
679
680    #[test]
681    fn test_resolve_returns_explicit_existing_path() {
682        let directory = temp_resolve_dir("resolve-explicit");
683        create_dir_all(&directory).unwrap();
684        let directory = directory.canonicalize().unwrap();
685        let provided = directory.join("config.yaml");
686        let default = directory.join(".acorn.json");
687        write(&provided, "{}\n").unwrap();
688        write(&default, "{}\n").unwrap();
689        let resolved = ApplicationConfiguration::resolve(&Some(provided.clone()));
690        assert_eq!(resolved, Some(provided));
691        let _ = remove_dir_all(directory);
692    }
693    #[test]
694    fn test_resolve_falls_back_to_default_when_provided_path_missing() {
695        let directory = temp_resolve_dir("resolve-fallback");
696        create_dir_all(&directory).unwrap();
697        let directory = directory.canonicalize().unwrap();
698        let default = directory.join(".acorn.yml");
699        write(&default, "{}\n").unwrap();
700        let previous = env::current_dir().unwrap();
701        env::set_current_dir(&directory).unwrap();
702        let resolved = ApplicationConfiguration::resolve(&Some(directory.join("missing.json")));
703        env::set_current_dir(previous).unwrap();
704        assert_eq!(resolved, Some(default));
705        let _ = remove_dir_all(directory);
706    }
707    #[test]
708    fn test_count_image_files_counts_supported_extensions() {
709        let paths = vec![
710            "content/plot.png".to_string(),
711            "content/photo.jpg".to_string(),
712            "content/photo.jpeg".to_string(),
713            "content/index.json".to_string(),
714        ];
715        assert_eq!(count_image_files(&paths), 2);
716    }
717    #[test]
718    fn test_count_json_files_counts_case_insensitive_json_paths() {
719        let paths = vec![
720            "content/index.json".to_string(),
721            "content/README.md".to_string(),
722            "content/data.JSON".to_string(),
723        ];
724        assert_eq!(count_json_files(&paths), 2);
725    }
726    #[test]
727    fn test_has_image_extension_matches_png_and_jpg() {
728        assert!(has_image_extension(&"image.png".to_string()));
729        assert!(has_image_extension(&"photo.JPG".to_string()));
730        assert!(!has_image_extension(&"graphic.jpeg".to_string()));
731    }
732    #[test]
733    fn test_is_ignored_path() {
734        let ignore = compile_regex_patterns(&[r"\.jpeg$".to_string(), r"notes\.txt$".to_string()]).unwrap();
735        assert!(is_ignored_path("/tmp/photo.jpeg", &ignore));
736        assert!(is_ignored_path("/tmp/notes.txt", &ignore));
737        assert!(!is_ignored_path("/tmp/index.json", &ignore));
738        let invalid = compile_regex_patterns(&["[".to_string()]);
739        assert!(invalid.is_err());
740        let ignore: Vec<Regex> = vec![];
741        assert!(is_ignored_path("/tmp/README.md", &ignore));
742    }
743    #[test]
744    fn test_is_filtered_path() {
745        let filter = compile_regex_patterns(&[r"\.json$".to_string(), r"img/".to_string()]).unwrap();
746        assert!(is_filtered_path("/tmp/data.json", &filter));
747        assert!(is_filtered_path("/tmp/img/photo.jpg", &filter));
748        assert!(!is_filtered_path("/tmp/README.md", &filter));
749        let invalid = compile_regex_patterns(&["[".to_string()]);
750        assert!(invalid.is_err());
751        let filter: Vec<Regex> = vec![];
752        assert!(is_filtered_path("/tmp/README.md", &filter));
753    }
754    #[test]
755    fn test_operations_complete_message_includes_bucket_name_and_guidance() {
756        let message = operations_complete_message(Some("acorn".to_string()), 2, 1);
757        assert!(message.contains("Obtained"));
758        assert!(message.contains("ACORN"));
759        assert!(message.contains(" bucket"));
760        assert!(message.contains("data file"));
761        assert!(message.contains("image"));
762        assert!(message.contains("Do you need to add some images?"));
763    }
764    #[test]
765    fn test_operations_complete_message_uses_url_placeholder_without_name() {
766        let message = operations_complete_message(None, 0, 0);
767        assert!(message.contains("Obtained"));
768        assert!(message.contains("<URL>"));
769    }
770}