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