Skip to main content

mockforge_core/
git_watch.rs

1//! Git Watch Mode
2//!
3//! Monitors a Git repository for OpenAPI spec changes and auto-syncs mocks.
4//! This enables contract-driven mocking where mocks stay in sync with API specifications.
5
6use crate::Error;
7use crate::Result;
8use serde::{Deserialize, Serialize};
9use std::path::{Path, PathBuf};
10use std::time::Duration;
11use tokio::time::interval;
12use tracing::{debug, error, info, warn};
13
14/// Git watch configuration
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct GitWatchConfig {
17    /// Repository URL (HTTPS or SSH)
18    pub repository_url: String,
19    /// Branch to watch (default: "main")
20    #[serde(default = "default_branch")]
21    pub branch: String,
22    /// Path to OpenAPI spec file(s) in the repository
23    /// Supports glob patterns (e.g., "**/*.yaml", "specs/*.json")
24    pub spec_paths: Vec<String>,
25    /// Polling interval in seconds (default: 60)
26    #[serde(default = "default_poll_interval")]
27    pub poll_interval_seconds: u64,
28    /// Authentication token for private repositories (optional)
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub auth_token: Option<String>,
31    /// Local cache directory for cloned repository
32    #[serde(default = "default_cache_dir")]
33    pub cache_dir: PathBuf,
34    /// Whether to enable watch mode (default: true)
35    #[serde(default = "default_true")]
36    pub enabled: bool,
37}
38
39fn default_branch() -> String {
40    "main".to_string()
41}
42
43fn default_poll_interval() -> u64 {
44    60
45}
46
47fn default_cache_dir() -> PathBuf {
48    PathBuf::from("./.mockforge-git-cache")
49}
50
51fn default_true() -> bool {
52    true
53}
54
55/// Git watch service that monitors a repository for changes
56pub struct GitWatchService {
57    config: GitWatchConfig,
58    last_commit: Option<String>,
59    repo_path: PathBuf,
60}
61
62impl GitWatchService {
63    /// Create a new Git watch service
64    pub fn new(config: GitWatchConfig) -> Result<Self> {
65        // Create cache directory if it doesn't exist
66        std::fs::create_dir_all(&config.cache_dir).map_err(|e| {
67            Error::io_with_context(
68                format!("creating cache directory {}", config.cache_dir.display()),
69                e.to_string(),
70            )
71        })?;
72
73        // Generate repository path from URL
74        let repo_name = Self::extract_repo_name(&config.repository_url)?;
75        let repo_path = config.cache_dir.join(repo_name);
76
77        Ok(Self {
78            config,
79            last_commit: None,
80            repo_path,
81        })
82    }
83
84    /// Extract repository name from URL
85    fn extract_repo_name(url: &str) -> Result<String> {
86        // Handle various URL formats:
87        // - https://github.com/user/repo.git
88        // - git@github.com:user/repo.git
89        // - https://github.com/user/repo
90        let name = if let Some(stripped) = url.strip_suffix(".git") {
91            stripped
92        } else {
93            url
94        };
95
96        // Extract the last component
97        let parts: Vec<&str> = name.split('/').collect();
98        if let Some(last) = parts.last() {
99            // Remove any query parameters or fragments
100            let clean = last.split('?').next().unwrap_or(last);
101            Ok(clean.to_string())
102        } else {
103            Err(Error::config(format!("Invalid repository URL: {}", url)))
104        }
105    }
106
107    /// Initialize the repository (clone if needed, update if exists)
108    pub async fn initialize(&mut self) -> Result<()> {
109        info!(
110            "Initializing Git watch for repository: {} (branch: {})",
111            self.config.repository_url, self.config.branch
112        );
113
114        if self.repo_path.exists() {
115            debug!("Repository exists, updating...");
116            self.update_repository().await?;
117        } else {
118            debug!("Repository does not exist, cloning...");
119            self.clone_repository().await?;
120        }
121
122        // Get initial commit hash
123        self.last_commit = Some(self.get_current_commit()?);
124
125        info!("Git watch initialized successfully");
126        Ok(())
127    }
128
129    /// Clone the repository
130    async fn clone_repository(&self) -> Result<()> {
131        use std::process::Command;
132
133        let url = if let Some(ref token) = self.config.auth_token {
134            self.inject_auth_token(&self.config.repository_url, token)?
135        } else {
136            self.config.repository_url.clone()
137        };
138
139        let output = Command::new("git")
140            .args([
141                "clone",
142                "--branch",
143                &self.config.branch,
144                "--depth",
145                "1", // Shallow clone for performance
146                &url,
147                self.repo_path.to_str().unwrap(),
148            ])
149            .output()
150            .map_err(|e| Error::io_with_context("executing git clone", e.to_string()))?;
151
152        if !output.status.success() {
153            let stderr = String::from_utf8_lossy(&output.stderr);
154            return Err(Error::io_with_context("git clone", stderr.to_string()));
155        }
156
157        info!("Repository cloned successfully");
158        Ok(())
159    }
160
161    /// Update the repository (fetch and checkout)
162    async fn update_repository(&self) -> Result<()> {
163        use std::process::Command;
164
165        let repo_path_str = self.repo_path.to_str().unwrap();
166
167        // Fetch latest changes
168        let output = Command::new("git")
169            .args(["-C", repo_path_str, "fetch", "origin", &self.config.branch])
170            .output()
171            .map_err(|e| Error::io_with_context("executing git fetch", e.to_string()))?;
172
173        if !output.status.success() {
174            let stderr = String::from_utf8_lossy(&output.stderr);
175            warn!("Git fetch failed: {}", stderr);
176            // Continue anyway, might be network issue
177        }
178
179        // Reset to remote branch
180        let output = Command::new("git")
181            .args([
182                "-C",
183                repo_path_str,
184                "reset",
185                "--hard",
186                &format!("origin/{}", self.config.branch),
187            ])
188            .output()
189            .map_err(|e| Error::io_with_context("executing git reset", e.to_string()))?;
190
191        if !output.status.success() {
192            let stderr = String::from_utf8_lossy(&output.stderr);
193            return Err(Error::io_with_context("git reset", stderr.to_string()));
194        }
195
196        debug!("Repository updated successfully");
197        Ok(())
198    }
199
200    /// Get current commit hash
201    fn get_current_commit(&self) -> Result<String> {
202        use std::process::Command;
203
204        let output = Command::new("git")
205            .args(["-C", self.repo_path.to_str().unwrap(), "rev-parse", "HEAD"])
206            .output()
207            .map_err(|e| Error::io_with_context("executing git rev-parse", e.to_string()))?;
208
209        if !output.status.success() {
210            let stderr = String::from_utf8_lossy(&output.stderr);
211            return Err(Error::io_with_context("git rev-parse", stderr.to_string()));
212        }
213
214        let commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
215        Ok(commit)
216    }
217
218    /// Inject authentication token into repository URL
219    fn inject_auth_token(&self, url: &str, token: &str) -> Result<String> {
220        // Handle HTTPS URLs
221        if url.starts_with("https://") {
222            // Insert token before the hostname
223            // https://github.com/user/repo -> https://token@github.com/user/repo
224            if let Some(rest) = url.strip_prefix("https://") {
225                return Ok(format!("https://{}@{}", token, rest));
226            }
227        }
228        // For SSH URLs, token injection is more complex and typically uses SSH keys
229        // For now, return the original URL and log a warning
230        if url.contains('@') {
231            warn!("SSH URL detected. Token authentication may not work. Consider using HTTPS or SSH keys.");
232        }
233        Ok(url.to_string())
234    }
235
236    /// Check for changes in the repository
237    pub async fn check_for_changes(&mut self) -> Result<bool> {
238        // Update repository
239        self.update_repository().await?;
240
241        // Get current commit
242        let current_commit = self.get_current_commit()?;
243
244        // Compare with last known commit
245        if let Some(ref last) = self.last_commit {
246            if last == &current_commit {
247                debug!("No changes detected (commit: {})", &current_commit[..8]);
248                return Ok(false);
249            }
250        }
251
252        info!(
253            "Changes detected! Previous: {}, Current: {}",
254            self.last_commit.as_ref().map(|c| &c[..8]).unwrap_or("none"),
255            &current_commit[..8]
256        );
257
258        // Update last commit
259        self.last_commit = Some(current_commit);
260
261        Ok(true)
262    }
263
264    /// Get paths to OpenAPI spec files
265    pub fn get_spec_files(&self) -> Result<Vec<PathBuf>> {
266        use globwalk::GlobWalkerBuilder;
267
268        let mut spec_files = Vec::new();
269
270        for pattern in &self.config.spec_paths {
271            let walker = GlobWalkerBuilder::from_patterns(&self.repo_path, &[pattern])
272                .build()
273                .map_err(|e| {
274                    Error::io_with_context(
275                        format!("building glob walker for {}", pattern),
276                        e.to_string(),
277                    )
278                })?;
279
280            for entry in walker {
281                match entry {
282                    Ok(entry) => {
283                        let path = entry.path();
284                        if path.is_file() {
285                            spec_files.push(path.to_path_buf());
286                        }
287                    }
288                    Err(e) => {
289                        warn!("Error walking path: {}", e);
290                    }
291                }
292            }
293        }
294
295        // Remove duplicates and sort
296        spec_files.sort();
297        spec_files.dedup();
298
299        info!("Found {} OpenAPI spec file(s)", spec_files.len());
300        Ok(spec_files)
301    }
302
303    /// Start watching the repository
304    pub async fn watch<F>(&mut self, mut on_change: F) -> Result<()>
305    where
306        F: FnMut(Vec<PathBuf>) -> Result<()>,
307    {
308        info!(
309            "Starting Git watch mode (polling every {} seconds)",
310            self.config.poll_interval_seconds
311        );
312
313        let mut interval = interval(Duration::from_secs(self.config.poll_interval_seconds));
314
315        loop {
316            interval.tick().await;
317
318            match self.check_for_changes().await {
319                Ok(true) => {
320                    // Changes detected, get spec files and notify
321                    match self.get_spec_files() {
322                        Ok(spec_files) => {
323                            if let Err(e) = on_change(spec_files) {
324                                error!("Error handling spec changes: {}", e);
325                            }
326                        }
327                        Err(e) => {
328                            error!("Failed to get spec files: {}", e);
329                        }
330                    }
331                }
332                Ok(false) => {
333                    // No changes, continue
334                }
335                Err(e) => {
336                    error!("Error checking for changes: {}", e);
337                    // Continue watching despite errors
338                }
339            }
340        }
341    }
342
343    /// Get the repository path
344    pub fn repo_path(&self) -> &Path {
345        &self.repo_path
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn test_extract_repo_name() {
355        let test_cases = vec![
356            ("https://github.com/user/repo.git", "repo"),
357            ("https://github.com/user/repo", "repo"),
358            ("git@github.com:user/repo.git", "repo"),
359            ("https://gitlab.com/group/project.git", "project"),
360        ];
361
362        for (url, expected) in test_cases {
363            let result = GitWatchService::extract_repo_name(url);
364            assert!(result.is_ok(), "Failed to extract repo name from: {}", url);
365            assert_eq!(result.unwrap(), expected);
366        }
367    }
368
369    #[test]
370    fn test_inject_auth_token() {
371        let config = GitWatchConfig {
372            repository_url: "https://github.com/user/repo.git".to_string(),
373            branch: "main".to_string(),
374            spec_paths: vec!["*.yaml".to_string()],
375            poll_interval_seconds: 60,
376            auth_token: None,
377            cache_dir: PathBuf::from("./test-cache"),
378            enabled: true,
379        };
380
381        let service = GitWatchService::new(config).unwrap();
382        let url = "https://github.com/user/repo.git";
383        let token = "ghp_token123";
384
385        let result = service.inject_auth_token(url, token).unwrap();
386        assert_eq!(result, "https://ghp_token123@github.com/user/repo.git");
387    }
388}