Skip to main content

vibe_graph_ops/
workspace.rs

1//! Workspace detection and sync source types.
2
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6use tracing::debug;
7
8use crate::error::{OpsError, OpsResult};
9
10/// Detected workspace type based on directory structure.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(tag = "type", rename_all = "snake_case")]
13pub enum WorkspaceKind {
14    /// Single git repository (has .git in root)
15    SingleRepo,
16    /// Multiple repositories in subdirectories
17    MultiRepo { repo_count: usize },
18    /// Plain directory without git
19    PlainDirectory,
20}
21
22impl std::fmt::Display for WorkspaceKind {
23    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24        match self {
25            WorkspaceKind::SingleRepo => write!(f, "single repository"),
26            WorkspaceKind::MultiRepo { repo_count } => {
27                write!(f, "workspace with {} repositories", repo_count)
28            }
29            WorkspaceKind::PlainDirectory => write!(f, "plain directory"),
30        }
31    }
32}
33
34/// The source type for a sync operation.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36#[serde(tag = "type", rename_all = "snake_case")]
37pub enum SyncSource {
38    /// A local filesystem path.
39    Local { path: PathBuf },
40    /// A GitHub organization (clone all repos).
41    GitHubOrg { org: String },
42    /// A single GitHub repository.
43    GitHubRepo { owner: String, repo: String },
44}
45
46impl SyncSource {
47    /// Create a local sync source.
48    pub fn local(path: impl Into<PathBuf>) -> Self {
49        Self::Local { path: path.into() }
50    }
51
52    /// Create a GitHub org sync source.
53    pub fn github_org(org: impl Into<String>) -> Self {
54        Self::GitHubOrg { org: org.into() }
55    }
56
57    /// Create a GitHub repo sync source.
58    pub fn github_repo(owner: impl Into<String>, repo: impl Into<String>) -> Self {
59        Self::GitHubRepo {
60            owner: owner.into(),
61            repo: repo.into(),
62        }
63    }
64
65    /// Parse an input string and detect the source type.
66    ///
67    /// Detection rules:
68    /// - Starts with `.`, `/`, or `~` → local path
69    /// - Contains `/` with two segments → `owner/repo`
70    /// - Single segment without path chars → org name
71    /// - Full GitHub URL → extract owner/repo or org
72    pub fn detect(input: &str) -> Self {
73        let input = input.trim();
74
75        // Check for explicit local path indicators
76        if input.starts_with('.')
77            || input.starts_with('/')
78            || input.starts_with('~')
79            || PathBuf::from(input).exists()
80        {
81            return Self::Local {
82                path: PathBuf::from(input),
83            };
84        }
85
86        // Clean up GitHub URLs
87        let cleaned = input
88            .trim_start_matches("https://")
89            .trim_start_matches("http://")
90            .trim_start_matches("git@github.com:")
91            .trim_start_matches("github.com/")
92            .trim_end_matches('/')
93            .trim_end_matches(".git");
94
95        let parts: Vec<&str> = cleaned.split('/').filter(|s| !s.is_empty()).collect();
96
97        match parts.len() {
98            0 => Self::Local {
99                path: PathBuf::from("."),
100            },
101            1 => {
102                // Single segment: could be org name or local dir
103                let path = PathBuf::from(input);
104                if path.exists() {
105                    Self::Local { path }
106                } else {
107                    Self::GitHubOrg {
108                        org: parts[0].to_string(),
109                    }
110                }
111            }
112            2 => Self::GitHubRepo {
113                owner: parts[0].to_string(),
114                repo: parts[1].to_string(),
115            },
116            _ => Self::GitHubRepo {
117                owner: parts[0].to_string(),
118                repo: parts[1].to_string(),
119            },
120        }
121    }
122
123    /// Check if this is a remote source (requires GitHub API).
124    pub fn is_remote(&self) -> bool {
125        matches!(self, Self::GitHubOrg { .. } | Self::GitHubRepo { .. })
126    }
127
128    /// Check if this is a local source.
129    pub fn is_local(&self) -> bool {
130        matches!(self, Self::Local { .. })
131    }
132
133    /// Get the local path if this is a local source.
134    pub fn local_path(&self) -> Option<&Path> {
135        match self {
136            Self::Local { path } => Some(path.as_path()),
137            _ => None,
138        }
139    }
140}
141
142impl std::fmt::Display for SyncSource {
143    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144        match self {
145            SyncSource::Local { path } => write!(f, "local:{}", path.display()),
146            SyncSource::GitHubOrg { org } => write!(f, "github-org:{}", org),
147            SyncSource::GitHubRepo { owner, repo } => write!(f, "github:{}/{}", owner, repo),
148        }
149    }
150}
151
152/// Result of workspace detection.
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct WorkspaceInfo {
155    /// Root path of the workspace.
156    pub root: PathBuf,
157    /// Detected workspace kind.
158    pub kind: WorkspaceKind,
159    /// Paths to git repositories found.
160    pub repo_paths: Vec<PathBuf>,
161    /// Name derived from directory.
162    pub name: String,
163}
164
165impl WorkspaceInfo {
166    /// Detect the workspace type for a given path.
167    pub fn detect(path: &Path) -> OpsResult<Self> {
168        let root = path.canonicalize().map_err(|e| OpsError::PathResolution {
169            path: path.to_path_buf(),
170            message: e.to_string(),
171        })?;
172
173        let name = root
174            .file_name()
175            .map(|s| s.to_string_lossy().to_string())
176            .unwrap_or_else(|| "workspace".to_string());
177
178        // Check if root is a git repo
179        if root.join(".git").exists() {
180            debug!(path = %root.display(), "Detected single git repository");
181            return Ok(WorkspaceInfo {
182                root: root.clone(),
183                kind: WorkspaceKind::SingleRepo,
184                repo_paths: vec![root],
185                name,
186            });
187        }
188
189        // Check for subdirectories that are git repos
190        let mut repo_paths = Vec::new();
191        if let Ok(entries) = std::fs::read_dir(&root) {
192            for entry in entries.filter_map(|e| e.ok()) {
193                let entry_path = entry.path();
194                if entry_path.is_dir() && entry_path.join(".git").exists() {
195                    repo_paths.push(entry_path);
196                }
197            }
198        }
199
200        if !repo_paths.is_empty() {
201            repo_paths.sort();
202            let repo_count = repo_paths.len();
203            debug!(
204                path = %root.display(),
205                repo_count,
206                "Detected multi-repo workspace"
207            );
208            return Ok(WorkspaceInfo {
209                root,
210                kind: WorkspaceKind::MultiRepo { repo_count },
211                repo_paths,
212                name,
213            });
214        }
215
216        // Plain directory
217        debug!(path = %root.display(), "Detected plain directory");
218        Ok(WorkspaceInfo {
219            root: root.clone(),
220            kind: WorkspaceKind::PlainDirectory,
221            repo_paths: vec![root],
222            name,
223        })
224    }
225
226    /// Check if this is a single repository workspace.
227    pub fn is_single_repo(&self) -> bool {
228        matches!(self.kind, WorkspaceKind::SingleRepo)
229    }
230
231    /// Check if this is a multi-repo workspace.
232    pub fn is_multi_repo(&self) -> bool {
233        matches!(self.kind, WorkspaceKind::MultiRepo { .. })
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn test_sync_source_detect_local_explicit() {
243        match SyncSource::detect("./my-project") {
244            SyncSource::Local { path } => assert_eq!(path, PathBuf::from("./my-project")),
245            other => panic!("Expected Local, got {:?}", other),
246        }
247
248        match SyncSource::detect("/absolute/path") {
249            SyncSource::Local { path } => assert_eq!(path, PathBuf::from("/absolute/path")),
250            other => panic!("Expected Local, got {:?}", other),
251        }
252    }
253
254    #[test]
255    fn test_sync_source_detect_github_repo() {
256        match SyncSource::detect("pinsky-three/vibe-graph") {
257            SyncSource::GitHubRepo { owner, repo } => {
258                assert_eq!(owner, "pinsky-three");
259                assert_eq!(repo, "vibe-graph");
260            }
261            other => panic!("Expected GitHubRepo, got {:?}", other),
262        }
263    }
264
265    #[test]
266    fn test_sync_source_detect_github_url() {
267        match SyncSource::detect("https://github.com/pinsky-three/vibe-graph") {
268            SyncSource::GitHubRepo { owner, repo } => {
269                assert_eq!(owner, "pinsky-three");
270                assert_eq!(repo, "vibe-graph");
271            }
272            other => panic!("Expected GitHubRepo, got {:?}", other),
273        }
274    }
275}