Skip to main content

stakpak_server/context/
environment.rs

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        // Hostname detection can touch filesystem/process APIs; keep it off the
43        // async runtime worker threads.
44        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/// Platform-native hostname fallback when env vars are not set.
119#[cfg(unix)]
120fn platform_hostname() -> Option<String> {
121    // Try /etc/hostname first (common on Linux), then fall back to POSIX uname.
122    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        // Initialize a git repo with an initial commit so HEAD exists
269        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            // git not available in test env — skip
276            return;
277        }
278
279        // Configure git user for the commit
280        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        // Create an initial commit so rev-parse HEAD works
290        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            // commit failed — skip
302            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}