1use std::collections::HashMap;
7use std::path::Path;
8use std::process::Command;
9
10use serde_json::{json, Value};
11
12#[derive(Debug, Default, Clone)]
14pub struct ContextMetadata {
15 pub entries: HashMap<String, Value>,
17}
18
19impl ContextMetadata {
20 pub fn collect(cwd: Option<&Path>) -> Self {
24 let mut ctx = Self::default();
25
26 if let Some(vcs) = collect_git_context(cwd) {
28 ctx.entries.insert("vcs".to_string(), vcs);
29 }
30
31 if let Some(ci) = collect_ci_context() {
33 ctx.entries.insert("ci".to_string(), ci);
34 }
35
36 ctx
37 }
38
39 pub fn is_empty(&self) -> bool {
41 self.entries.is_empty()
42 }
43
44 pub fn into_map(self) -> HashMap<String, Value> {
46 self.entries
47 }
48}
49
50fn collect_git_context(cwd: Option<&Path>) -> Option<Value> {
58 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 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 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 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 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 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
133fn sanitize_git_url(url: &str) -> String {
135 if let Some(at_pos) = url.find('@') {
137 if url.starts_with("https://") || url.starts_with("http://") {
138 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
147fn collect_ci_context() -> Option<Value> {
156 let mut ci = serde_json::Map::new();
157
158 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 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 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 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 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 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 let ctx = collect_git_context(None);
293
294 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 let ctx = ContextMetadata::collect(None);
307
308 assert!(ctx.entries.contains_key("vcs"), "Should have VCS context");
309 }
310}