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