1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use stakpak_shared::utils::{LocalFileSystemProvider, generate_directory_tree};
4use std::env;
5use std::path::Path;
6use std::process::Command;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct GitContext {
10 pub branch: Option<String>,
11 pub has_uncommitted_changes: Option<bool>,
12 pub remote_url: Option<String>,
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct EnvironmentContext {
17 pub machine_name: String,
18 pub operating_system: String,
19 pub shell_type: String,
20 pub is_container: bool,
21 pub working_directory: String,
22 pub current_datetime_utc: DateTime<Utc>,
23 pub directory_tree: String,
24 pub git: Option<GitContext>,
25}
26
27impl EnvironmentContext {
28 pub async fn snapshot(working_directory: &str) -> Self {
29 let provider = LocalFileSystemProvider;
30 let directory_tree = generate_directory_tree(&provider, working_directory, "", 1, 0)
31 .await
32 .ok()
33 .filter(|tree| !tree.trim().is_empty())
34 .unwrap_or_else(|| "(No files or directories found)".to_string());
35
36 let wd = working_directory.to_string();
37 let git = tokio::task::spawn_blocking(move || detect_git_context(&wd))
38 .await
39 .ok()
40 .flatten();
41
42 let machine_name = tokio::task::spawn_blocking(detect_machine_name)
45 .await
46 .ok()
47 .filter(|value| !value.trim().is_empty())
48 .unwrap_or_else(|| "unknown-machine".to_string());
49
50 Self {
51 machine_name,
52 operating_system: detect_operating_system(),
53 shell_type: detect_shell_type(),
54 is_container: detect_container_environment(),
55 working_directory: working_directory.to_string(),
56 current_datetime_utc: Utc::now(),
57 directory_tree,
58 git,
59 }
60 }
61
62 pub fn to_local_context_block(&self) -> String {
63 let mut block = String::new();
64
65 block.push_str("# System Details\n\n");
66 block.push_str(&format!("Machine Name: {}\n", self.machine_name));
67 block.push_str(&format!(
68 "Current Date/Time: {}\n",
69 self.current_datetime_utc.format("%Y-%m-%d %H:%M:%S UTC")
70 ));
71 block.push_str(&format!("Operating System: {}\n", self.operating_system));
72 block.push_str(&format!("Shell Type: {}\n", self.shell_type));
73 block.push_str(&format!(
74 "Running in Container Environment: {}\n",
75 if self.is_container { "yes" } else { "no" }
76 ));
77
78 if let Some(git) = &self.git {
79 block.push_str("Git Repository: yes\n");
80 if let Some(branch) = &git.branch {
81 block.push_str(&format!("Current Branch: {}\n", branch));
82 }
83 if let Some(has_changes) = git.has_uncommitted_changes {
84 block.push_str(&format!(
85 "Uncommitted Changes: {}\n",
86 if has_changes { "yes" } else { "no" }
87 ));
88 }
89 if let Some(remote_url) = &git.remote_url {
90 block.push_str(&format!("Remote URL: {}\n", remote_url));
91 }
92 } else {
93 block.push_str("Git Repository: no\n");
94 }
95
96 block.push_str(&format!(
97 "\n# Current Working Directory ({})\n\n{}",
98 self.working_directory, self.directory_tree
99 ));
100
101 block
102 }
103}
104
105fn detect_machine_name() -> String {
106 env::var("HOSTNAME")
107 .ok()
108 .filter(|value| !value.trim().is_empty())
109 .or_else(|| {
110 env::var("COMPUTERNAME")
111 .ok()
112 .filter(|value| !value.trim().is_empty())
113 })
114 .or_else(platform_hostname)
115 .unwrap_or_else(|| "unknown-machine".to_string())
116}
117
118#[cfg(unix)]
120fn platform_hostname() -> Option<String> {
121 std::fs::read_to_string("/etc/hostname")
123 .ok()
124 .map(|value| value.trim().to_string())
125 .filter(|value| !value.is_empty())
126 .or_else(|| {
127 std::process::Command::new("uname")
128 .arg("-n")
129 .output()
130 .ok()
131 .filter(|output| output.status.success())
132 .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
133 .filter(|value| !value.is_empty())
134 })
135}
136
137#[cfg(not(unix))]
138fn platform_hostname() -> Option<String> {
139 None
140}
141
142fn detect_operating_system() -> String {
143 match std::env::consts::OS {
144 "windows" => "Windows".to_string(),
145 "macos" => "macOS".to_string(),
146 "linux" => "Linux".to_string(),
147 "freebsd" => "FreeBSD".to_string(),
148 "openbsd" => "OpenBSD".to_string(),
149 "netbsd" => "NetBSD".to_string(),
150 value => value.to_string(),
151 }
152}
153
154fn detect_shell_type() -> String {
155 env::var("SHELL")
156 .ok()
157 .and_then(|path| {
158 Path::new(&path)
159 .file_name()
160 .map(|name| name.to_string_lossy().to_string())
161 })
162 .or_else(|| env::var("COMSPEC").ok())
163 .unwrap_or_else(|| "Unknown".to_string())
164}
165
166fn detect_container_environment() -> bool {
167 if Path::new("/.dockerenv").exists() {
168 return true;
169 }
170
171 [
172 "DOCKER_CONTAINER",
173 "KUBERNETES_SERVICE_HOST",
174 "container",
175 "PODMAN_VERSION",
176 ]
177 .iter()
178 .any(|key| env::var(key).is_ok())
179}
180
181fn detect_git_context(working_directory: &str) -> Option<GitContext> {
182 let path = Path::new(working_directory);
183
184 let is_git_repo = run_git(path, ["rev-parse", "--is-inside-work-tree"])
185 .map(|output| output.trim() == "true")
186 .unwrap_or(false);
187 if !is_git_repo {
188 return None;
189 }
190
191 let branch = run_git(path, ["rev-parse", "--abbrev-ref", "HEAD"]);
192 let has_uncommitted_changes = run_git(path, ["status", "--porcelain"]).map(|output| {
193 let trimmed = output.trim();
194 !trimmed.is_empty()
195 });
196
197 let remote_url = run_git(path, ["remote", "get-url", "origin"]).or_else(|| {
198 let remotes = run_git(path, ["remote"])?;
199 let first_remote = remotes.lines().next()?.trim();
200 if first_remote.is_empty() {
201 return None;
202 }
203 run_git(path, ["remote", "get-url", first_remote])
204 });
205
206 Some(GitContext {
207 branch,
208 has_uncommitted_changes,
209 remote_url,
210 })
211}
212
213fn run_git<const N: usize>(working_directory: &Path, args: [&str; N]) -> Option<String> {
214 let output = Command::new("git")
215 .args(args)
216 .current_dir(working_directory)
217 .output()
218 .ok()?;
219
220 if !output.status.success() {
221 return None;
222 }
223
224 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
225 if stdout.is_empty() {
226 return None;
227 }
228
229 Some(stdout)
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 #[tokio::test]
237 async fn builds_local_context_block() {
238 let temp = tempfile::TempDir::new().expect("temp dir");
239 let context = EnvironmentContext::snapshot(temp.path().to_string_lossy().as_ref()).await;
240 let block = context.to_local_context_block();
241
242 assert!(block.contains("# System Details"));
243 assert!(block.contains("# Current Working Directory"));
244 }
245
246 #[tokio::test]
247 async fn snapshot_populates_all_fields() {
248 let temp = tempfile::TempDir::new().expect("temp dir");
249 let context = EnvironmentContext::snapshot(temp.path().to_string_lossy().as_ref()).await;
250
251 assert!(!context.machine_name.is_empty());
252 assert!(!context.operating_system.is_empty());
253 assert!(!context.shell_type.is_empty());
254 assert!(!context.working_directory.is_empty());
255 }
256
257 #[test]
258 fn no_git_context_for_non_repo_directory() {
259 let temp = tempfile::TempDir::new().expect("temp dir");
260 let git = detect_git_context(temp.path().to_string_lossy().as_ref());
261 assert!(git.is_none(), "non-repo dir should have no git context");
262 }
263
264 #[test]
265 fn detects_git_context_for_repo() {
266 let temp = tempfile::TempDir::new().expect("temp dir");
267
268 let init = Command::new("git")
270 .args(["init"])
271 .current_dir(temp.path())
272 .output();
273
274 if init.is_err() || !init.as_ref().map(|o| o.status.success()).unwrap_or(false) {
275 return;
277 }
278
279 let _ = Command::new("git")
281 .args(["config", "user.email", "test@test.com"])
282 .current_dir(temp.path())
283 .output();
284 let _ = Command::new("git")
285 .args(["config", "user.name", "Test"])
286 .current_dir(temp.path())
287 .output();
288
289 std::fs::write(temp.path().join("README.md"), "init").expect("write readme");
291 let _ = Command::new("git")
292 .args(["add", "."])
293 .current_dir(temp.path())
294 .output();
295 let commit = Command::new("git")
296 .args(["commit", "-m", "init"])
297 .current_dir(temp.path())
298 .output();
299
300 if commit.is_err() || !commit.as_ref().map(|o| o.status.success()).unwrap_or(false) {
301 return;
303 }
304
305 let git = detect_git_context(temp.path().to_string_lossy().as_ref());
306 assert!(git.is_some(), "initialized repo should have git context");
307
308 let git = git.expect("git context");
309 assert!(git.branch.is_some(), "should detect branch after commit");
310 }
311
312 #[test]
313 fn detects_git_context_from_nested_directory() {
314 let temp = tempfile::TempDir::new().expect("temp dir");
315
316 let init = Command::new("git")
317 .args(["init"])
318 .current_dir(temp.path())
319 .output();
320
321 if init.is_err() || !init.as_ref().map(|o| o.status.success()).unwrap_or(false) {
322 return;
323 }
324
325 let _ = Command::new("git")
326 .args(["config", "user.email", "test@test.com"])
327 .current_dir(temp.path())
328 .output();
329 let _ = Command::new("git")
330 .args(["config", "user.name", "Test"])
331 .current_dir(temp.path())
332 .output();
333
334 std::fs::write(temp.path().join("README.md"), "init").expect("write readme");
335 let _ = Command::new("git")
336 .args(["add", "."])
337 .current_dir(temp.path())
338 .output();
339 let commit = Command::new("git")
340 .args(["commit", "-m", "init"])
341 .current_dir(temp.path())
342 .output();
343
344 if commit.is_err() || !commit.as_ref().map(|o| o.status.success()).unwrap_or(false) {
345 return;
346 }
347
348 let nested = temp.path().join("src").join("module");
349 std::fs::create_dir_all(&nested).expect("create nested");
350
351 let git = detect_git_context(nested.to_string_lossy().as_ref());
352 assert!(
353 git.is_some(),
354 "nested path inside repo should still detect git context"
355 );
356 }
357
358 #[test]
359 fn local_context_block_includes_git_info() {
360 let context = EnvironmentContext {
361 machine_name: "test".to_string(),
362 operating_system: "Linux".to_string(),
363 shell_type: "bash".to_string(),
364 is_container: false,
365 working_directory: "/tmp".to_string(),
366 current_datetime_utc: Utc::now(),
367 directory_tree: "├── src".to_string(),
368 git: Some(GitContext {
369 branch: Some("main".to_string()),
370 has_uncommitted_changes: Some(true),
371 remote_url: Some("https://github.com/org/repo".to_string()),
372 }),
373 };
374
375 let block = context.to_local_context_block();
376 assert!(block.contains("Git Repository: yes"));
377 assert!(block.contains("Current Branch: main"));
378 assert!(block.contains("Uncommitted Changes: yes"));
379 assert!(block.contains("Remote URL: https://github.com/org/repo"));
380 }
381
382 #[test]
383 fn local_context_block_no_git() {
384 let context = EnvironmentContext {
385 machine_name: "test".to_string(),
386 operating_system: "macOS".to_string(),
387 shell_type: "zsh".to_string(),
388 is_container: true,
389 working_directory: "/app".to_string(),
390 current_datetime_utc: Utc::now(),
391 directory_tree: "├── Dockerfile".to_string(),
392 git: None,
393 };
394
395 let block = context.to_local_context_block();
396 assert!(block.contains("Git Repository: no"));
397 assert!(block.contains("Running in Container Environment: yes"));
398 }
399}