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