Skip to main content

zlayer_builder/templates/
detect.rs

1//! Runtime auto-detection
2//!
3//! This module provides functionality to automatically detect the runtime
4//! of a project based on the files present in the project directory.
5
6use std::path::Path;
7
8use super::Runtime;
9
10/// Detect the runtime from files in the given directory.
11///
12/// This function examines the project directory for characteristic files
13/// that indicate which runtime the project uses. Detection order matters:
14/// more specific runtimes (like Bun, Deno) are checked before generic ones (Node.js).
15///
16/// # Examples
17///
18/// ```no_run
19/// use zlayer_builder::templates::detect_runtime;
20///
21/// let runtime = detect_runtime("/path/to/project");
22/// if let Some(rt) = runtime {
23///     println!("Detected runtime: {:?}", rt);
24/// }
25/// ```
26pub fn detect_runtime(context_path: impl AsRef<Path>) -> Option<Runtime> {
27    let path = context_path.as_ref();
28
29    // Check for Node.js ecosystem first (most common)
30    if path.join("package.json").exists() {
31        // Check for Bun - bun.lockb is definitive
32        if path.join("bun.lockb").exists() {
33            return Some(Runtime::Bun);
34        }
35
36        // Check for Deno - deno.json or deno.jsonc alongside package.json
37        if path.join("deno.json").exists() || path.join("deno.jsonc").exists() {
38            return Some(Runtime::Deno);
39        }
40
41        // Default to Node.js 20 for generic package.json projects
42        return Some(Runtime::Node20);
43    }
44
45    // Check for Deno without package.json
46    if path.join("deno.json").exists()
47        || path.join("deno.jsonc").exists()
48        || path.join("deno.lock").exists()
49    {
50        return Some(Runtime::Deno);
51    }
52
53    // Check for Rust
54    if path.join("Cargo.toml").exists() {
55        return Some(Runtime::Rust);
56    }
57
58    // Check for Python (check multiple indicators)
59    if path.join("pyproject.toml").exists()
60        || path.join("requirements.txt").exists()
61        || path.join("setup.py").exists()
62        || path.join("Pipfile").exists()
63        || path.join("poetry.lock").exists()
64    {
65        return Some(Runtime::Python312);
66    }
67
68    // Check for Go
69    if path.join("go.mod").exists() {
70        return Some(Runtime::Go);
71    }
72
73    None
74}
75
76/// Detect runtime with version hints from project files.
77///
78/// This extends basic detection by attempting to read version specifications
79/// from configuration files to choose the most appropriate version.
80pub fn detect_runtime_with_version(context_path: impl AsRef<Path>) -> Option<Runtime> {
81    let path = context_path.as_ref();
82
83    // First do basic detection
84    let base_runtime = detect_runtime(path)?;
85
86    // Try to refine version based on configuration files
87    match base_runtime {
88        Runtime::Node20 | Runtime::Node22 => {
89            // Try to read .nvmrc or .node-version
90            if let Some(version) = read_node_version(path) {
91                if version.starts_with("22") || version.starts_with("v22") {
92                    return Some(Runtime::Node22);
93                }
94                if version.starts_with("20") || version.starts_with("v20") {
95                    return Some(Runtime::Node20);
96                }
97            }
98
99            // Try to read engines.node from package.json
100            if let Some(version) = read_package_node_version(path) {
101                if version.contains("22") {
102                    return Some(Runtime::Node22);
103                }
104            }
105
106            Some(Runtime::Node20)
107        }
108        Runtime::Python312 | Runtime::Python313 => {
109            // Try to read python version from pyproject.toml or .python-version
110            if let Some(version) = read_python_version(path) {
111                if version.starts_with("3.13") {
112                    return Some(Runtime::Python313);
113                }
114            }
115
116            Some(Runtime::Python312)
117        }
118        other => Some(other),
119    }
120}
121
122/// Read Node.js version from .nvmrc or .node-version files
123fn read_node_version(path: &Path) -> Option<String> {
124    for filename in &[".nvmrc", ".node-version"] {
125        let version_file = path.join(filename);
126        if version_file.exists() {
127            if let Ok(content) = std::fs::read_to_string(&version_file) {
128                let version = content.trim().to_string();
129                if !version.is_empty() {
130                    return Some(version);
131                }
132            }
133        }
134    }
135    None
136}
137
138/// Read Node.js version from package.json engines field
139fn read_package_node_version(path: &Path) -> Option<String> {
140    let package_json = path.join("package.json");
141    if package_json.exists() {
142        if let Ok(content) = std::fs::read_to_string(&package_json) {
143            if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
144                if let Some(engines) = json.get("engines") {
145                    if let Some(node) = engines.get("node") {
146                        if let Some(version) = node.as_str() {
147                            return Some(version.to_string());
148                        }
149                    }
150                }
151            }
152        }
153    }
154    None
155}
156
157/// Read Python version from .python-version or pyproject.toml
158fn read_python_version(path: &Path) -> Option<String> {
159    // Check .python-version first (used by pyenv)
160    let python_version = path.join(".python-version");
161    if python_version.exists() {
162        if let Ok(content) = std::fs::read_to_string(&python_version) {
163            let version = content.trim().to_string();
164            if !version.is_empty() {
165                return Some(version);
166            }
167        }
168    }
169
170    // Check pyproject.toml for requires-python
171    let pyproject = path.join("pyproject.toml");
172    if pyproject.exists() {
173        if let Ok(content) = std::fs::read_to_string(&pyproject) {
174            // Simple parsing - look for requires-python
175            for line in content.lines() {
176                let line = line.trim();
177                if line.starts_with("requires-python") {
178                    if let Some(version) = line.split('=').nth(1) {
179                        let version = version.trim().trim_matches('"').trim_matches('\'');
180                        return Some(version.to_string());
181                    }
182                }
183            }
184        }
185    }
186
187    None
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use std::fs;
194    use tempfile::TempDir;
195
196    fn create_temp_dir() -> TempDir {
197        TempDir::new().expect("Failed to create temp directory")
198    }
199
200    #[test]
201    fn test_detect_nodejs_project() {
202        let dir = create_temp_dir();
203        fs::write(dir.path().join("package.json"), "{}").unwrap();
204
205        let runtime = detect_runtime(dir.path());
206        assert_eq!(runtime, Some(Runtime::Node20));
207    }
208
209    #[test]
210    fn test_detect_bun_project() {
211        let dir = create_temp_dir();
212        fs::write(dir.path().join("package.json"), "{}").unwrap();
213        fs::write(dir.path().join("bun.lockb"), "").unwrap();
214
215        let runtime = detect_runtime(dir.path());
216        assert_eq!(runtime, Some(Runtime::Bun));
217    }
218
219    #[test]
220    fn test_detect_deno_project() {
221        let dir = create_temp_dir();
222        fs::write(dir.path().join("deno.json"), "{}").unwrap();
223
224        let runtime = detect_runtime(dir.path());
225        assert_eq!(runtime, Some(Runtime::Deno));
226    }
227
228    #[test]
229    fn test_detect_deno_with_package_json() {
230        let dir = create_temp_dir();
231        fs::write(dir.path().join("package.json"), "{}").unwrap();
232        fs::write(dir.path().join("deno.json"), "{}").unwrap();
233
234        let runtime = detect_runtime(dir.path());
235        assert_eq!(runtime, Some(Runtime::Deno));
236    }
237
238    #[test]
239    fn test_detect_rust_project() {
240        let dir = create_temp_dir();
241        fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
242
243        let runtime = detect_runtime(dir.path());
244        assert_eq!(runtime, Some(Runtime::Rust));
245    }
246
247    #[test]
248    fn test_detect_python_requirements() {
249        let dir = create_temp_dir();
250        fs::write(dir.path().join("requirements.txt"), "flask==2.0").unwrap();
251
252        let runtime = detect_runtime(dir.path());
253        assert_eq!(runtime, Some(Runtime::Python312));
254    }
255
256    #[test]
257    fn test_detect_python_pyproject() {
258        let dir = create_temp_dir();
259        fs::write(dir.path().join("pyproject.toml"), "[project]").unwrap();
260
261        let runtime = detect_runtime(dir.path());
262        assert_eq!(runtime, Some(Runtime::Python312));
263    }
264
265    #[test]
266    fn test_detect_go_project() {
267        let dir = create_temp_dir();
268        fs::write(dir.path().join("go.mod"), "module example.com/app").unwrap();
269
270        let runtime = detect_runtime(dir.path());
271        assert_eq!(runtime, Some(Runtime::Go));
272    }
273
274    #[test]
275    fn test_detect_no_runtime() {
276        let dir = create_temp_dir();
277        // Empty directory
278
279        let runtime = detect_runtime(dir.path());
280        assert_eq!(runtime, None);
281    }
282
283    #[test]
284    fn test_detect_node22_from_nvmrc() {
285        let dir = create_temp_dir();
286        fs::write(dir.path().join("package.json"), "{}").unwrap();
287        fs::write(dir.path().join(".nvmrc"), "22.0.0").unwrap();
288
289        let runtime = detect_runtime_with_version(dir.path());
290        assert_eq!(runtime, Some(Runtime::Node22));
291    }
292
293    #[test]
294    fn test_detect_node22_from_package_engines() {
295        let dir = create_temp_dir();
296        let package_json = r#"{"engines": {"node": ">=22.0.0"}}"#;
297        fs::write(dir.path().join("package.json"), package_json).unwrap();
298
299        let runtime = detect_runtime_with_version(dir.path());
300        assert_eq!(runtime, Some(Runtime::Node22));
301    }
302
303    #[test]
304    fn test_detect_python313_from_version_file() {
305        let dir = create_temp_dir();
306        fs::write(dir.path().join("requirements.txt"), "flask").unwrap();
307        fs::write(dir.path().join(".python-version"), "3.13.0").unwrap();
308
309        let runtime = detect_runtime_with_version(dir.path());
310        assert_eq!(runtime, Some(Runtime::Python313));
311    }
312}