Skip to main content

magic_bird/
context.rs

1//! Context detection for metadata population.
2//!
3//! This module detects VCS (git) and CI environment context to populate
4//! metadata fields on invocations.
5
6use std::collections::HashMap;
7use std::path::Path;
8use std::process::Command;
9
10use serde_json::{json, Value};
11
12/// Collected context metadata.
13#[derive(Debug, Default, Clone)]
14pub struct ContextMetadata {
15    /// All collected metadata entries.
16    pub entries: HashMap<String, Value>,
17}
18
19impl ContextMetadata {
20    /// Collect all available context metadata.
21    ///
22    /// This is the main entry point - it collects VCS and CI context.
23    pub fn collect(cwd: Option<&Path>) -> Self {
24        let mut ctx = Self::default();
25
26        // Collect VCS (git) context
27        if let Some(vcs) = collect_git_context(cwd) {
28            ctx.entries.insert("vcs".to_string(), vcs);
29        }
30
31        // Collect CI context
32        if let Some(ci) = collect_ci_context() {
33            ctx.entries.insert("ci".to_string(), ci);
34        }
35
36        ctx
37    }
38
39    /// Check if any metadata was collected.
40    pub fn is_empty(&self) -> bool {
41        self.entries.is_empty()
42    }
43
44    /// Merge this context into a HashMap.
45    pub fn into_map(self) -> HashMap<String, Value> {
46        self.entries
47    }
48}
49
50/// Collect git repository context.
51///
52/// Returns a JSON object with:
53/// - branch: Current branch name (or HEAD if detached)
54/// - commit: Short commit hash
55/// - dirty: Whether there are uncommitted changes
56/// - remote: Origin remote URL (if available)
57fn collect_git_context(cwd: Option<&Path>) -> Option<Value> {
58    // Check if we're in a git repo
59    let mut cmd = Command::new("git");
60    cmd.args(["rev-parse", "--is-inside-work-tree"]);
61    if let Some(dir) = cwd {
62        cmd.current_dir(dir);
63    }
64
65    let output = cmd.output().ok()?;
66    if !output.status.success() {
67        return None;
68    }
69
70    let mut vcs = serde_json::Map::new();
71
72    // Get current branch
73    let mut cmd = Command::new("git");
74    cmd.args(["rev-parse", "--abbrev-ref", "HEAD"]);
75    if let Some(dir) = cwd {
76        cmd.current_dir(dir);
77    }
78    if let Ok(output) = cmd.output() {
79        if output.status.success() {
80            let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
81            vcs.insert("branch".to_string(), json!(branch));
82        }
83    }
84
85    // Get short commit hash
86    let mut cmd = Command::new("git");
87    cmd.args(["rev-parse", "--short", "HEAD"]);
88    if let Some(dir) = cwd {
89        cmd.current_dir(dir);
90    }
91    if let Ok(output) = cmd.output() {
92        if output.status.success() {
93            let commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
94            vcs.insert("commit".to_string(), json!(commit));
95        }
96    }
97
98    // Check if dirty (uncommitted changes)
99    let mut cmd = Command::new("git");
100    cmd.args(["status", "--porcelain"]);
101    if let Some(dir) = cwd {
102        cmd.current_dir(dir);
103    }
104    if let Ok(output) = cmd.output() {
105        if output.status.success() {
106            let dirty = !output.stdout.is_empty();
107            vcs.insert("dirty".to_string(), json!(dirty));
108        }
109    }
110
111    // Get origin remote URL (sanitized - no credentials)
112    let mut cmd = Command::new("git");
113    cmd.args(["remote", "get-url", "origin"]);
114    if let Some(dir) = cwd {
115        cmd.current_dir(dir);
116    }
117    if let Ok(output) = cmd.output() {
118        if output.status.success() {
119            let remote = String::from_utf8_lossy(&output.stdout).trim().to_string();
120            // Sanitize: remove any embedded credentials (user:pass@)
121            let sanitized = sanitize_git_url(&remote);
122            vcs.insert("remote".to_string(), json!(sanitized));
123        }
124    }
125
126    if vcs.is_empty() {
127        None
128    } else {
129        Some(Value::Object(vcs))
130    }
131}
132
133/// Sanitize a git URL by removing embedded credentials.
134fn sanitize_git_url(url: &str) -> String {
135    // Handle HTTPS URLs with credentials: https://user:pass@github.com/...
136    if let Some(at_pos) = url.find('@') {
137        if url.starts_with("https://") || url.starts_with("http://") {
138            // Find the :// and rebuild without credentials
139            if let Some(proto_end) = url.find("://") {
140                return format!("{}{}", &url[..proto_end + 3], &url[at_pos + 1..]);
141            }
142        }
143    }
144    url.to_string()
145}
146
147/// Collect CI environment context.
148///
149/// Detects common CI systems and returns relevant metadata:
150/// - GitHub Actions
151/// - GitLab CI
152/// - Jenkins
153/// - CircleCI
154/// - Travis CI
155fn collect_ci_context() -> Option<Value> {
156    let mut ci = serde_json::Map::new();
157
158    // GitHub Actions
159    if std::env::var("GITHUB_ACTIONS").is_ok() {
160        ci.insert("provider".to_string(), json!("github"));
161
162        if let Ok(run_id) = std::env::var("GITHUB_RUN_ID") {
163            ci.insert("run_id".to_string(), json!(run_id));
164        }
165        if let Ok(run_number) = std::env::var("GITHUB_RUN_NUMBER") {
166            ci.insert("run_number".to_string(), json!(run_number));
167        }
168        if let Ok(workflow) = std::env::var("GITHUB_WORKFLOW") {
169            ci.insert("workflow".to_string(), json!(workflow));
170        }
171        if let Ok(job) = std::env::var("GITHUB_JOB") {
172            ci.insert("job".to_string(), json!(job));
173        }
174        if let Ok(ref_name) = std::env::var("GITHUB_REF_NAME") {
175            ci.insert("ref".to_string(), json!(ref_name));
176        }
177        if let Ok(event) = std::env::var("GITHUB_EVENT_NAME") {
178            ci.insert("event".to_string(), json!(event));
179        }
180        if let Ok(actor) = std::env::var("GITHUB_ACTOR") {
181            ci.insert("actor".to_string(), json!(actor));
182        }
183
184        return Some(Value::Object(ci));
185    }
186
187    // GitLab CI
188    if std::env::var("GITLAB_CI").is_ok() {
189        ci.insert("provider".to_string(), json!("gitlab"));
190
191        if let Ok(job_id) = std::env::var("CI_JOB_ID") {
192            ci.insert("job_id".to_string(), json!(job_id));
193        }
194        if let Ok(pipeline_id) = std::env::var("CI_PIPELINE_ID") {
195            ci.insert("pipeline_id".to_string(), json!(pipeline_id));
196        }
197        if let Ok(job_name) = std::env::var("CI_JOB_NAME") {
198            ci.insert("job_name".to_string(), json!(job_name));
199        }
200        if let Ok(ref_name) = std::env::var("CI_COMMIT_REF_NAME") {
201            ci.insert("ref".to_string(), json!(ref_name));
202        }
203
204        return Some(Value::Object(ci));
205    }
206
207    // Jenkins
208    if std::env::var("JENKINS_URL").is_ok() {
209        ci.insert("provider".to_string(), json!("jenkins"));
210
211        if let Ok(build_number) = std::env::var("BUILD_NUMBER") {
212            ci.insert("build_number".to_string(), json!(build_number));
213        }
214        if let Ok(job_name) = std::env::var("JOB_NAME") {
215            ci.insert("job_name".to_string(), json!(job_name));
216        }
217        if let Ok(branch) = std::env::var("GIT_BRANCH") {
218            ci.insert("branch".to_string(), json!(branch));
219        }
220
221        return Some(Value::Object(ci));
222    }
223
224    // CircleCI
225    if std::env::var("CIRCLECI").is_ok() {
226        ci.insert("provider".to_string(), json!("circleci"));
227
228        if let Ok(build_num) = std::env::var("CIRCLE_BUILD_NUM") {
229            ci.insert("build_num".to_string(), json!(build_num));
230        }
231        if let Ok(job) = std::env::var("CIRCLE_JOB") {
232            ci.insert("job".to_string(), json!(job));
233        }
234        if let Ok(branch) = std::env::var("CIRCLE_BRANCH") {
235            ci.insert("branch".to_string(), json!(branch));
236        }
237
238        return Some(Value::Object(ci));
239    }
240
241    // Travis CI
242    if std::env::var("TRAVIS").is_ok() {
243        ci.insert("provider".to_string(), json!("travis"));
244
245        if let Ok(build_id) = std::env::var("TRAVIS_BUILD_ID") {
246            ci.insert("build_id".to_string(), json!(build_id));
247        }
248        if let Ok(job_id) = std::env::var("TRAVIS_JOB_ID") {
249            ci.insert("job_id".to_string(), json!(job_id));
250        }
251        if let Ok(branch) = std::env::var("TRAVIS_BRANCH") {
252            ci.insert("branch".to_string(), json!(branch));
253        }
254
255        return Some(Value::Object(ci));
256    }
257
258    // Generic CI detection (many CI systems set CI=true)
259    if std::env::var("CI").map(|v| v == "true" || v == "1").unwrap_or(false) {
260        ci.insert("provider".to_string(), json!("unknown"));
261        return Some(Value::Object(ci));
262    }
263
264    None
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn test_sanitize_git_url_https_with_creds() {
273        let url = "https://user:password@github.com/org/repo.git";
274        assert_eq!(sanitize_git_url(url), "https://github.com/org/repo.git");
275    }
276
277    #[test]
278    fn test_sanitize_git_url_https_no_creds() {
279        let url = "https://github.com/org/repo.git";
280        assert_eq!(sanitize_git_url(url), "https://github.com/org/repo.git");
281    }
282
283    #[test]
284    fn test_sanitize_git_url_ssh() {
285        let url = "git@github.com:org/repo.git";
286        assert_eq!(sanitize_git_url(url), "git@github.com:org/repo.git");
287    }
288
289    #[test]
290    fn test_collect_git_context_in_repo() {
291        // This test runs in the magic repo, so we should get git context
292        let ctx = collect_git_context(None);
293
294        // We're in a git repo, so this should return Some
295        assert!(ctx.is_some(), "Should detect git context");
296
297        let vcs = ctx.unwrap();
298        assert!(vcs.get("branch").is_some(), "Should have branch");
299        assert!(vcs.get("commit").is_some(), "Should have commit");
300        assert!(vcs.get("dirty").is_some(), "Should have dirty flag");
301    }
302
303    #[test]
304    fn test_collect_context_metadata() {
305        // Should collect at least VCS context since we're in a git repo
306        let ctx = ContextMetadata::collect(None);
307
308        assert!(ctx.entries.contains_key("vcs"), "Should have VCS context");
309    }
310}