Skip to main content

cuenv_ci/provider/
local.rs

1use super::{CIContext, CIProvider};
2use crate::report::{CheckHandle, PipelineReport};
3use async_trait::async_trait;
4use cuenv_core::Result;
5use std::collections::HashSet;
6use std::path::PathBuf;
7
8pub struct LocalProvider {
9    context: CIContext,
10    base_ref: Option<String>,
11}
12
13impl LocalProvider {
14    /// Create a `LocalProvider` that compares against a specific base reference.
15    /// This will detect changes between the base ref and HEAD, plus uncommitted changes.
16    #[must_use]
17    pub fn with_base_ref(base_ref: String) -> Self {
18        Self {
19            context: CIContext {
20                provider: "local".to_string(),
21                event: "manual".to_string(),
22                ref_name: "current".to_string(),
23                base_ref: Some(base_ref.clone()),
24                sha: "current".to_string(),
25            },
26            base_ref: Some(base_ref),
27        }
28    }
29}
30
31#[async_trait]
32impl CIProvider for LocalProvider {
33    fn detect() -> Option<Self> {
34        // Always available as fallback
35        Some(Self {
36            context: CIContext {
37                provider: "local".to_string(),
38                event: "manual".to_string(),
39                ref_name: "current".to_string(),
40                base_ref: None,
41                sha: "current".to_string(),
42            },
43            base_ref: None,
44        })
45    }
46
47    fn context(&self) -> &CIContext {
48        &self.context
49    }
50
51    async fn changed_files(&self) -> Result<Vec<PathBuf>> {
52        let mut changed: HashSet<PathBuf> = HashSet::new();
53
54        // If we have a base_ref, get committed changes since that ref
55        if let Some(ref base_ref) = self.base_ref {
56            // Use three-dot syntax to get changes between base_ref and HEAD
57            // This shows what's in HEAD that isn't in base_ref
58            let output = std::process::Command::new("git")
59                .args(["diff", "--name-only", &format!("{base_ref}...HEAD")])
60                .output()
61                .ok();
62
63            if let Some(output) = output {
64                let stdout = String::from_utf8_lossy(&output.stdout);
65                for line in stdout.lines() {
66                    changed.insert(PathBuf::from(line));
67                }
68            }
69        }
70
71        // Always include uncommitted changes (staged + unstaged)
72        let output = std::process::Command::new("git")
73            .args(["diff", "--name-only", "HEAD"])
74            .output()
75            .ok();
76
77        if let Some(output) = output {
78            let stdout = String::from_utf8_lossy(&output.stdout);
79            for line in stdout.lines() {
80                changed.insert(PathBuf::from(line));
81            }
82        }
83
84        Ok(changed.into_iter().collect())
85    }
86
87    async fn create_check(&self, _name: &str) -> Result<CheckHandle> {
88        Ok(CheckHandle {
89            id: "local".to_string(),
90        })
91    }
92
93    async fn update_check(&self, _handle: &CheckHandle, _summary: &str) -> Result<()> {
94        Ok(())
95    }
96
97    async fn complete_check(&self, _handle: &CheckHandle, _report: &PipelineReport) -> Result<()> {
98        Ok(())
99    }
100
101    async fn upload_report(&self, _report: &PipelineReport) -> Result<Option<String>> {
102        Ok(None)
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use crate::report::{ContextReport, PipelineStatus};
110
111    fn make_test_report() -> PipelineReport {
112        PipelineReport {
113            version: "1.0".to_string(),
114            project: "test".to_string(),
115            pipeline: "ci".to_string(),
116            context: ContextReport {
117                provider: "local".to_string(),
118                event: "manual".to_string(),
119                ref_name: "current".to_string(),
120                base_ref: None,
121                sha: "abc123".to_string(),
122                changed_files: vec![],
123            },
124            started_at: chrono::Utc::now(),
125            completed_at: Some(chrono::Utc::now()),
126            duration_ms: Some(100),
127            status: PipelineStatus::Success,
128            tasks: vec![],
129        }
130    }
131
132    #[test]
133    fn test_local_provider_detect() {
134        let provider = LocalProvider::detect();
135        assert!(provider.is_some());
136
137        let provider = provider.unwrap();
138        let ctx = provider.context();
139        assert_eq!(ctx.provider, "local");
140        assert_eq!(ctx.event, "manual");
141        assert_eq!(ctx.ref_name, "current");
142        assert!(ctx.base_ref.is_none());
143    }
144
145    #[test]
146    fn test_local_provider_with_base_ref() {
147        let provider = LocalProvider::with_base_ref("main".to_string());
148        let ctx = provider.context();
149
150        assert_eq!(ctx.provider, "local");
151        assert_eq!(ctx.event, "manual");
152        assert_eq!(ctx.base_ref, Some("main".to_string()));
153    }
154
155    #[test]
156    fn test_local_provider_context_sha() {
157        let provider = LocalProvider::detect().unwrap();
158        let ctx = provider.context();
159        assert_eq!(ctx.sha, "current");
160    }
161
162    #[tokio::test]
163    async fn test_local_provider_create_check() {
164        let provider = LocalProvider::detect().unwrap();
165        let handle = provider.create_check("test-check").await.unwrap();
166        assert_eq!(handle.id, "local");
167    }
168
169    #[tokio::test]
170    async fn test_local_provider_update_check() {
171        let provider = LocalProvider::detect().unwrap();
172        let handle = CheckHandle {
173            id: "local".to_string(),
174        };
175        let result = provider.update_check(&handle, "running").await;
176        assert!(result.is_ok());
177    }
178
179    #[tokio::test]
180    async fn test_local_provider_complete_check() {
181        let provider = LocalProvider::detect().unwrap();
182        let handle = CheckHandle {
183            id: "local".to_string(),
184        };
185        let report = make_test_report();
186        let result = provider.complete_check(&handle, &report).await;
187        assert!(result.is_ok());
188    }
189
190    #[tokio::test]
191    async fn test_local_provider_upload_report_returns_none() {
192        let provider = LocalProvider::detect().unwrap();
193        let report = make_test_report();
194        let result = provider.upload_report(&report).await.unwrap();
195        assert!(result.is_none());
196    }
197
198    #[tokio::test]
199    async fn test_local_provider_changed_files() {
200        // This test is dependent on git, but should work in any repo
201        let provider = LocalProvider::detect().unwrap();
202        let result = provider.changed_files().await;
203        // Should succeed even if there are no changes
204        assert!(result.is_ok());
205    }
206
207    #[tokio::test]
208    async fn test_local_provider_with_base_ref_changed_files() {
209        // Test with a base ref that exists
210        let provider = LocalProvider::with_base_ref("HEAD~1".to_string());
211        let result = provider.changed_files().await;
212        // Should succeed even if the range is invalid
213        assert!(result.is_ok());
214    }
215}