1use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6use tracing::debug;
7
8use crate::error::{OpsError, OpsResult};
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(tag = "type", rename_all = "snake_case")]
13pub enum WorkspaceKind {
14 SingleRepo,
16 MultiRepo { repo_count: usize },
18 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#[derive(Debug, Clone, Serialize, Deserialize)]
36#[serde(tag = "type", rename_all = "snake_case")]
37pub enum SyncSource {
38 Local { path: PathBuf },
40 GitHubOrg { org: String },
42 GitHubRepo { owner: String, repo: String },
44}
45
46impl SyncSource {
47 pub fn local(path: impl Into<PathBuf>) -> Self {
49 Self::Local { path: path.into() }
50 }
51
52 pub fn github_org(org: impl Into<String>) -> Self {
54 Self::GitHubOrg { org: org.into() }
55 }
56
57 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 pub fn detect(input: &str) -> Self {
73 let input = input.trim();
74
75 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 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 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 pub fn is_remote(&self) -> bool {
125 matches!(self, Self::GitHubOrg { .. } | Self::GitHubRepo { .. })
126 }
127
128 pub fn is_local(&self) -> bool {
130 matches!(self, Self::Local { .. })
131 }
132
133 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#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct WorkspaceInfo {
155 pub root: PathBuf,
157 pub kind: WorkspaceKind,
159 pub repo_paths: Vec<PathBuf>,
161 pub name: String,
163}
164
165impl WorkspaceInfo {
166 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 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 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 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 pub fn is_single_repo(&self) -> bool {
228 matches!(self.kind, WorkspaceKind::SingleRepo)
229 }
230
231 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}