Skip to main content

hm_dsl_engine/
detect.rs

1use std::path::Path;
2
3use anyhow::{Context, bail};
4
5use crate::DslLanguage;
6
7/// Detect the DSL language used in a project by scanning `.hm/` for file
8/// extensions. Prefers **TypeScript** when both are present (the `hm run`
9/// default).
10///
11/// # Errors
12///
13/// - The `.hm/` directory does not exist.
14/// - No `.py` or `.ts` files are found inside `.hm/`.
15pub fn detect_language(repo_root: &Path) -> anyhow::Result<DslLanguage> {
16    let harmont_dir = repo_root.join(".hm");
17    if !harmont_dir.is_dir() {
18        bail!("no .hm/ directory found in {}", repo_root.display());
19    }
20    let langs = scan_extensions(repo_root)?;
21    if langs.has_ts {
22        // When both languages are present, prefer TypeScript.
23        Ok(DslLanguage::TypeScript)
24    } else if langs.has_py {
25        Ok(DslLanguage::Python)
26    } else {
27        bail!("no .py or .ts files found in {}", harmont_dir.display())
28    }
29}
30
31/// Like [`detect_language`] but prefers **Python** when both are present.
32///
33/// Used by the machine-facing `hm pipelines` / `hm render` commands that the
34/// backend shells out to: the Python path is the fully-supported one (the
35/// discovery envelope is Python-only today), so a repo carrying both a `.py`
36/// and a redundant `.ts` resolves to Python rather than the unsupported TS
37/// registry. `hm run` keeps the TypeScript-preferring [`detect_language`].
38///
39/// # Errors
40///
41/// - The `.hm/` directory does not exist.
42/// - No `.py` or `.ts` files are found inside `.hm/`.
43pub fn detect_language_python_first(repo_root: &Path) -> anyhow::Result<DslLanguage> {
44    let harmont_dir = repo_root.join(".hm");
45    if !harmont_dir.is_dir() {
46        bail!("no .hm/ directory found in {}", repo_root.display());
47    }
48    let langs = scan_extensions(repo_root)?;
49    if langs.has_py {
50        Ok(DslLanguage::Python)
51    } else if langs.has_ts {
52        Ok(DslLanguage::TypeScript)
53    } else {
54        bail!("no .py or .ts files found in {}", harmont_dir.display())
55    }
56}
57
58/// True when `.hm/` exists and holds at least one `.py` or `.ts` file.
59///
60/// The backend fans pipeline discovery out across every repo in an
61/// installation, most of which declare no pipelines at all. Those repos should
62/// yield an empty registry, not an error — callers use this to short-circuit to
63/// an empty envelope instead of calling [`detect_language_python_first`].
64#[must_use]
65pub fn has_pipeline_files(repo_root: &Path) -> bool {
66    matches!(scan_extensions(repo_root), Ok(langs) if langs.has_py || langs.has_ts)
67}
68
69/// Which DSL extensions a `.hm/` scan turned up. Named fields make a py/ts
70/// swap at a call site impossible to express, unlike a bare `(bool, bool)`.
71struct DetectedLangs {
72    has_py: bool,
73    has_ts: bool,
74}
75
76/// Scan `.hm/` and report which DSL extensions are present. A missing `.hm/`
77/// directory yields all-`false`; an unreadable one is an error.
78fn scan_extensions(repo_root: &Path) -> anyhow::Result<DetectedLangs> {
79    let harmont_dir = repo_root.join(".hm");
80    if !harmont_dir.is_dir() {
81        return Ok(DetectedLangs {
82            has_py: false,
83            has_ts: false,
84        });
85    }
86
87    let entries = std::fs::read_dir(&harmont_dir)
88        .with_context(|| format!("failed to read {}", harmont_dir.display()))?;
89
90    let mut has_py = false;
91    let mut has_ts = false;
92    for entry in entries {
93        let entry = entry?;
94        match entry.path().extension().and_then(|e| e.to_str()) {
95            Some("py") => has_py = true,
96            Some("ts") => has_ts = true,
97            _ => {}
98        }
99    }
100    Ok(DetectedLangs { has_py, has_ts })
101}
102
103#[cfg(test)]
104#[allow(clippy::unwrap_used, clippy::expect_used)]
105mod tests {
106    use super::*;
107    use std::fs;
108    use tempfile::TempDir;
109
110    /// Helper: create a temp dir with `.hm/` and the given filenames inside
111    /// it.
112    fn setup(files: &[&str]) -> TempDir {
113        let tmp = TempDir::new().unwrap();
114        let harmont = tmp.path().join(".hm");
115        fs::create_dir(&harmont).unwrap();
116        for name in files {
117            fs::write(harmont.join(name), "").unwrap();
118        }
119        tmp
120    }
121
122    #[test]
123    fn python_file_detected() {
124        let tmp = setup(&["ci.py"]);
125        let lang = detect_language(tmp.path()).unwrap();
126        assert_eq!(lang, DslLanguage::Python);
127    }
128
129    #[test]
130    fn typescript_file_detected() {
131        let tmp = setup(&["ci.ts"]);
132        let lang = detect_language(tmp.path()).unwrap();
133        assert_eq!(lang, DslLanguage::TypeScript);
134    }
135
136    #[test]
137    fn mixed_languages_prefers_typescript() {
138        let tmp = setup(&["ci.py", "deploy.ts"]);
139        let lang = detect_language(tmp.path()).unwrap();
140        assert_eq!(lang, DslLanguage::TypeScript);
141    }
142
143    #[test]
144    fn no_harmont_dir_is_error() {
145        let tmp = TempDir::new().unwrap();
146        // Do NOT create .hm/
147        let err = detect_language(tmp.path()).unwrap_err();
148        let msg = err.to_string();
149        assert!(msg.contains("no .hm/ directory"), "unexpected error: {msg}");
150    }
151
152    #[test]
153    fn empty_harmont_dir_is_error() {
154        let tmp = TempDir::new().unwrap();
155        fs::create_dir(tmp.path().join(".hm")).unwrap();
156        let err = detect_language(tmp.path()).unwrap_err();
157        let msg = err.to_string();
158        assert!(
159            msg.contains("no .py or .ts files"),
160            "unexpected error: {msg}"
161        );
162    }
163
164    #[test]
165    fn python_first_prefers_python_when_mixed() {
166        let tmp = setup(&["ci.py", "deploy.ts"]);
167        assert_eq!(
168            detect_language_python_first(tmp.path()).unwrap(),
169            DslLanguage::Python
170        );
171    }
172
173    #[test]
174    fn python_first_falls_back_to_typescript_when_only_ts() {
175        let tmp = setup(&["ci.ts"]);
176        assert_eq!(
177            detect_language_python_first(tmp.path()).unwrap(),
178            DslLanguage::TypeScript
179        );
180    }
181
182    #[test]
183    fn python_first_no_harmont_dir_is_error() {
184        let tmp = TempDir::new().unwrap();
185        let err = detect_language_python_first(tmp.path()).unwrap_err();
186        assert!(
187            err.to_string().contains("no .hm/ directory"),
188            "unexpected error: {err}"
189        );
190    }
191
192    #[test]
193    fn has_pipeline_files_true_for_py_and_ts() {
194        assert!(has_pipeline_files(setup(&["ci.py"]).path()));
195        assert!(has_pipeline_files(setup(&["ci.ts"]).path()));
196        assert!(has_pipeline_files(setup(&["ci.py", "deploy.ts"]).path()));
197    }
198
199    #[test]
200    fn has_pipeline_files_false_for_missing_or_empty_harmont() {
201        // No .hm/ directory at all.
202        assert!(!has_pipeline_files(TempDir::new().unwrap().path()));
203        // .hm/ exists but declares no .py/.ts files.
204        assert!(!has_pipeline_files(setup(&["README.md"]).path()));
205    }
206}