Skip to main content

grm/
config.rs

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