Skip to main content

gixor/
lib.rs

1//! Gixor is a tool to manage the boilerplate files (`.gitignore`).
2//! This is alternative tool of [gibo](https://github.com/simonwhitaker/gibo) written in Rust.
3//!
4//! Also, this library provides an API of the Gitignore boilerplate management.
5//! The main structure of this library is [`Gixor`].
6//!
7//! # Example of Dump the boilerplate
8//!
9//! ```rust
10//! use gixor::{Gixor, GixorFactory, Name, Result};
11//!
12//! // load configuration file and build Gixor object.
13//! let gixor = GixorFactory::load("testdata/config.json").unwrap();
14//! gixor.prepare(true).unwrap(); // clone or update all repositories, if needed.
15//! // create vec of Name instance.
16//! let names = Name::parse_all(vec!["rust", "macos", "linux", "windows"]);
17//! // dump the boilerplate of rust, macos, linux, and windows into stdout.
18//! let r = gixor.dump(names, std::io::stdout(), false);
19//! ```
20//!
21//! # Features
22//!
23//! [`Gixor`] provides the following features for operating Git repositories.:
24//! - `usegix` (default): use [`gix`](https://docs.rs/gix/latest/gix/) crate which is a pure Rust
25//!   implementation of Git. Nothing has to be installed alongside, and no C library is linked.
26//! - `--no-default-features`: use `git` command via
27//!   [`std::process::Command`](https://doc.rust-lang.org/std/process/struct.Command.html),
28//!   which requires `git` to be on the `PATH`.
29//!
30use std::{
31    fmt::Display,
32    path::{Path, PathBuf},
33};
34
35use serde::{Deserialize, Deserializer, Serialize};
36
37#[cfg(not(any(feature = "local", feature = "embedded")))]
38compile_error!(
39    "gixor needs to know where the boilerplates come from: enable `local` to keep clones on the \
40     file system, or `embedded` to compile a snapshot in."
41);
42
43#[cfg(all(feature = "local", feature = "embedded"))]
44compile_error!("The features `local` and `embedded` cannot be enabled at the same time.");
45
46pub mod aliases;
47#[cfg(feature = "local")]
48pub mod gitbridge;
49pub mod repos;
50mod source;
51
52/// Represents the result of Gixor.
53pub type Result<T> = std::result::Result<T, Error>;
54
55/// Represents an error of Gixor.
56#[derive(Debug)]
57pub enum Error {
58    /// Multiple errors.
59    Array(Vec<Error>),
60    /// Error related to alias.
61    Alias(String),
62    /// Error when the alias is not found.
63    AliasNotFound(String),
64    /// Error when the boilerplate is not found.
65    BoilerplateNotFound(String),
66    /// Error when the file is not found.
67    FileNotFound(PathBuf),
68    /// Fatal error.
69    Fatal(String),
70    /// Git error.
71    Git(String),
72    /// IO error.
73    IO(std::io::Error),
74    /// JSON error.
75    Json(serde_json::Error),
76    /// Error when the repository is not found.
77    RepositoryNotFound(String),
78}
79
80impl Error {
81    pub fn to_err<T>(item: T, errs: Vec<Error>) -> Result<T> {
82        if errs.is_empty() {
83            Ok(item)
84        } else if errs.len() == 1 {
85            Err(errs.into_iter().next().unwrap())
86        } else {
87            Err(Error::Array(errs))
88        }
89    }
90
91    /// Convert `Vec<Result<T>>` to `Result<Vec<T>>`
92    /// If `Vec<Result<T>>` has the multiple errors,
93    /// `Result<Vec<T>>` returns `Err(GixorError::Array(Vec<GixorError>))`.
94    pub fn vec_result_to_result_vec<T>(vec: Vec<Result<T>>) -> Result<Vec<T>> {
95        let mut ok_items = vec![];
96        let mut errs = vec![];
97        for r in vec {
98            match r {
99                Ok(item) => ok_items.push(item),
100                Err(e) => errs.push(e),
101            }
102        }
103        Error::to_err(ok_items, errs)
104    }
105}
106
107impl Display for Error {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        use Error::*;
110        match self {
111            Array(errs) => {
112                for (i, e) in errs.iter().enumerate() {
113                    if i > 0 {
114                        writeln!(f)?;
115                    }
116                    write!(f, "{e}")?;
117                }
118                Ok(())
119            }
120            Alias(msg) => write!(f, "{msg}"),
121            AliasNotFound(name) => write!(f, "{name}: alias not found"),
122            BoilerplateNotFound(name) => write!(f, "{name}: boilerplate not found"),
123            FileNotFound(path) => write!(f, "{}: file not found", path.display()),
124            Git(e) => write!(f, "Git error: {e}"),
125            IO(e) => write!(f, "IO error: {e}"),
126            Json(e) => write!(f, "JSON error: {e}"),
127            Fatal(msg) => write!(f, "Fatal error: {msg}"),
128            RepositoryNotFound(name) => write!(f, "{name}: repository not found"),
129        }
130    }
131}
132
133mod routine;
134
135/// Finds the entries of `.gitignore` file in the given path.
136/// The given path should be a directory containing a `.gitignore` file or
137/// a regular file which is treats as a `.gitignore` file.
138/// If the `.gitignore` file is not found, returns [`Error::FileNotFound`] error.
139pub fn entries<P: AsRef<Path>>(path: P) -> Result<Vec<String>> {
140    log::info!("Find current entries from {}", path.as_ref().display());
141    routine::entries(path)
142}
143
144/// Finds the target repositories by the given repository names.
145///
146/// ## Returns
147///
148/// If the given `repository_names` is empty, all repositories managed by `gixor` are returned.
149/// Otherwise, the repositories matched with the given names are returned.
150///
151/// ## Errors
152/// - If any of given repository name is not found, returns [`Error::RepositoryNotFound`].
153/// - If multiple repository names are not found, returns [`Error::Array`] which contains multiple [`Error::RepositoryNotFound`].
154pub fn find_target_repositories<S: AsRef<str>>(
155    gixor: &Gixor,
156    repository_names: Vec<S>,
157) -> Result<Vec<&repos::Repository>> {
158    log::info!(
159        "find_target_repositories: repository_names={:?}",
160        repository_names
161            .iter()
162            .map(|s| s.as_ref())
163            .collect::<Vec<_>>()
164    );
165    routine::find_target_repositories(gixor, repository_names)
166}
167
168/// The name of the boilerplate which contains the repository name and the boilerplate name.
169/// The repository name is [`repos::Repository::name`].
170/// The boilerplate name is the file stem of the boilerplate (gitignore) file.
171#[derive(Debug, Clone)]
172pub struct Name {
173    /// The repository name for of the boilerplate. If `None`, the repository name do not care.
174    pub repository_name: Option<String>,
175    /// The boilerplate name.
176    pub boilerplate_name: String,
177}
178
179impl Serialize for Name {
180    fn serialize<S>(&self, serializer: S) -> core::result::Result<S::Ok, S::Error>
181    where
182        S: serde::Serializer,
183    {
184        self.to_string().serialize(serializer)
185    }
186}
187
188impl<'de> Deserialize<'de> for Name {
189    fn deserialize<D>(deserializer: D) -> core::result::Result<Self, D::Error>
190    where
191        D: Deserializer<'de>,
192    {
193        let s = String::deserialize(deserializer)?;
194        Ok(Name::parse(s))
195    }
196}
197
198impl Display for Name {
199    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200        match &self.repository_name {
201            Some(repo) => write!(f, "{}/{}", repo, self.boilerplate_name),
202            None => write!(f, "{}", self.boilerplate_name),
203        }
204    }
205}
206
207impl From<&str> for Name {
208    fn from(s: &str) -> Self {
209        Name::parse(s)
210    }
211}
212
213/// Represents a boilerplate name for finding a boilerplate.
214impl Name {
215    /// Create a new `Name` instance with boilerplate name.
216    /// The repository name is `None` (`None` means don't care).
217    fn new_of<S: AsRef<str>>(boilerplate_name: S) -> Self {
218        Self {
219            repository_name: None,
220            boilerplate_name: boilerplate_name.as_ref().to_string(),
221        }
222    }
223
224    /// Create a new `Name` instance with repository name and boilerplate name.
225    pub fn new<S: AsRef<str>>(repository_name: S, boilerplate_name: S) -> Self {
226        let boilerplate_name = boilerplate_name.as_ref().to_string();
227        Self {
228            repository_name: Some(repository_name.as_ref().to_string()),
229            boilerplate_name,
230        }
231    }
232
233    /// Create a new `Name` instance with the given name.
234    /// The given name should format `<repository_name>/<boilerplate_name>`.
235    /// If the given string do not contain `/`, the repository name is `None`.
236    pub fn parse<S: AsRef<str>>(name: S) -> Self {
237        let name = name.as_ref();
238        let items = name.split('/').collect::<Vec<_>>();
239        if items.len() >= 2 {
240            Self::new(items[0], items[1])
241        } else {
242            Self::new_of(name)
243        }
244    }
245
246    /// Create a vec of `Name` instance from the given string vec.
247    /// The this method gives each name to [Name::parse] method, and collect them.
248    pub fn parse_all<S: AsRef<str>>(names: Vec<S>) -> Vec<Self> {
249        names.iter().map(Name::parse).collect()
250    }
251
252    /// Returns `true` if the given boilerplate is matched with this instance.
253    pub fn matches(&self, boilerplate: &repos::Boilerplate) -> bool {
254        boilerplate.matches(self)
255    }
256}
257
258/// Represents the main structure of Gixor, the engine for managing .gitignore boilerplates.
259///
260/// It holds the configuration, including repository locations and aliases, and provides
261/// methods to interact with them (cloning, updating, finding, and dumping boilerplates).
262pub struct Gixor {
263    config: Config,
264    load_from: PathBuf,
265}
266
267/// Provides the functions for management of the boilerplate repositories.
268pub trait RepositoryManager {
269    /// Returns the length of the repositories in the container.
270    fn len(&self) -> usize;
271    /// Returns `true` if the repositories in the container is empty.
272    fn is_empty(&self) -> bool;
273    /// Iterate the repositories in the container.
274    fn repositories(&self) -> impl Iterator<Item = &repos::Repository>;
275    /// Find the repository by the name.
276    fn repository<N: AsRef<str>>(&self, name: N) -> Option<&repos::Repository>;
277    /// Add the given new repository and returns the new instance of Gixor.
278    fn add_repository(&mut self, repo: repos::Repository) -> Result<()>;
279    /// Add a repository build from the given url and returns the new instance of Gixor.
280    fn add_repository_of<S: AsRef<str>>(&mut self, url: S) -> Result<()>;
281    /// Remove the repository which has the given name, and returns the new instance of Gixor.
282    fn remove_repository_with<S: AsRef<str>>(&mut self, name: S, keep_repo_dir: bool)
283        -> Result<()>;
284    /// Remove the repository which has the given name, and returns the new instance of Gixor.
285    fn remove_repository<S: AsRef<str>>(&mut self, name: S) -> Result<()>;
286}
287
288/// Provides the functions for management of the aliases.
289pub trait AliasManager {
290    /// Iterate the aliases in the configuration.
291    fn iter_aliases(&self) -> impl Iterator<Item = &aliases::Alias>;
292    /// Remove the alias which has the given name.
293    fn remove_alias<S: AsRef<str>>(&mut self, name: S) -> Result<()>;
294    /// Add the given alias.
295    fn add_alias(&mut self, alias: aliases::Alias) -> Result<()>;
296}
297
298/// The configuration directory only means something where there is a user to have one, so both
299/// it and the constructors resting on it belong to the `local` feature.
300#[cfg(feature = "local")]
301impl Default for Gixor {
302    /// Create a default instance of Gixor.
303    /// The default configuration is as follows:
304    /// - The base path is as follows.
305    ///     - Linux: `$XDG_CONFIG_HOME/gixor/config.json` or `$HOME/.config/gixor/config.json`
306    ///     - macOS: `$HOME/Library/Application Support/gixor/config.json`
307    ///     - Windows: `{FOLDERID_RoamingAppData}\gixor\config.json`
308    /// - The default repository is [`repos::Repository::default`].
309    /// - The default configuration file is `${XDG_CONFIG_HOME}/gixor/config.json`.
310    ///
311    /// The default location is as follows.
312    fn default() -> Self {
313        match dirs::config_dir() {
314            Some(dir) => {
315                let repositories = vec![repos::Repository::default()];
316                let config = Config {
317                    repositories,
318                    base_path: dir.join("gixor").join("boilerplates"),
319                    aliases: None,
320                };
321                Self {
322                    config,
323                    load_from: dir.join("gixor").join("config.json"),
324                }
325            }
326            None => panic!("Failed to get the config directory"),
327        }
328    }
329}
330
331/// The factory pattern for [`Gixor`].
332pub struct GixorFactory {}
333
334impl GixorFactory {
335    /// Builds a [`Gixor`] over the boilerplates compiled into the binary.
336    ///
337    /// There is no configuration file and nothing to clone: the repositories are the ones the
338    /// snapshot was taken from, and [`Gixor::prepare`] has nothing to do. This is the entry
339    /// point for a target with no file system, such as wasm.
340    #[cfg(feature = "embedded")]
341    pub fn embedded() -> Gixor {
342        Gixor::new(
343            Config {
344                repositories: source::repositories(),
345                base_path: PathBuf::new(),
346                aliases: None,
347            },
348            PathBuf::new(),
349        )
350    }
351
352    /// Load the configuration file from the default location,
353    /// falling back to a fresh configuration when there is none yet.
354    #[cfg(feature = "local")]
355    pub fn load_or_default() -> Gixor {
356        match dirs::config_dir() {
357            Some(dir) => {
358                let path = dir.join("gixor").join("config.json");
359                GixorFactory::load(&path).unwrap_or_else(|_| GixorFactory::new_at(path))
360            }
361            None => panic!("Failed to get the config directory"),
362        }
363    }
364
365    /// Creates a fresh configuration destined for `path`, holding the default repository and
366    /// no alias. Nothing is written until [`Gixor::store`] is called.
367    ///
368    /// Use this to start a configuration that does not exist yet. [`GixorFactory::load`]
369    /// deliberately refuses a missing file instead, so that a mistyped path is reported rather
370    /// than silently turning into an empty configuration.
371    pub fn new_at<P: AsRef<Path>>(path: P) -> Gixor {
372        let path = path.as_ref();
373        Gixor::new(
374            Config {
375                repositories: vec![repos::Repository::default()],
376                base_path: path.parent().unwrap_or(Path::new(".")).join("boilerplates"),
377                aliases: None,
378            },
379            path.to_path_buf(),
380        )
381    }
382
383    /// Parse the configuration file from the given path.
384    ///
385    /// Returns [`Error::FileNotFound`] when `path` does not exist. To start from a configuration
386    /// that has yet to be written, use [`GixorFactory::new_at`].
387    pub fn load<P: AsRef<Path>>(path: P) -> Result<Gixor> {
388        let path = path.as_ref();
389        match std::fs::File::open(path) {
390            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
391                Err(Error::FileNotFound(path.to_path_buf()))
392            }
393            Err(e) => Err(Error::IO(e)),
394            Ok(f) => match serde_json::from_reader(f) {
395                Ok(config) => Ok(Gixor::new(
396                    update_base_path(config, path),
397                    path.to_path_buf(),
398                )),
399                Err(e) => Err(Error::Json(e)),
400            },
401        }
402    }
403}
404
405impl Gixor {
406    fn new(config: Config, load_from: PathBuf) -> Self {
407        log::debug!("config path: {load_from:?}");
408        log::debug!("config: {}", serde_json::to_string_pretty(&config).unwrap());
409        Gixor { config, load_from }
410    }
411    /// Returns the base path of this configuration.
412    pub fn base_path(&self) -> &Path {
413        &self.config.base_path
414    }
415
416    /// Prepare the repositories in the local environment by cloning or updating them.
417    ///
418    /// This method will iterate through all configured repositories. If a repository
419    /// does not exist locally, it will be cloned. If it exists, it will be updated (pulled).
420    ///
421    /// # Arguments
422    /// * `no_network` - If true, skip network operations (no clone or pull).
423    pub fn prepare(&self, no_network: bool) -> Result<()> {
424        self.config.prepare(no_network)
425    }
426
427    /// Write the content of boilerplates corresponding to the given names to the destination.
428    ///
429    /// # Arguments
430    /// * `names` - A vector of [`Name`] instances representing the boilerplates to dump.
431    /// * `dest` - A writer where the combined content will be written.
432    /// * `clear_flag` - If true, do not include the existing prologue from the destination.
433    pub fn dump(
434        &self,
435        names: Vec<Name>,
436        mut dest: impl std::io::Write,
437        clear_flag: bool,
438    ) -> Result<()> {
439        let content = self.build_gitignore(names, ".gitignore", clear_flag)?;
440        dest.write_all(content.as_bytes()).map_err(Error::IO)?;
441        dest.flush().map_err(Error::IO)
442    }
443
444    /// Builds the content that [`Gixor::dump_to`] would write, without touching any file.
445    ///
446    /// Use this to preview the result, or to write it somewhere else.
447    ///
448    /// # Arguments
449    /// * `names` - A vector of [`Name`] instances representing the boilerplates to dump.
450    /// * `dest` - The path the content is destined for. Its prologue is carried over, and
451    ///   `"-"` reads the prologue from `.gitignore` in the current directory.
452    /// * `clear_prologue` - If true, drop the prologue of the destination.
453    pub fn build_gitignore<P: AsRef<Path>>(
454        &self,
455        names: Vec<Name>,
456        dest: P,
457        clear_prologue: bool,
458    ) -> Result<String> {
459        let dest = dest.as_ref();
460        let prologue = if clear_prologue {
461            vec![]
462        } else {
463            let from = if dest == Path::new("-") {
464                PathBuf::from(".gitignore")
465            } else {
466                routine::find_gitignore(dest)
467            };
468            routine::load_prologue(&from)
469        };
470        let boilerplates = routine::find_boilerplates(self, names)?;
471        routine::build_content(boilerplates, prologue, self.base_path())
472    }
473
474    /// Builds the same content as [`Gixor::build_gitignore`], from a gitignore already in hand
475    /// rather than one on disk.
476    ///
477    /// This is what a caller with no file system has to use: a browser holds the current
478    /// `.gitignore` as text, and gets the new one back as text.
479    ///
480    /// # Arguments
481    /// * `names` - A vector of [`Name`] instances representing the boilerplates to dump.
482    /// * `current` - The current content of the gitignore. Its prologue, the part before the
483    ///   first boilerplate, is carried over. Pass `""` to start from nothing.
484    pub fn build_gitignore_with(&self, names: Vec<Name>, current: &str) -> Result<String> {
485        let prologue = routine::prologue_of(current);
486        let boilerplates = routine::find_boilerplates(self, names)?;
487        routine::build_content(boilerplates, prologue, self.base_path())
488    }
489
490    /// Writes the selected boilerplates to a file or stdout.
491    ///
492    /// If the destination is `"-"`, the content is written to stdout.
493    /// If the `dest` is a directory, the content is written to `${dest}/.gitignore`.
494    /// Otherwise, the content is written to the file specified by `dest`.
495    ///
496    /// The destination is replaced by a rename once the whole content has been built and
497    /// written elsewhere, so an error leaves the existing file exactly as it was.
498    ///
499    /// # Arguments
500    /// * `names` - A vector of [`Name`] instances.
501    /// * `dest` - The destination path or `"-"` for stdout.
502    /// * `clear_flag` - If true, drop the prologue of the destination.
503    pub fn dump_to<P: AsRef<Path>>(
504        &self,
505        names: Vec<Name>,
506        dest: P,
507        clear_flag: bool,
508    ) -> Result<()> {
509        let p = dest.as_ref();
510        log::info!(
511            "dump {} entries into {} with clear_flag: {clear_flag}.",
512            names.len(),
513            p.display()
514        );
515        // The content is built first and in full. Nothing here opens the destination until the
516        // result is known to be complete, so a failure leaves the existing file untouched.
517        let content = self.build_gitignore(names, p, clear_flag)?;
518        if p == Path::new("-") {
519            use std::io::Write;
520            let mut out = std::io::stdout();
521            out.write_all(content.as_bytes()).map_err(Error::IO)?;
522            return out.flush().map_err(Error::IO);
523        }
524        routine::write_atomically(&routine::find_gitignore(p), &content)
525    }
526
527    /// Store the configuration to the configuration path.
528    pub fn store(&self) -> Result<()> {
529        if let Some(parent) = self.load_from.parent()
530            && !parent.as_os_str().is_empty() {
531                std::fs::create_dir_all(parent).map_err(Error::IO)?;
532        }
533        match std::fs::File::create(&self.load_from) {
534            Err(e) => Err(Error::IO(e)),
535            Ok(f) => match serde_json::to_writer(f, &self.config) {
536                Err(e) => Err(Error::Json(e)),
537                Ok(_) => Ok(()),
538            },
539        }
540    }
541
542    /// Iterate the boilerplate paths in the configuration.
543    pub fn iter(&self) -> impl Iterator<Item = repos::Boilerplate<'_>> {
544        self.config.iter()
545    }
546
547    /// Find the boilerplate by the name.
548    pub fn find(&self, name: Name) -> Result<Vec<repos::Boilerplate<'_>>> {
549        self.config.find(name)
550    }
551}
552
553impl AliasManager for Gixor {
554    fn iter_aliases(&self) -> impl Iterator<Item = &aliases::Alias> {
555        self.config.iter_aliases()
556    }
557
558    fn remove_alias<S: AsRef<str>>(&mut self, name: S) -> Result<()> {
559        self.config.remove_alias(name)
560    }
561
562    fn add_alias(&mut self, alias: aliases::Alias) -> Result<()> {
563        self.config.add_alias(alias)
564    }
565}
566
567impl RepositoryManager for Gixor {
568    /// Find the repository by the name.
569    /// Returns the length of the repositories in the configuration.
570    fn len(&self) -> usize {
571        self.config.repositories.len()
572    }
573
574    /// Returns `true` if the repositories in the configuration is empty.
575    fn is_empty(&self) -> bool {
576        self.config.repositories.is_empty()
577    }
578
579    /// Find the repository by the name.
580    fn repository<N: AsRef<str>>(&self, name: N) -> Option<&repos::Repository> {
581        let name = name.as_ref();
582        self.config
583            .repositories
584            .iter()
585            .find(|repo| repo.name == name)
586    }
587
588    /// Iterate the repositories in the configuration.
589    fn repositories(&self) -> impl Iterator<Item = &repos::Repository> {
590        self.config.repositories.iter()
591    }
592
593    /// Add the given new repository and returns the new instance of Gixor.
594    fn add_repository(&mut self, repo: repos::Repository) -> Result<()> {
595        match repo.clone_repo_to(&self.config.base_path) {
596            Err(e) => Err(e),
597            Ok(_) => {
598                self.config.repositories.push(repo);
599                Ok(())
600            }
601        }
602    }
603
604    /// Add a repository build from the given url and returns the new instance of Gixor.
605    fn add_repository_of<S: AsRef<str>>(&mut self, url: S) -> Result<()> {
606        let repo = repos::Repository::new(url);
607        self.add_repository(repo)
608    }
609
610    /// Remove the repository which has the given name, and returns the new instance of Gixor.
611    /// If `keep_repo_dir` is `true`, the directory of the removed repository will be remained.
612    fn remove_repository_with<S: AsRef<str>>(
613        &mut self,
614        name: S,
615        keep_repo_dir: bool,
616    ) -> Result<()> {
617        let name = name.as_ref();
618        let index = self
619            .config
620            .repositories
621            .iter()
622            .position(|repo| repo.name == name);
623        if let Some(index) = index {
624            let repo = self.config.repositories.remove(index);
625            if !keep_repo_dir {
626                remove_repo_dir(&self.config.base_path, repo)?;
627            }
628            Ok(())
629        } else {
630            Err(Error::Fatal(format!("{name}: repository not found")))
631        }
632    }
633
634    /// Remove the repository which has the given name, and returns the new instance of Gixor.
635    /// The directory of the removed repository will be deleted.
636    fn remove_repository<S: AsRef<str>>(&mut self, name: S) -> Result<()> {
637        self.remove_repository_with(name, false)
638    }
639}
640
641fn update_base_path(config: Config, path: &Path) -> Config {
642    let parent = path.parent().unwrap_or(Path::new("."));
643    let base_path = config.base_path.clone();
644    let new_base_path = if base_path.is_absolute() || base_path.starts_with(".") {
645        base_path
646    } else {
647        parent.join(base_path)
648    };
649    Config {
650        base_path: new_base_path,
651        repositories: config.repositories,
652        aliases: config.aliases,
653    }
654}
655
656#[derive(Serialize, Deserialize, Debug)]
657#[serde(rename_all = "kebab-case")]
658struct Config {
659    pub(crate) repositories: Vec<repos::Repository>,
660    #[serde(flatten)]
661    pub(crate) aliases: Option<aliases::Aliases>,
662    pub(crate) base_path: PathBuf,
663}
664
665impl Config {
666    /// Find the related boilerplates by the names from all of repositories.
667    /// The method matches the given name with an alias and, the boilerplate name in the repository..
668    fn find(&self, name: Name) -> Result<Vec<repos::Boilerplate<'_>>> {
669        if let Some(r) = aliases::extract_alias(self, &name) {
670            Ok(r)
671        } else {
672            for repo in &self.repositories {
673                if let Some(item) = repo.find(&name, &self.base_path) {
674                    log::trace!("{}: found from repository {}", name, item.repository_name());
675                    return Ok(vec![item]);
676                }
677            }
678            Err(Error::BoilerplateNotFound(name.boilerplate_name))
679        }
680    }
681
682    /// Find all related boilerplates of the given names from all of repositories.
683    /// The method matches the given name with an alias and the boilerplate name in the repository.
684    fn find_all(&self, names: Vec<Name>) -> Result<Vec<repos::Boilerplate<'_>>> {
685        let r = names
686            .into_iter()
687            .map(|name| self.find(name))
688            .collect::<Result<Vec<_>>>();
689        match r {
690            Ok(v) => Ok(v.into_iter().flatten().collect::<Vec<_>>()),
691            Err(e) => Err(e),
692        }
693    }
694
695    /// Iterate the boilerplates from all repositories.
696    fn iter(&self) -> impl Iterator<Item = repos::Boilerplate<'_>> {
697        self.repositories
698            .iter()
699            .flat_map(move |repo| repo.iter(&self.base_path))
700    }
701
702    /// Prepare the repositories in the local environment by cloning or updating them.
703    fn prepare(&self, no_network: bool) -> Result<()> {
704        let mut errs = vec![];
705        if no_network {
706            log::info!("Network access is disabled.");
707            Ok(())
708        } else {
709            self.repositories.iter().for_each(|repo| {
710                if let Err(e) = repo.prepare(&self.base_path) {
711                    errs.push(e);
712                }
713            });
714            Error::to_err((), errs)
715        }
716    }
717}
718
719impl AliasManager for Config {
720    fn iter_aliases(&self) -> impl Iterator<Item = &aliases::Alias> {
721        self.aliases.iter().flat_map(|a| a.iter_aliases())
722    }
723
724    fn remove_alias<S: AsRef<str>>(&mut self, name: S) -> Result<()> {
725        self.aliases.as_mut().map_or(
726            Err(Error::AliasNotFound(name.as_ref().to_string())),
727            |aliases| aliases.remove_alias(name),
728        )
729    }
730
731    fn add_alias(&mut self, alias: aliases::Alias) -> Result<()> {
732        let aliases = self.aliases.get_or_insert_with(aliases::Aliases::default);
733        aliases.add_alias(alias)
734    }
735}
736
737fn remove_repo_dir<P: AsRef<Path>>(base_path: P, repo: repos::Repository) -> Result<()> {
738    let path = base_path.as_ref().join(repo.name);
739    match std::fs::remove_dir_all(&path) {
740        // The repository is gone from the configuration either way, and a clone that was never
741        // made is not a failure to remove it. The embedded build never has one at all.
742        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
743            log::debug!("{}: no directory to remove", path.display());
744            Ok(())
745        }
746        Err(e) => Err(Error::IO(e)),
747        Ok(_) => Ok(()),
748    }
749}
750
751#[cfg(test)]
752mod tests {
753    use super::*;
754
755    /// The directory holding the fixtures of `testdata`, shared by the unit tests of this crate.
756    ///
757    /// The paths are anchored on `CARGO_MANIFEST_DIR` rather than written relative to the working
758    /// directory. Cargo happens to run test binaries from the package root, but relying on that
759    /// is what led the tests to read `../testdata`, one level above the repository, where the
760    /// fixtures are not.
761    fn testdata_dir() -> PathBuf {
762        PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/testdata"))
763    }
764
765    /// The configuration used by the tests, carrying three repositories and two aliases.
766    ///
767    /// The existence check matters: a path that is not there is not an error the tests would
768    /// notice on their own, it just leaves them asserting against an empty configuration.
769    pub(crate) fn config_path() -> PathBuf {
770        let path = testdata_dir().join("config.json");
771        assert!(
772            path.exists(),
773            "{}: the test configuration is missing",
774            path.display()
775        );
776        path
777    }
778
779    /// The directory the test repositories are cloned into. Ignored by `testdata/.gitignore`.
780    pub(crate) fn boilerplates_path() -> PathBuf {
781        testdata_dir().join("boilerplates")
782    }
783
784    /// Clones or updates the repositories of the test configuration, once for the whole binary.
785    ///
786    /// Every test that resolves a name down to a boilerplate needs them on disk. Preparing from
787    /// each test instead would have several clones racing into the same directory, since the
788    /// tests run in parallel, and letting one test prepare for the others would make the outcome
789    /// depend on the order they happen to run in.
790    pub(crate) fn prepare_once() {
791        static PREPARED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
792        PREPARED.get_or_init(|| {
793            GixorFactory::load(config_path())
794                .unwrap()
795                .prepare(false)
796                .unwrap()
797        });
798    }
799
800    #[test]
801    fn test_vec_result_to_result_vec() {
802        let value = vec![Ok(1), Ok(2), Ok(3)];
803        let result = Error::vec_result_to_result_vec(value).unwrap();
804        assert_eq!(result, vec![1, 2, 3]);
805    }
806
807    /// A mistyped configuration path used to yield an empty configuration that looked like a
808    /// working one. Starting from scratch is now an explicit choice instead.
809    #[test]
810    fn load_reports_a_missing_configuration() {
811        let dir = tempfile::tempdir().unwrap();
812        let path = dir.path().join("config.json");
813
814        assert!(matches!(
815            GixorFactory::load(&path),
816            Err(Error::FileNotFound(_))
817        ));
818
819        let gixor = GixorFactory::new_at(&path);
820        assert_eq!(gixor.config.repositories.len(), 1);
821        assert_eq!(gixor.config.base_path, dir.path().join("boilerplates"));
822        assert_eq!(gixor.load_from, path);
823    }
824
825    #[test]
826    fn parse_gixor() {
827        match GixorFactory::load(config_path()) {
828            Err(e) => panic!("Failed to parse the config file: {e}"),
829            Ok(gixor) => {
830                assert_eq!(gixor.config.base_path, boilerplates_path());
831                assert_eq!(gixor.config.repositories.len(), 3);
832            }
833        }
834    }
835
836    #[test]
837    fn test_error_display() {
838        assert_eq!(
839            Error::Json(serde::de::Error::custom("hoge")).to_string(),
840            "JSON error: hoge"
841        );
842        assert_eq!(
843            Error::IO(std::io::Error::new(std::io::ErrorKind::NotFound, "hoge")).to_string(),
844            "IO error: hoge"
845        );
846        assert_eq!(
847            Error::BoilerplateNotFound("name".to_string()).to_string(),
848            "name: boilerplate not found"
849        );
850        assert_eq!(Error::Git("hoge".into()).to_string(), "Git error: hoge");
851        assert_eq!(
852            Error::AliasNotFound("hoge".into()).to_string(),
853            "hoge: alias not found"
854        );
855        assert_eq!(
856            Error::FileNotFound("hoge".into()).to_string(),
857            "hoge: file not found"
858        );
859        assert_eq!(
860            Error::RepositoryNotFound("hoge".into()).to_string(),
861            "hoge: repository not found"
862        );
863        assert_eq!(
864            Error::Fatal("message".to_string()).to_string(),
865            "Fatal error: message"
866        );
867        assert_eq!(
868            Error::Array(vec![
869                Error::Fatal("hoge1".to_string()),
870                Error::Fatal("hoge2".to_string())
871            ])
872            .to_string(),
873            "Fatal error: hoge1\nFatal error: hoge2"
874        );
875        assert_eq!(
876            Error::Alias("hoge: alias not found".to_string()).to_string(),
877            "hoge: alias not found"
878        )
879    }
880
881    #[test]
882    fn test_target_name() {
883        let target = Name::new("tamada", "devcontainer");
884        assert_eq!(target.repository_name, Some("tamada".to_string()));
885        assert_eq!(target.boilerplate_name, "devcontainer");
886
887        let target = Name::parse("tamada/devcontainer");
888        assert_eq!(target.repository_name, Some("tamada".to_string()));
889        assert_eq!(target.boilerplate_name, "devcontainer");
890
891        let target = Name::parse("devcontainer");
892        assert_eq!(target.repository_name, None);
893        assert_eq!(target.boilerplate_name, "devcontainer");
894    }
895
896    #[test]
897    fn test_name_serialize_deserialize() {
898        let name: Name = serde_json::from_str("\"os-list\"").unwrap();
899        assert_eq!(name.repository_name, None);
900        assert_eq!(name.boilerplate_name, "os-list");
901
902        let str = serde_json::to_string(&name).unwrap();
903        assert_eq!(str, "\"os-list\"");
904
905        let name: Name = serde_json::from_str("\"alias/os-list\"").unwrap();
906        assert_eq!(name.repository_name, Some("alias".to_string()));
907        assert_eq!(name.boilerplate_name, "os-list");
908
909        let str = serde_json::to_string(&name).unwrap();
910        assert_eq!(str, "\"alias/os-list\"");
911    }
912
913    #[test]
914    fn test_repository_manager() {
915        let temp_dir = tempfile::tempdir().unwrap();
916        let config_path = temp_dir.path().join("config.json");
917        let mut gixor = Gixor::new(
918            Config {
919                repositories: vec![],
920                base_path: temp_dir.path().join("boilerplates"),
921                aliases: None,
922            },
923            config_path,
924        );
925
926        assert!(gixor.is_empty());
927        assert_eq!(gixor.len(), 0);
928
929        let repo = repos::Repository::default();
930        gixor.add_repository(repo).unwrap();
931
932        assert!(!gixor.is_empty());
933        assert_eq!(gixor.len(), 1);
934        assert!(gixor.repository("default").is_some());
935        assert_eq!(gixor.repositories().count(), 1);
936
937        gixor.remove_repository("default").unwrap();
938        assert!(gixor.is_empty());
939    }
940
941    #[test]
942    fn test_alias_manager() {
943        let mut gixor = Gixor::new(
944            Config {
945                repositories: vec![],
946                base_path: PathBuf::from("."),
947                aliases: None,
948            },
949            PathBuf::from("config.json"),
950        );
951
952        let alias = aliases::Alias::new("web".into(), "web stuff".into(), vec![]);
953        gixor.add_alias(alias).unwrap();
954        assert_eq!(gixor.iter_aliases().count(), 1);
955
956        gixor.remove_alias("web").unwrap();
957        assert_eq!(gixor.iter_aliases().count(), 0);
958    }
959
960    #[test]
961    fn test_gixor_store() {
962        let temp_dir = tempfile::tempdir().unwrap();
963        let config_path = temp_dir.path().join("sub").join("config.json");
964        let gixor = Gixor::new(
965            Config {
966                repositories: vec![],
967                base_path: PathBuf::from("."),
968                aliases: None,
969            },
970            config_path.clone(),
971        );
972
973        gixor.store().unwrap();
974        assert!(config_path.exists());
975    }
976
977    #[test]
978    fn test_update_base_path() {
979        let config = Config {
980            repositories: vec![],
981            base_path: PathBuf::from("boilerplates"),
982            aliases: None,
983        };
984        let path = PathBuf::from("/etc/gixor/config.json");
985        let updated = update_base_path(config, &path);
986        assert_eq!(updated.base_path, PathBuf::from("/etc/gixor/boilerplates"));
987
988        let config2 = Config {
989            repositories: vec![],
990            base_path: PathBuf::from("/absolute/path"),
991            aliases: None,
992        };
993        let updated2 = update_base_path(config2, &path);
994        assert_eq!(updated2.base_path, PathBuf::from("/absolute/path"));
995    }
996}