grm/
config.rs

1use std::{
2    path::{Path, PathBuf},
3    process,
4};
5
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9use super::{
10    RemoteName, auth,
11    output::{print_error, print_warning},
12    path, provider,
13    provider::{Filter, Provider},
14    repo, tree,
15};
16
17#[derive(Debug, Deserialize, Serialize, clap::ValueEnum, Clone)]
18pub enum RemoteProvider {
19    #[serde(alias = "github", alias = "GitHub")]
20    Github,
21    #[serde(alias = "gitlab", alias = "GitLab")]
22    Gitlab,
23}
24
25pub const WORKTREE_CONFIG_FILE_NAME: &str = "grm.toml";
26
27#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
28#[serde(rename_all = "snake_case")]
29pub enum RemoteType {
30    Ssh,
31    Https,
32    File,
33}
34
35fn worktree_setup_default() -> bool {
36    false
37}
38
39#[derive(Debug, Serialize, Deserialize)]
40#[serde(untagged)]
41pub enum Config {
42    ConfigTrees(ConfigTrees),
43    ConfigProvider(ConfigProvider),
44}
45
46#[derive(Debug, Serialize, Deserialize)]
47#[serde(deny_unknown_fields)]
48pub struct ConfigTrees {
49    pub trees: Vec<Tree>,
50}
51
52#[derive(Clone, Debug, Serialize, Deserialize)]
53pub struct User(String);
54
55impl User {
56    pub fn into_username(self) -> String {
57        self.0
58    }
59}
60
61#[derive(Clone, Debug, Serialize, Deserialize)]
62pub struct Group(String);
63
64impl Group {
65    pub fn into_groupname(self) -> String {
66        self.0
67    }
68}
69
70#[derive(Debug, Serialize, Deserialize)]
71#[serde(deny_unknown_fields)]
72pub struct ConfigProviderFilter {
73    pub access: Option<bool>,
74    pub owner: Option<bool>,
75    pub users: Option<Vec<User>>,
76    pub groups: Option<Vec<Group>>,
77}
78
79#[derive(Debug, Serialize, Deserialize)]
80#[serde(deny_unknown_fields)]
81pub struct ConfigProvider {
82    pub provider: RemoteProvider,
83    pub token_command: String,
84    pub root: String,
85    pub filters: Option<ConfigProviderFilter>,
86
87    pub force_ssh: Option<bool>,
88
89    pub api_url: Option<String>,
90
91    pub worktree: Option<bool>,
92
93    pub remote_name: Option<String>,
94}
95
96#[derive(Debug, Serialize, Deserialize)]
97#[serde(deny_unknown_fields)]
98pub struct Remote {
99    pub name: String,
100    pub url: String,
101    #[serde(rename = "type")]
102    pub remote_type: RemoteType,
103}
104
105#[derive(Debug, Serialize, Deserialize)]
106#[serde(deny_unknown_fields)]
107pub struct Repo {
108    pub name: String,
109
110    #[serde(default = "worktree_setup_default")]
111    pub worktree_setup: bool,
112
113    pub remotes: Option<Vec<Remote>>,
114}
115
116impl ConfigTrees {
117    pub fn to_config(self) -> Config {
118        Config::ConfigTrees(self)
119    }
120
121    pub fn from_vec(vec: Vec<Tree>) -> Self {
122        Self { trees: vec }
123    }
124
125    pub fn from_trees(vec: Vec<tree::Tree>) -> Self {
126        Self {
127            trees: vec.into_iter().map(Tree::from_tree).collect(),
128        }
129    }
130
131    pub fn trees(self) -> Vec<Tree> {
132        self.trees
133    }
134
135    pub fn trees_mut(&mut self) -> &mut Vec<Tree> {
136        &mut self.trees
137    }
138
139    pub fn trees_ref(&self) -> &Vec<Tree> {
140        self.trees.as_ref()
141    }
142}
143
144#[derive(Error, Debug)]
145pub enum SerializationError {
146    #[error(transparent)]
147    Toml(#[from] toml::ser::Error),
148    #[error(transparent)]
149    Yaml(#[from] serde_yaml::Error),
150}
151
152#[derive(Error, Debug)]
153pub enum Error {
154    #[error(transparent)]
155    Auth(#[from] auth::Error),
156    #[error(transparent)]
157    Provider(#[from] provider::Error),
158    #[error(transparent)]
159    Serialization(#[from] SerializationError),
160    #[error(transparent)]
161    Path(#[from] path::Error),
162    #[error("Error reading configuration file \"{:?}\": {}", .path, .message)]
163    ReadConfig { message: String, path: PathBuf },
164    #[error("Error parsing configuration file \"{:?}\": {}", .path, .message)]
165    ParseConfig { message: String, path: PathBuf },
166    #[error("cannot strip prefix \"{:?}\" from \"{:?}\": {}", .prefix, .path, message)]
167    StripPrefix {
168        path: PathBuf,
169        prefix: PathBuf,
170        message: String,
171    },
172}
173
174impl Config {
175    pub fn get_trees(self) -> Result<Vec<Tree>, Error> {
176        match self {
177            Self::ConfigTrees(config) => Ok(config.trees),
178            Self::ConfigProvider(config) => {
179                let token = auth::get_token_from_command(&config.token_command)?;
180
181                let filters = config.filters.unwrap_or(ConfigProviderFilter {
182                    access: Some(false),
183                    owner: Some(false),
184                    users: Some(vec![]),
185                    groups: Some(vec![]),
186                });
187
188                let filter = Filter::new(
189                    filters
190                        .users
191                        .unwrap_or_default()
192                        .into_iter()
193                        .map(Into::into)
194                        .collect(),
195                    filters
196                        .groups
197                        .unwrap_or_default()
198                        .into_iter()
199                        .map(Into::into)
200                        .collect(),
201                    filters.owner.unwrap_or(false),
202                    filters.access.unwrap_or(false),
203                );
204
205                if filter.empty() {
206                    print_warning(
207                        "The configuration does not contain any filters, so no repos will match",
208                    );
209                }
210
211                let repos = match config.provider {
212                    RemoteProvider::Github => match provider::Github::new(
213                        filter,
214                        token,
215                        config.api_url.map(provider::Url::new),
216                    ) {
217                        Ok(provider) => provider,
218                        Err(error) => {
219                            print_error(&format!("Error: {error}"));
220                            process::exit(1);
221                        }
222                    }
223                    .get_repos(
224                        config.worktree.unwrap_or(false),
225                        config.force_ssh.unwrap_or(false),
226                        config.remote_name.map(RemoteName::new),
227                    )?,
228                    RemoteProvider::Gitlab => match provider::Gitlab::new(
229                        filter,
230                        token,
231                        config.api_url.map(provider::Url::new),
232                    ) {
233                        Ok(provider) => provider,
234                        Err(error) => {
235                            print_error(&format!("Error: {error}"));
236                            process::exit(1);
237                        }
238                    }
239                    .get_repos(
240                        config.worktree.unwrap_or(false),
241                        config.force_ssh.unwrap_or(false),
242                        config.remote_name.map(RemoteName::new),
243                    )?,
244                };
245
246                let mut trees = vec![];
247
248                #[expect(clippy::iter_over_hash_type, reason = "fine in this case")]
249                for (namespace, namespace_repos) in repos {
250                    let repos = namespace_repos.into_iter().map(Into::into).collect();
251                    let tree = Tree {
252                        root: Root(if let Some(namespace) = namespace {
253                            PathBuf::from(&config.root).join(namespace.as_str())
254                        } else {
255                            PathBuf::from(&config.root)
256                        }),
257                        repos: Some(repos),
258                    };
259                    trees.push(tree);
260                }
261                Ok(trees)
262            }
263        }
264    }
265
266    pub fn from_trees(trees: Vec<Tree>) -> Self {
267        Self::ConfigTrees(ConfigTrees { trees })
268    }
269
270    pub fn normalize(&mut self) -> Result<(), Error> {
271        if let &mut Self::ConfigTrees(ref mut config) = self {
272            let home = path::env_home()?;
273            for tree in &mut config.trees_mut().iter_mut() {
274                if tree.root.starts_with(&home) {
275                    // The tilde is not handled differently, it's just a normal path component for
276                    // `Path`. Therefore we can treat it like that during
277                    // **output**.
278                    //
279                    // The `unwrap()` is safe here as we are testing via `starts_with()`
280                    // beforehand
281                    #[expect(clippy::missing_panics_doc, reason = "explicit checks for prefixes")]
282                    let root = {
283                        let mut path = tree
284                            .root
285                            .strip_prefix(&home)
286                            .expect("checked for HOME prefix explicitly");
287                        if path.starts_with(Path::new("/")) {
288                            path = path
289                                .strip_prefix(Path::new("/"))
290                                .expect("will always be an absolute path");
291                        }
292                        path
293                    };
294
295                    tree.root = Root::new(Path::new("~").join(root.path()));
296                }
297            }
298        }
299        Ok(())
300    }
301
302    pub fn as_toml(&self) -> Result<String, SerializationError> {
303        Ok(toml::to_string(self)?)
304    }
305
306    pub fn as_yaml(&self) -> Result<String, SerializationError> {
307        Ok(serde_yaml::to_string(self)?)
308    }
309}
310
311#[derive(Debug, Serialize, Deserialize)]
312pub struct Root(PathBuf);
313
314impl Root {
315    pub fn new(s: PathBuf) -> Self {
316        Self(s)
317    }
318
319    pub fn path(&self) -> &Path {
320        self.0.as_path()
321    }
322
323    pub fn starts_with(&self, base: &Path) -> bool {
324        self.0.as_path().starts_with(base)
325    }
326
327    pub fn strip_prefix(&self, prefix: &Path) -> Result<Self, Error> {
328        Ok(Self(
329            self.0
330                .as_path()
331                .strip_prefix(prefix)
332                .map_err(|e| Error::StripPrefix {
333                    path: self.0.clone(),
334                    prefix: prefix.to_path_buf(),
335                    message: e.to_string(),
336                })?
337                .to_path_buf(),
338        ))
339    }
340
341    pub fn into_path_buf(self) -> PathBuf {
342        self.0
343    }
344}
345
346#[derive(Debug, Serialize, Deserialize)]
347#[serde(deny_unknown_fields)]
348pub struct Tree {
349    pub root: Root,
350    pub repos: Option<Vec<Repo>>,
351}
352
353impl Tree {
354    pub fn from_repos(root: &Path, repos: Vec<repo::Repo>) -> Self {
355        Self {
356            root: Root::new(root.to_path_buf()),
357            repos: Some(repos.into_iter().map(Into::into).collect()),
358        }
359    }
360
361    pub fn from_tree(tree: tree::Tree) -> Self {
362        Self {
363            root: tree.root.into(),
364            repos: Some(tree.repos.into_iter().map(Into::into).collect()),
365        }
366    }
367}
368
369#[derive(Debug, Error)]
370pub enum ReadConfigError {
371    #[error("Configuration file not found at `{:?}`", .path)]
372    NotFound { path: PathBuf },
373    #[error("Error reading configuration file at `{:?}`: {}", .path, .message)]
374    Generic { path: PathBuf, message: String },
375    #[error("Error parsing configuration file at `{:?}`: {}", .path, .message)]
376    Parse { path: PathBuf, message: String },
377}
378
379pub fn read_config<'a, T>(path: &Path) -> Result<T, ReadConfigError>
380where
381    T: for<'de> serde::Deserialize<'de>,
382{
383    let content = match std::fs::read_to_string(path) {
384        Ok(s) => s,
385        Err(e) => {
386            return Err(match e.kind() {
387                std::io::ErrorKind::NotFound => ReadConfigError::NotFound {
388                    path: path.to_owned(),
389                },
390                _ => ReadConfigError::Generic {
391                    path: path.to_owned(),
392                    message: e.to_string(),
393                },
394            });
395        }
396    };
397
398    let config: T = match toml::from_str(&content) {
399        Ok(c) => c,
400        Err(_) => match serde_yaml::from_str(&content) {
401            Ok(c) => c,
402            Err(e) => {
403                return Err(ReadConfigError::Parse {
404                    path: path.to_owned(),
405                    message: e.to_string(),
406                });
407            }
408        },
409    };
410
411    Ok(config)
412}
413
414#[derive(Debug, Serialize, Deserialize)]
415#[serde(deny_unknown_fields)]
416pub struct TrackingConfig {
417    pub default: bool,
418    pub default_remote: String,
419    pub default_remote_prefix: Option<String>,
420}
421
422#[derive(Debug, Serialize, Deserialize)]
423#[serde(deny_unknown_fields)]
424pub struct WorktreeRootConfig {
425    pub persistent_branches: Option<Vec<String>>,
426    pub track: Option<TrackingConfig>,
427}
428
429pub fn read_worktree_root_config(
430    worktree_root: &Path,
431) -> Result<Option<WorktreeRootConfig>, Error> {
432    let path = worktree_root.join(WORKTREE_CONFIG_FILE_NAME);
433    let content = match std::fs::read_to_string(&path) {
434        Ok(s) => s,
435        Err(e) => match e.kind() {
436            std::io::ErrorKind::NotFound => return Ok(None),
437            _ => {
438                return Err(Error::ReadConfig {
439                    message: e.to_string(),
440                    path,
441                });
442            }
443        },
444    };
445
446    let config: WorktreeRootConfig = match toml::from_str(&content) {
447        Ok(c) => c,
448        Err(e) => {
449            return Err(Error::ParseConfig {
450                message: e.to_string(),
451                path,
452            });
453        }
454    };
455
456    Ok(Some(config))
457}