tsk/context/
repository_context.rs

1use anyhow::Result;
2use async_trait::async_trait;
3use std::path::Path;
4use std::sync::Arc;
5
6use super::FileSystemOperations;
7
8/// Provides context about a repository including auto-detection of tech stack and project name
9#[async_trait]
10pub trait RepositoryContext: Send + Sync {
11    /// Detects the technology stack based on repository files
12    async fn detect_tech_stack(&self, repo_path: &Path) -> Result<String>;
13
14    /// Detects the project name from the repository path
15    async fn detect_project_name(&self, repo_path: &Path) -> Result<String>;
16}
17
18/// Default implementation of RepositoryContext
19pub struct DefaultRepositoryContext {
20    file_system: Arc<dyn FileSystemOperations>,
21}
22
23impl DefaultRepositoryContext {
24    /// Creates a new DefaultRepositoryContext
25    pub fn new(file_system: Arc<dyn FileSystemOperations>) -> Self {
26        Self { file_system }
27    }
28
29    /// Checks if a file exists in the repository
30    async fn file_exists(&self, repo_path: &Path, file_name: &str) -> bool {
31        let file_path = repo_path.join(file_name);
32        self.file_system.exists(&file_path).await.unwrap_or(false)
33    }
34
35    /// Cleans a project name to be suitable for Docker tags
36    fn clean_project_name(name: &str) -> String {
37        // Remove special characters and convert to lowercase
38        let cleaned: String = name
39            .chars()
40            .map(|c| {
41                if c.is_alphanumeric() || c == '-' {
42                    c
43                } else {
44                    '-'
45                }
46            })
47            .collect::<String>()
48            .to_lowercase();
49
50        // Collapse consecutive dashes into a single dash
51        let mut result = String::new();
52        let mut prev_dash = false;
53        for c in cleaned.chars() {
54            if c == '-' {
55                if !prev_dash {
56                    result.push(c);
57                }
58                prev_dash = true;
59            } else {
60                result.push(c);
61                prev_dash = false;
62            }
63        }
64
65        // Trim dashes from both ends
66        result.trim_matches('-').to_string()
67    }
68}
69
70#[async_trait]
71impl RepositoryContext for DefaultRepositoryContext {
72    async fn detect_tech_stack(&self, repo_path: &Path) -> Result<String> {
73        // Check for language-specific files in priority order
74        let tech_stack = if self.file_exists(repo_path, "Cargo.toml").await {
75            "rust"
76        } else if self.file_exists(repo_path, "pyproject.toml").await
77            || self.file_exists(repo_path, "requirements.txt").await
78            || self.file_exists(repo_path, "setup.py").await
79        {
80            "python"
81        } else if self.file_exists(repo_path, "package.json").await {
82            "node"
83        } else if self.file_exists(repo_path, "go.mod").await {
84            "go"
85        } else if self.file_exists(repo_path, "pom.xml").await
86            || self.file_exists(repo_path, "build.gradle").await
87            || self.file_exists(repo_path, "build.gradle.kts").await
88        {
89            "java"
90        } else if self.file_exists(repo_path, "rockspec").await
91            || self.file_exists(repo_path, ".luacheckrc").await
92            || self.file_exists(repo_path, "init.lua").await
93        {
94            "lua"
95        } else {
96            "default"
97        };
98
99        Ok(tech_stack.to_string())
100    }
101
102    async fn detect_project_name(&self, repo_path: &Path) -> Result<String> {
103        // Extract the directory name from the repository path
104        let project_name = repo_path
105            .file_name()
106            .and_then(|name| name.to_str())
107            .map(Self::clean_project_name)
108            .unwrap_or_else(|| "default".to_string());
109
110        // Ensure the name is not empty after cleaning
111        let project_name = if project_name.is_empty() {
112            "default".to_string()
113        } else {
114            project_name
115        };
116
117        Ok(project_name)
118    }
119}
120
121/// Mock implementation for testing
122pub struct MockRepositoryContext {
123    tech_stack: String,
124    project_name: String,
125}
126
127impl MockRepositoryContext {
128    /// Creates a new MockRepositoryContext with successful results
129    pub fn new(tech_stack: String, project_name: String) -> Self {
130        Self {
131            tech_stack,
132            project_name,
133        }
134    }
135}
136
137#[async_trait]
138impl RepositoryContext for MockRepositoryContext {
139    async fn detect_tech_stack(&self, _repo_path: &Path) -> Result<String> {
140        Ok(self.tech_stack.clone())
141    }
142
143    async fn detect_project_name(&self, _repo_path: &Path) -> Result<String> {
144        Ok(self.project_name.clone())
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::context::file_system::tests::MockFileSystem;
152
153    #[tokio::test]
154    async fn test_detect_rust_tech_stack() {
155        let mock_fs = MockFileSystem::new().with_file("/repo/Cargo.toml", "");
156
157        let repo_context = DefaultRepositoryContext::new(Arc::new(mock_fs));
158        let result = repo_context
159            .detect_tech_stack(Path::new("/repo"))
160            .await
161            .unwrap();
162
163        assert_eq!(result, "rust");
164    }
165
166    #[tokio::test]
167    async fn test_detect_python_tech_stack() {
168        let mock_fs = MockFileSystem::new().with_file("/repo/pyproject.toml", "");
169
170        let repo_context = DefaultRepositoryContext::new(Arc::new(mock_fs));
171        let result = repo_context
172            .detect_tech_stack(Path::new("/repo"))
173            .await
174            .unwrap();
175
176        assert_eq!(result, "python");
177    }
178
179    #[tokio::test]
180    async fn test_detect_python_tech_stack_requirements() {
181        let mock_fs = MockFileSystem::new().with_file("/repo/requirements.txt", "");
182
183        let repo_context = DefaultRepositoryContext::new(Arc::new(mock_fs));
184        let result = repo_context
185            .detect_tech_stack(Path::new("/repo"))
186            .await
187            .unwrap();
188
189        assert_eq!(result, "python");
190    }
191
192    #[tokio::test]
193    async fn test_detect_node_tech_stack() {
194        let mock_fs = MockFileSystem::new().with_file("/repo/package.json", "");
195
196        let repo_context = DefaultRepositoryContext::new(Arc::new(mock_fs));
197        let result = repo_context
198            .detect_tech_stack(Path::new("/repo"))
199            .await
200            .unwrap();
201
202        assert_eq!(result, "node");
203    }
204
205    #[tokio::test]
206    async fn test_detect_go_tech_stack() {
207        let mock_fs = MockFileSystem::new().with_file("/repo/go.mod", "");
208
209        let repo_context = DefaultRepositoryContext::new(Arc::new(mock_fs));
210        let result = repo_context
211            .detect_tech_stack(Path::new("/repo"))
212            .await
213            .unwrap();
214
215        assert_eq!(result, "go");
216    }
217
218    #[tokio::test]
219    async fn test_detect_java_tech_stack() {
220        let mock_fs = MockFileSystem::new().with_file("/repo/pom.xml", "");
221
222        let repo_context = DefaultRepositoryContext::new(Arc::new(mock_fs));
223        let result = repo_context
224            .detect_tech_stack(Path::new("/repo"))
225            .await
226            .unwrap();
227
228        assert_eq!(result, "java");
229    }
230
231    #[tokio::test]
232    async fn test_detect_lua_tech_stack_rockspec() {
233        let mock_fs = MockFileSystem::new().with_file("/repo/rockspec", "");
234
235        let repo_context = DefaultRepositoryContext::new(Arc::new(mock_fs));
236        let result = repo_context
237            .detect_tech_stack(Path::new("/repo"))
238            .await
239            .unwrap();
240
241        assert_eq!(result, "lua");
242    }
243
244    #[tokio::test]
245    async fn test_detect_lua_tech_stack_luacheckrc() {
246        let mock_fs = MockFileSystem::new().with_file("/repo/.luacheckrc", "");
247
248        let repo_context = DefaultRepositoryContext::new(Arc::new(mock_fs));
249        let result = repo_context
250            .detect_tech_stack(Path::new("/repo"))
251            .await
252            .unwrap();
253
254        assert_eq!(result, "lua");
255    }
256
257    #[tokio::test]
258    async fn test_detect_lua_tech_stack_init() {
259        let mock_fs = MockFileSystem::new().with_file("/repo/init.lua", "");
260
261        let repo_context = DefaultRepositoryContext::new(Arc::new(mock_fs));
262        let result = repo_context
263            .detect_tech_stack(Path::new("/repo"))
264            .await
265            .unwrap();
266
267        assert_eq!(result, "lua");
268    }
269
270    #[tokio::test]
271    async fn test_detect_default_tech_stack() {
272        let mock_fs = MockFileSystem::new();
273
274        let repo_context = DefaultRepositoryContext::new(Arc::new(mock_fs));
275        let result = repo_context
276            .detect_tech_stack(Path::new("/repo"))
277            .await
278            .unwrap();
279
280        assert_eq!(result, "default");
281    }
282
283    #[tokio::test]
284    async fn test_detect_project_name() {
285        let mock_fs = MockFileSystem::new();
286        let repo_context = DefaultRepositoryContext::new(Arc::new(mock_fs));
287
288        let result = repo_context
289            .detect_project_name(Path::new("/home/user/my-awesome-project"))
290            .await
291            .unwrap();
292        assert_eq!(result, "my-awesome-project");
293    }
294
295    #[tokio::test]
296    async fn test_clean_project_name() {
297        let mock_fs = MockFileSystem::new();
298        let repo_context = DefaultRepositoryContext::new(Arc::new(mock_fs));
299
300        let result = repo_context
301            .detect_project_name(Path::new("/home/user/My_Awesome Project!"))
302            .await
303            .unwrap();
304        assert_eq!(result, "my-awesome-project");
305    }
306
307    #[tokio::test]
308    async fn test_project_name_with_special_chars() {
309        let mock_fs = MockFileSystem::new();
310        let repo_context = DefaultRepositoryContext::new(Arc::new(mock_fs));
311
312        let result = repo_context
313            .detect_project_name(Path::new("/home/user/test@#$%project"))
314            .await
315            .unwrap();
316        assert_eq!(result, "test-project");
317    }
318
319    #[tokio::test]
320    async fn test_project_name_fallback() {
321        let mock_fs = MockFileSystem::new();
322        let repo_context = DefaultRepositoryContext::new(Arc::new(mock_fs));
323
324        let result = repo_context
325            .detect_project_name(Path::new("/"))
326            .await
327            .unwrap();
328        assert_eq!(result, "default");
329    }
330
331    #[tokio::test]
332    async fn test_mock_repository_context() {
333        let mock =
334            MockRepositoryContext::new("custom-stack".to_string(), "custom-project".to_string());
335
336        assert_eq!(
337            mock.detect_tech_stack(Path::new("/any")).await.unwrap(),
338            "custom-stack"
339        );
340        assert_eq!(
341            mock.detect_project_name(Path::new("/any")).await.unwrap(),
342            "custom-project"
343        );
344    }
345}