Skip to main content

git_same/config/
workspace.rs

1//! Workspace configuration.
2//!
3//! Each workspace represents a sync target folder with its own provider,
4//! selected organizations, and repository filters. Workspace config lives
5//! inside the sync folder itself at `<root>/.git-same/config.toml`, making
6//! workspaces portable and self-describing.
7
8use super::{ConfigCloneOptions, FilterOptions, SyncMode};
9use crate::types::ProviderKind;
10use serde::{Deserialize, Serialize};
11use std::path::{Path, PathBuf};
12
13/// Provider configuration scoped to a single workspace.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct WorkspaceProvider {
16    /// The type of provider (github, gitlab, etc.)
17    #[serde(default)]
18    pub kind: ProviderKind,
19
20    /// API base URL (required for GitHub Enterprise)
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub api_url: Option<String>,
23
24    /// Whether to prefer SSH for cloning (default: true)
25    #[serde(default = "default_true")]
26    pub prefer_ssh: bool,
27}
28
29fn default_true() -> bool {
30    true
31}
32
33impl Default for WorkspaceProvider {
34    fn default() -> Self {
35        Self {
36            kind: ProviderKind::GitHub,
37            api_url: None,
38            prefer_ssh: true,
39        }
40    }
41}
42
43impl WorkspaceProvider {
44    /// Returns the effective API URL for this provider.
45    pub fn effective_api_url(&self) -> String {
46        self.api_url
47            .clone()
48            .unwrap_or_else(|| self.kind.default_api_url().to_string())
49    }
50
51    /// Returns the display name for this provider.
52    pub fn display_name(&self) -> &str {
53        self.kind.display_name()
54    }
55}
56
57/// Configuration for a single workspace (sync target folder).
58///
59/// Stored at `<root>/.git-same/config.toml`. The `root_path` field is not
60/// serialized — it is populated at load time from the `.git-same/` parent.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct WorkspaceConfig {
63    /// Absolute path to the workspace root (parent of `.git-same/`).
64    ///
65    /// Not stored in config.toml — derived from the file's location at load time.
66    #[serde(skip)]
67    pub root_path: PathBuf,
68
69    /// Provider configuration for this workspace.
70    pub provider: WorkspaceProvider,
71
72    /// The authenticated username (discovered during setup).
73    #[serde(default)]
74    pub username: String,
75
76    /// Selected organizations to sync (empty = all).
77    #[serde(default)]
78    pub orgs: Vec<String>,
79
80    /// Specific repos to include (empty = all from selected orgs).
81    #[serde(default)]
82    pub include_repos: Vec<String>,
83
84    /// Repos to exclude by full name (e.g., "org/repo").
85    #[serde(default)]
86    pub exclude_repos: Vec<String>,
87
88    /// Directory structure pattern override (None = use global default).
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub structure: Option<String>,
91
92    /// Sync mode override (None = use global default).
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub sync_mode: Option<SyncMode>,
95
96    /// Clone options override (None = use global default).
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    #[serde(rename = "clone")]
99    pub clone_options: Option<ConfigCloneOptions>,
100
101    /// Filter options.
102    #[serde(default)]
103    pub filters: FilterOptions,
104
105    /// Concurrency override (None = use global default).
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub concurrency: Option<usize>,
108
109    /// Dashboard auto-refresh interval override in seconds (None = use global default).
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub refresh_interval: Option<u64>,
112
113    /// ISO 8601 timestamp of last sync.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub last_synced: Option<String>,
116}
117
118impl WorkspaceConfig {
119    /// Create a new workspace config for the given root directory.
120    pub fn new_from_root(root: &Path) -> Self {
121        Self {
122            root_path: root.to_path_buf(),
123            provider: WorkspaceProvider::default(),
124            username: String::new(),
125            orgs: Vec::new(),
126            include_repos: Vec::new(),
127            exclude_repos: Vec::new(),
128            structure: None,
129            sync_mode: None,
130            clone_options: None,
131            filters: FilterOptions::default(),
132            concurrency: None,
133            refresh_interval: None,
134            last_synced: None,
135        }
136    }
137
138    /// Returns the workspace root path (equivalent of old `expanded_base_path()`).
139    pub fn expanded_base_path(&self) -> PathBuf {
140        self.root_path.clone()
141    }
142
143    /// Returns a user-friendly label: `"~/repos (GitHub)"`.
144    pub fn display_label(&self) -> String {
145        let path_str = tilde_collapse_path(&self.root_path);
146        format!("{} ({})", path_str, self.provider.kind.display_name())
147    }
148
149    /// Returns a short display summary for selectors.
150    pub fn summary(&self) -> String {
151        let orgs = if self.orgs.is_empty() {
152            "all orgs".to_string()
153        } else {
154            format!("{} org(s)", self.orgs.len())
155        };
156        let synced = self.last_synced.as_deref().unwrap_or("never synced");
157        format!("{} ({}, {})", self.display_label(), orgs, synced)
158    }
159
160    /// Serialize to TOML string.
161    pub fn to_toml(&self) -> Result<String, crate::errors::AppError> {
162        toml::to_string_pretty(self).map_err(|e| {
163            crate::errors::AppError::config(format!("Failed to serialize workspace config: {}", e))
164        })
165    }
166
167    /// Parse from TOML string.
168    pub fn from_toml(content: &str) -> Result<Self, crate::errors::AppError> {
169        toml::from_str(content).map_err(|e| {
170            crate::errors::AppError::config(format!("Failed to parse workspace config: {}", e))
171        })
172    }
173}
174
175/// Collapse the home directory prefix to `~` for display.
176pub fn tilde_collapse_path(path: &Path) -> String {
177    let home = std::env::var("HOME")
178        .or_else(|_| std::env::var("USERPROFILE"))
179        .ok();
180
181    if let Some(home) = home {
182        let home_path = Path::new(&home);
183        if let Ok(suffix) = path.strip_prefix(home_path) {
184            if suffix.as_os_str().is_empty() {
185                return "~".to_string();
186            }
187            return format!("~{}{}", std::path::MAIN_SEPARATOR, suffix.to_string_lossy());
188        }
189    }
190
191    path.to_string_lossy().to_string()
192}
193
194#[cfg(test)]
195#[path = "workspace_tests.rs"]
196mod tests;