1use std::path::{Path, PathBuf};
2use std::fs;
3use serde::{Deserialize, Serialize};
4use crate::error::{Result, ToriiError};
5
6#[derive(Debug, Serialize, Deserialize, Clone)]
8pub struct ToriiConfig {
9 pub user: UserConfig,
11
12 pub snapshot: SnapshotConfig,
14
15 pub mirror: MirrorConfig,
17
18 pub git: GitConfig,
20
21 pub ui: UiConfig,
23
24 #[serde(default)]
26 pub auth: AuthConfig,
27
28 #[serde(default)]
30 pub update: UpdateConfig,
31
32 #[serde(default)]
34 pub worktree: WorktreeConfig,
35}
36
37#[derive(Debug, Serialize, Deserialize, Clone)]
38pub struct WorktreeConfig {
39 pub base_dir: String,
47
48 #[serde(default)]
61 pub inherit_paths: Vec<String>,
62}
63
64impl Default for WorktreeConfig {
65 fn default() -> Self {
66 Self {
67 base_dir: "..".to_string(),
68 inherit_paths: Vec::new(),
69 }
70 }
71}
72
73#[derive(Debug, Serialize, Deserialize, Clone)]
74pub struct UpdateConfig {
75 pub check: bool,
77
78 pub interval_hours: u64,
80}
81
82impl Default for UpdateConfig {
83 fn default() -> Self {
84 Self { check: true, interval_hours: 24 }
85 }
86}
87
88#[derive(Debug, Serialize, Deserialize, Clone, Default)]
89pub struct AuthConfig {
90 pub github_token: Option<String>,
92
93 pub gitlab_token: Option<String>,
95
96 pub gitea_token: Option<String>,
98
99 pub forgejo_token: Option<String>,
101
102 pub codeberg_token: Option<String>,
104}
105
106#[derive(Debug, Serialize, Deserialize, Clone)]
107pub struct UserConfig {
108 pub name: Option<String>,
110
111 pub email: Option<String>,
113
114 pub editor: Option<String>,
116}
117
118#[derive(Debug, Serialize, Deserialize, Clone)]
119pub struct SnapshotConfig {
120 pub auto_enabled: bool,
122
123 pub auto_interval_minutes: u32,
125
126 pub retention_days: u32,
128
129 pub max_snapshots: Option<u32>,
131}
132
133#[derive(Debug, Serialize, Deserialize, Clone)]
134pub struct MirrorConfig {
135 pub autofetch_enabled: bool,
137
138 pub autofetch_interval_minutes: u32,
140
141 pub default_protocol: String,
143}
144
145#[derive(Debug, Serialize, Deserialize, Clone)]
146pub struct GitConfig {
147 pub default_branch: String,
149
150 pub sign_commits: bool,
152
153 pub gpg_key: Option<String>,
155
156 #[serde(default)]
160 pub gpg_program: Option<String>,
161
162 pub pull_rebase: bool,
164}
165
166#[derive(Debug, Serialize, Deserialize, Clone)]
167pub struct UiConfig {
168 pub colors: bool,
170
171 pub emoji: bool,
173
174 pub verbose: bool,
176
177 pub date_format: String,
179}
180
181impl Default for ToriiConfig {
182 fn default() -> Self {
183 Self {
184 user: UserConfig {
185 name: None,
186 email: None,
187 editor: std::env::var("EDITOR").ok(),
188 },
189 snapshot: SnapshotConfig {
190 auto_enabled: false,
191 auto_interval_minutes: 30,
192 retention_days: 30,
193 max_snapshots: Some(100),
194 },
195 mirror: MirrorConfig {
196 autofetch_enabled: false,
197 autofetch_interval_minutes: 30,
198 default_protocol: "ssh".to_string(),
199 },
200 git: GitConfig {
201 default_branch: "main".to_string(),
202 sign_commits: false,
203 gpg_key: None,
204 gpg_program: None,
205 pull_rebase: false,
206 },
207 ui: UiConfig {
208 colors: true,
209 emoji: true,
210 verbose: false,
211 date_format: "%Y-%m-%d %H:%M".to_string(),
212 },
213 auth: AuthConfig::default(),
214 update: UpdateConfig::default(),
215 worktree: WorktreeConfig::default(),
216 }
217 }
218}
219
220impl ToriiConfig {
221 fn global_config_path() -> Result<PathBuf> {
223 let config_dir = dirs::config_dir()
224 .ok_or_else(|| ToriiError::InvalidConfig("Could not determine config directory for this platform".to_string()))?
225 .join("torii");
226 fs::create_dir_all(&config_dir)?;
227 Ok(config_dir.join("config.toml"))
228 }
229
230 fn local_config_path<P: AsRef<Path>>(repo_path: P) -> Result<PathBuf> {
232 let torii_dir = repo_path.as_ref().join(".torii");
233 fs::create_dir_all(&torii_dir)?;
234 Ok(torii_dir.join("config.toml"))
235 }
236
237 pub fn load_global() -> Result<Self> {
239 let config_path = Self::global_config_path()?;
240
241 if !config_path.exists() {
242 return Ok(Self::default());
243 }
244
245 let config_str = fs::read_to_string(&config_path)?;
246 let config: ToriiConfig = toml::from_str(&config_str)
247 .map_err(|e| ToriiError::InvalidConfig(format!("Failed to parse config: {}", e)))?;
248
249 Ok(config)
250 }
251
252 pub fn load_local<P: AsRef<Path>>(repo_path: P) -> Result<Self> {
254 let mut config = Self::load_global()?;
255
256 let local_path = Self::local_config_path(&repo_path)?;
257 if local_path.exists() {
258 let local_str = fs::read_to_string(&local_path)?;
259 let local_config: ToriiConfig = toml::from_str(&local_str)
260 .map_err(|e| ToriiError::InvalidConfig(format!("Failed to parse local config: {}", e)))?;
261
262 config = Self::merge(config, local_config);
264 }
265
266 Ok(config)
267 }
268
269 pub fn save_global(&self) -> Result<()> {
271 let config_path = Self::global_config_path()?;
272 let config_str = toml::to_string_pretty(self)
273 .map_err(|e| ToriiError::InvalidConfig(format!("Failed to serialize config: {}", e)))?;
274 fs::write(&config_path, config_str)?;
275 Ok(())
276 }
277
278 pub fn save_local<P: AsRef<Path>>(&self, repo_path: P) -> Result<()> {
280 let config_path = Self::local_config_path(repo_path)?;
281 let config_str = toml::to_string_pretty(self)
282 .map_err(|e| ToriiError::InvalidConfig(format!("Failed to serialize config: {}", e)))?;
283 fs::write(&config_path, config_str)?;
284 Ok(())
285 }
286
287 fn merge(mut base: Self, overlay: Self) -> Self {
289 if overlay.user.name.is_some() {
291 base.user.name = overlay.user.name;
292 }
293 if overlay.user.email.is_some() {
294 base.user.email = overlay.user.email;
295 }
296 if overlay.user.editor.is_some() {
297 base.user.editor = overlay.user.editor;
298 }
299
300 base.snapshot = overlay.snapshot;
302
303 base.mirror = overlay.mirror;
305
306 if !overlay.git.default_branch.is_empty() && overlay.git.default_branch != "main" {
327 base.git.default_branch = overlay.git.default_branch;
328 }
329 base.git.sign_commits = base.git.sign_commits || overlay.git.sign_commits;
330 if overlay.git.gpg_key.is_some() { base.git.gpg_key = overlay.git.gpg_key; }
331 if overlay.git.gpg_program.is_some() { base.git.gpg_program = overlay.git.gpg_program; }
332 base.git.pull_rebase = base.git.pull_rebase || overlay.git.pull_rebase;
333
334 base.ui = overlay.ui;
336
337 if overlay.auth.github_token.is_some() { base.auth.github_token = overlay.auth.github_token; }
339 if overlay.auth.gitlab_token.is_some() { base.auth.gitlab_token = overlay.auth.gitlab_token; }
340 if overlay.auth.gitea_token.is_some() { base.auth.gitea_token = overlay.auth.gitea_token; }
341 if overlay.auth.forgejo_token.is_some() { base.auth.forgejo_token = overlay.auth.forgejo_token; }
342 if overlay.auth.codeberg_token.is_some() { base.auth.codeberg_token = overlay.auth.codeberg_token; }
343
344 base.worktree = overlay.worktree;
346
347 base
348 }
349
350 pub fn get(&self, key: &str) -> Option<String> {
352 let parts: Vec<&str> = key.split('.').collect();
353 if parts.len() != 2 {
354 return None;
355 }
356
357 match (parts[0], parts[1]) {
358 ("user", "name") => self.user.name.clone(),
359 ("user", "email") => self.user.email.clone(),
360 ("user", "editor") => self.user.editor.clone(),
361 ("snapshot", "auto_enabled") => Some(self.snapshot.auto_enabled.to_string()),
362 ("snapshot", "auto_interval_minutes") => Some(self.snapshot.auto_interval_minutes.to_string()),
363 ("snapshot", "retention_days") => Some(self.snapshot.retention_days.to_string()),
364 ("snapshot", "max_snapshots") => self.snapshot.max_snapshots.map(|v| v.to_string()),
365 ("mirror", "autofetch_enabled") => Some(self.mirror.autofetch_enabled.to_string()),
366 ("mirror", "autofetch_interval_minutes") => Some(self.mirror.autofetch_interval_minutes.to_string()),
367 ("mirror", "default_protocol") => Some(self.mirror.default_protocol.clone()),
368 ("git", "default_branch") => Some(self.git.default_branch.clone()),
369 ("git", "sign_commits") => Some(self.git.sign_commits.to_string()),
370 ("git", "gpg_key") => self.git.gpg_key.clone(),
371 ("git", "gpg_program") => self.git.gpg_program.clone(),
372 ("gpg", "program") => self.git.gpg_program.clone(),
374 ("user", "signingkey") => self.git.gpg_key.clone(),
376 ("commit", "gpgsign") => Some(self.git.sign_commits.to_string()),
377 ("git", "pull_rebase") => Some(self.git.pull_rebase.to_string()),
378 ("ui", "colors") => Some(self.ui.colors.to_string()),
379 ("ui", "emoji") => Some(self.ui.emoji.to_string()),
380 ("ui", "verbose") => Some(self.ui.verbose.to_string()),
381 ("ui", "date_format") => Some(self.ui.date_format.clone()),
382 ("auth", "github_token") => self.auth.github_token.clone().map(|_| "[set]".to_string()),
383 ("auth", "gitlab_token") => self.auth.gitlab_token.clone().map(|_| "[set]".to_string()),
384 ("auth", "gitea_token") => self.auth.gitea_token.clone().map(|_| "[set]".to_string()),
385 ("auth", "forgejo_token") => self.auth.forgejo_token.clone().map(|_| "[set]".to_string()),
386 ("auth", "codeberg_token") => self.auth.codeberg_token.clone().map(|_| "[set]".to_string()),
387 ("worktree", "base_dir") => Some(self.worktree.base_dir.clone()),
388 ("worktree", "inherit_paths") => {
389 if self.worktree.inherit_paths.is_empty() {
390 None
391 } else {
392 Some(self.worktree.inherit_paths.join(","))
393 }
394 }
395 _ => None,
396 }
397 }
398
399 pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
401 let parts: Vec<&str> = key.split('.').collect();
402 if parts.len() != 2 {
403 return Err(ToriiError::InvalidConfig(format!("Invalid config key: {}", key)));
404 }
405
406 match (parts[0], parts[1]) {
407 ("user", "name") => self.user.name = Some(value.to_string()),
408 ("user", "email") => self.user.email = Some(value.to_string()),
409 ("user", "editor") => self.user.editor = Some(value.to_string()),
410 ("snapshot", "auto_enabled") => {
411 self.snapshot.auto_enabled = value.parse()
412 .map_err(|_| ToriiError::InvalidConfig("Value must be true or false".to_string()))?;
413 }
414 ("snapshot", "auto_interval_minutes") => {
415 self.snapshot.auto_interval_minutes = value.parse()
416 .map_err(|_| ToriiError::InvalidConfig("Value must be a number".to_string()))?;
417 }
418 ("snapshot", "retention_days") => {
419 self.snapshot.retention_days = value.parse()
420 .map_err(|_| ToriiError::InvalidConfig("Value must be a number".to_string()))?;
421 }
422 ("snapshot", "max_snapshots") => {
423 self.snapshot.max_snapshots = Some(value.parse()
424 .map_err(|_| ToriiError::InvalidConfig("Value must be a number".to_string()))?);
425 }
426 ("mirror", "autofetch_enabled") => {
427 self.mirror.autofetch_enabled = value.parse()
428 .map_err(|_| ToriiError::InvalidConfig("Value must be true or false".to_string()))?;
429 }
430 ("mirror", "autofetch_interval_minutes") => {
431 self.mirror.autofetch_interval_minutes = value.parse()
432 .map_err(|_| ToriiError::InvalidConfig("Value must be a number".to_string()))?;
433 }
434 ("mirror", "default_protocol") => {
435 if value != "ssh" && value != "https" {
436 return Err(ToriiError::InvalidConfig("Protocol must be 'ssh' or 'https'".to_string()));
437 }
438 self.mirror.default_protocol = value.to_string();
439 }
440 ("git", "default_branch") => self.git.default_branch = value.to_string(),
441 ("git", "sign_commits") => {
442 self.git.sign_commits = value.parse()
443 .map_err(|_| ToriiError::InvalidConfig("Value must be true or false".to_string()))?;
444 }
445 ("git", "gpg_key") => self.git.gpg_key = Some(value.to_string()),
446 ("git", "gpg_program") => self.git.gpg_program = Some(value.to_string()),
447 ("user", "signingkey") => self.git.gpg_key = Some(value.to_string()),
451 ("gpg", "program") => self.git.gpg_program = Some(value.to_string()),
452 ("commit", "gpgsign") => {
453 self.git.sign_commits = value.parse()
454 .map_err(|_| ToriiError::InvalidConfig("Value must be true or false".to_string()))?;
455 }
456 ("git", "pull_rebase") => {
457 self.git.pull_rebase = value.parse()
458 .map_err(|_| ToriiError::InvalidConfig("Value must be true or false".to_string()))?;
459 }
460 ("ui", "colors") => {
461 self.ui.colors = value.parse()
462 .map_err(|_| ToriiError::InvalidConfig("Value must be true or false".to_string()))?;
463 }
464 ("ui", "emoji") => {
465 self.ui.emoji = value.parse()
466 .map_err(|_| ToriiError::InvalidConfig("Value must be true or false".to_string()))?;
467 }
468 ("ui", "verbose") => {
469 self.ui.verbose = value.parse()
470 .map_err(|_| ToriiError::InvalidConfig("Value must be true or false".to_string()))?;
471 }
472 ("ui", "date_format") => self.ui.date_format = value.to_string(),
473 ("auth", "github_token") => self.auth.github_token = Some(value.to_string()),
474 ("auth", "gitlab_token") => self.auth.gitlab_token = Some(value.to_string()),
475 ("auth", "gitea_token") => self.auth.gitea_token = Some(value.to_string()),
476 ("auth", "forgejo_token") => self.auth.forgejo_token = Some(value.to_string()),
477 ("auth", "codeberg_token") => self.auth.codeberg_token = Some(value.to_string()),
478 ("worktree", "base_dir") => {
479 if value.trim().is_empty() {
480 return Err(ToriiError::InvalidConfig(
481 "worktree.base_dir must not be empty (use '..' for sibling directories)".to_string(),
482 ));
483 }
484 self.worktree.base_dir = value.to_string();
485 }
486 ("worktree", "inherit_paths") => {
487 self.worktree.inherit_paths = if value.trim().is_empty() {
489 Vec::new()
490 } else {
491 value
492 .split(',')
493 .map(|s| s.trim().to_string())
494 .filter(|s| !s.is_empty())
495 .collect()
496 };
497 }
498 _ => return Err(ToriiError::InvalidConfig(format!("Unknown config key: {}", key))),
499 }
500
501 Ok(())
502 }
503
504 pub fn list(&self) -> Vec<(String, String)> {
506 let mut items = Vec::new();
507
508 if let Some(name) = &self.user.name {
510 items.push(("user.name".to_string(), name.clone()));
511 }
512 if let Some(email) = &self.user.email {
513 items.push(("user.email".to_string(), email.clone()));
514 }
515 if let Some(editor) = &self.user.editor {
516 items.push(("user.editor".to_string(), editor.clone()));
517 }
518
519 items.push(("snapshot.auto_enabled".to_string(), self.snapshot.auto_enabled.to_string()));
521 items.push(("snapshot.auto_interval_minutes".to_string(), self.snapshot.auto_interval_minutes.to_string()));
522 items.push(("snapshot.retention_days".to_string(), self.snapshot.retention_days.to_string()));
523 if let Some(max) = self.snapshot.max_snapshots {
524 items.push(("snapshot.max_snapshots".to_string(), max.to_string()));
525 }
526
527 items.push(("mirror.autofetch_enabled".to_string(), self.mirror.autofetch_enabled.to_string()));
529 items.push(("mirror.autofetch_interval_minutes".to_string(), self.mirror.autofetch_interval_minutes.to_string()));
530 items.push(("mirror.default_protocol".to_string(), self.mirror.default_protocol.clone()));
531
532 items.push(("git.default_branch".to_string(), self.git.default_branch.clone()));
534 items.push(("git.sign_commits".to_string(), self.git.sign_commits.to_string()));
535 if let Some(key) = &self.git.gpg_key {
536 items.push(("git.gpg_key".to_string(), key.clone()));
537 }
538 if let Some(p) = &self.git.gpg_program {
539 items.push(("git.gpg_program".to_string(), p.clone()));
540 }
541 items.push(("git.pull_rebase".to_string(), self.git.pull_rebase.to_string()));
542
543 items.push(("ui.colors".to_string(), self.ui.colors.to_string()));
545 items.push(("ui.emoji".to_string(), self.ui.emoji.to_string()));
546 items.push(("ui.verbose".to_string(), self.ui.verbose.to_string()));
547 items.push(("ui.date_format".to_string(), self.ui.date_format.clone()));
548
549 items.push(("auth.github_token".to_string(), if self.auth.github_token.is_some() { "[set]".to_string() } else { "[not set]".to_string() }));
551 items.push(("auth.gitlab_token".to_string(), if self.auth.gitlab_token.is_some() { "[set]".to_string() } else { "[not set]".to_string() }));
552 items.push(("auth.gitea_token".to_string(), if self.auth.gitea_token.is_some() { "[set]".to_string() } else { "[not set]".to_string() }));
553 items.push(("auth.forgejo_token".to_string(), if self.auth.forgejo_token.is_some() { "[set]".to_string() } else { "[not set]".to_string() }));
554 items.push(("auth.codeberg_token".to_string(), if self.auth.codeberg_token.is_some() { "[set]".to_string() } else { "[not set]".to_string() }));
555
556 items.push(("worktree.base_dir".to_string(), self.worktree.base_dir.clone()));
558 if !self.worktree.inherit_paths.is_empty() {
559 items.push((
560 "worktree.inherit_paths".to_string(),
561 self.worktree.inherit_paths.join(","),
562 ));
563 }
564
565 items
566 }
567}