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 #[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}