Skip to main content

git_same/types/
repo.rs

1//! Repository and organization type definitions.
2//!
3//! These types represent the data structures returned by Git hosting provider APIs
4//! and used internally for clone/sync planning.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9/// A GitHub/GitLab/Bitbucket organization.
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11pub struct Org {
12    /// Organization login/username (e.g., "rust-lang")
13    pub login: String,
14    /// Unique ID from the provider
15    pub id: u64,
16    /// Optional description
17    #[serde(default)]
18    pub description: Option<String>,
19}
20
21impl Org {
22    /// Creates a new organization with just login and id.
23    pub fn new(login: impl Into<String>, id: u64) -> Self {
24        Self {
25            login: login.into(),
26            id,
27            description: None,
28        }
29    }
30}
31
32/// A repository from a Git hosting provider.
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
34pub struct Repo {
35    /// Unique ID from the provider
36    pub id: u64,
37    /// Repository name (e.g., "gisa")
38    pub name: String,
39    /// Full name including owner (e.g., "user/gisa")
40    pub full_name: String,
41    /// SSH clone URL (e.g., "git@github.com:user/gisa.git")
42    pub ssh_url: String,
43    /// HTTPS clone URL (e.g., "https://github.com/user/gisa.git")
44    pub clone_url: String,
45    /// Default branch name (e.g., "main")
46    pub default_branch: String,
47    /// Whether this is a private repository
48    #[serde(default)]
49    pub private: bool,
50    /// Whether this repository is archived (read-only)
51    #[serde(default)]
52    pub archived: bool,
53    /// Whether this is a fork of another repository
54    #[serde(default)]
55    pub fork: bool,
56    /// When the repository was last pushed to
57    #[serde(default)]
58    pub pushed_at: Option<DateTime<Utc>>,
59    /// Optional description
60    #[serde(default)]
61    pub description: Option<String>,
62}
63
64impl Repo {
65    /// Creates a minimal repo for testing.
66    #[cfg(test)]
67    pub fn test(name: &str, owner: &str) -> Self {
68        Self {
69            id: rand_id(),
70            name: name.to_string(),
71            full_name: format!("{}/{}", owner, name),
72            ssh_url: format!("git@github.com:{}/{}.git", owner, name),
73            clone_url: format!("https://github.com/{}/{}.git", owner, name),
74            default_branch: "main".to_string(),
75            private: false,
76            archived: false,
77            fork: false,
78            pushed_at: None,
79            description: None,
80        }
81    }
82
83    /// Returns the owner from the full_name.
84    pub fn owner(&self) -> &str {
85        self.full_name.split('/').next().unwrap_or(&self.full_name)
86    }
87}
88
89#[cfg(test)]
90fn rand_id() -> u64 {
91    use std::sync::atomic::{AtomicU64, Ordering};
92
93    static COUNTER: AtomicU64 = AtomicU64::new(1);
94    COUNTER.fetch_add(1, Ordering::Relaxed)
95}
96
97/// A repository with its owner information.
98///
99/// This type pairs a repository with the owner that it was discovered under,
100/// which may be an organization or the user's personal account.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct OwnedRepo {
103    /// Organization name or username
104    pub owner: String,
105    /// The repository
106    pub repo: Repo,
107}
108
109impl OwnedRepo {
110    /// Creates a new owned repo.
111    pub fn new(owner: impl Into<String>, repo: Repo) -> Self {
112        Self {
113            owner: owner.into(),
114            repo,
115        }
116    }
117
118    /// Returns the full path for this repo (e.g., "org/repo").
119    pub fn full_name(&self) -> &str {
120        &self.repo.full_name
121    }
122
123    /// Returns the repository name.
124    pub fn name(&self) -> &str {
125        &self.repo.name
126    }
127}
128
129/// Result of comparing discovered repos with local filesystem.
130///
131/// This represents the action plan for a clone/sync operation.
132#[derive(Debug, Default)]
133pub struct ActionPlan {
134    /// New repositories that need to be cloned
135    pub to_clone: Vec<OwnedRepo>,
136    /// Existing repositories that should be synced
137    pub to_sync: Vec<OwnedRepo>,
138    /// Repositories that were skipped (already exist, uncommitted changes, etc.)
139    pub skipped: Vec<SkippedRepo>,
140}
141
142impl ActionPlan {
143    /// Creates an empty action plan.
144    pub fn new() -> Self {
145        Self::default()
146    }
147
148    /// Returns the total number of repositories in the plan.
149    pub fn total(&self) -> usize {
150        self.to_clone.len() + self.to_sync.len() + self.skipped.len()
151    }
152
153    /// Returns true if there's nothing to do.
154    pub fn is_empty(&self) -> bool {
155        self.to_clone.is_empty() && self.to_sync.is_empty()
156    }
157
158    /// Adds a repo to clone.
159    pub fn add_clone(&mut self, repo: OwnedRepo) {
160        self.to_clone.push(repo);
161    }
162
163    /// Adds a repo to sync.
164    pub fn add_sync(&mut self, repo: OwnedRepo) {
165        self.to_sync.push(repo);
166    }
167
168    /// Adds a skipped repo.
169    pub fn add_skipped(&mut self, repo: OwnedRepo, reason: impl Into<String>) {
170        self.skipped.push(SkippedRepo {
171            repo,
172            reason: reason.into(),
173        });
174    }
175}
176
177/// A repository that was skipped during planning.
178#[derive(Debug)]
179pub struct SkippedRepo {
180    /// The repository that was skipped
181    pub repo: OwnedRepo,
182    /// Reason for skipping
183    pub reason: String,
184}
185
186/// Outcome of a single clone or sync operation.
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub enum OpResult {
189    /// Operation completed successfully
190    Success,
191    /// Operation failed with an error
192    Failed(String),
193    /// Operation was skipped for a reason
194    Skipped(String),
195}
196
197impl OpResult {
198    /// Returns true if the operation was successful.
199    pub fn is_success(&self) -> bool {
200        matches!(self, OpResult::Success)
201    }
202
203    /// Returns true if the operation failed.
204    pub fn is_failed(&self) -> bool {
205        matches!(self, OpResult::Failed(_))
206    }
207
208    /// Returns true if the operation was skipped.
209    pub fn is_skipped(&self) -> bool {
210        matches!(self, OpResult::Skipped(_))
211    }
212
213    /// Returns the error message if failed.
214    pub fn error_message(&self) -> Option<&str> {
215        match self {
216            OpResult::Failed(msg) => Some(msg),
217            _ => None,
218        }
219    }
220
221    /// Returns the skip reason if skipped.
222    pub fn skip_reason(&self) -> Option<&str> {
223        match self {
224            OpResult::Skipped(reason) => Some(reason),
225            _ => None,
226        }
227    }
228}
229
230/// Summary statistics for a batch operation.
231#[derive(Debug, Default, Clone)]
232pub struct OpSummary {
233    /// Number of successful operations
234    pub success: usize,
235    /// Number of failed operations
236    pub failed: usize,
237    /// Number of skipped operations
238    pub skipped: usize,
239}
240
241impl OpSummary {
242    /// Creates an empty summary.
243    pub fn new() -> Self {
244        Self::default()
245    }
246
247    /// Records a result.
248    pub fn record(&mut self, result: &OpResult) {
249        match result {
250            OpResult::Success => self.success += 1,
251            OpResult::Failed(_) => self.failed += 1,
252            OpResult::Skipped(_) => self.skipped += 1,
253        }
254    }
255
256    /// Returns the total number of operations.
257    pub fn total(&self) -> usize {
258        self.success + self.failed + self.skipped
259    }
260
261    /// Returns true if there were any failures.
262    pub fn has_failures(&self) -> bool {
263        self.failed > 0
264    }
265}
266
267#[cfg(test)]
268#[path = "repo_tests.rs"]
269mod tests;