Skip to content

Commit 0d08a16

Browse files
fix(hm): discovery prefers Python + empty registry for pipeline-less repos (#34)
1 parent a2380f2 commit 0d08a16

4 files changed

Lines changed: 144 additions & 17 deletions

File tree

crates/hm-dsl-engine/src/detect.rs

Lines changed: 96 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,43 +5,83 @@ use anyhow::{Context, bail};
55
use crate::DslLanguage;
66

77
/// Detect the DSL language used in a project by scanning `.harmont/` for file
8-
/// extensions.
8+
/// extensions. Prefers **TypeScript** when both are present (the `hm run`
9+
/// default).
910
///
1011
/// # Errors
1112
///
1213
/// - The `.harmont/` directory does not exist.
1314
/// - No `.py` or `.ts` files are found inside `.harmont/`.
14-
/// - Both `.py` and `.ts` files are present (mixed languages).
1515
pub fn detect_language(repo_root: &Path) -> anyhow::Result<DslLanguage> {
1616
let harmont_dir = repo_root.join(".harmont");
17+
if !harmont_dir.is_dir() {
18+
bail!("no .harmont/ directory found in {}", repo_root.display());
19+
}
20+
match scan_extensions(repo_root)? {
21+
// When both languages are present, prefer TypeScript.
22+
(_, true) => Ok(DslLanguage::TypeScript),
23+
(true, false) => Ok(DslLanguage::Python),
24+
(false, false) => bail!("no .py or .ts files found in {}", harmont_dir.display()),
25+
}
26+
}
1727

28+
/// Like [`detect_language`] but prefers **Python** when both are present.
29+
///
30+
/// Used by the machine-facing `hm pipelines` / `hm render` commands that the
31+
/// backend shells out to: the Python path is the fully-supported one (the
32+
/// discovery envelope is Python-only today), so a repo carrying both a `.py`
33+
/// and a redundant `.ts` resolves to Python rather than the unsupported TS
34+
/// registry. `hm run` keeps the TypeScript-preferring [`detect_language`].
35+
///
36+
/// # Errors
37+
///
38+
/// - The `.harmont/` directory does not exist.
39+
/// - No `.py` or `.ts` files are found inside `.harmont/`.
40+
pub fn detect_language_python_first(repo_root: &Path) -> anyhow::Result<DslLanguage> {
41+
let harmont_dir = repo_root.join(".harmont");
1842
if !harmont_dir.is_dir() {
1943
bail!("no .harmont/ directory found in {}", repo_root.display());
2044
}
45+
match scan_extensions(repo_root)? {
46+
(true, _) => Ok(DslLanguage::Python),
47+
(false, true) => Ok(DslLanguage::TypeScript),
48+
(false, false) => bail!("no .py or .ts files found in {}", harmont_dir.display()),
49+
}
50+
}
51+
52+
/// True when `.harmont/` exists and holds at least one `.py` or `.ts` file.
53+
///
54+
/// The backend fans pipeline discovery out across every repo in an
55+
/// installation, most of which declare no pipelines at all. Those repos should
56+
/// yield an empty registry, not an error — callers use this to short-circuit to
57+
/// an empty envelope instead of calling [`detect_language_python_first`].
58+
#[must_use]
59+
pub fn has_pipeline_files(repo_root: &Path) -> bool {
60+
matches!(scan_extensions(repo_root), Ok((py, ts)) if py || ts)
61+
}
62+
63+
/// Scan `.harmont/` and report `(has_py, has_ts)`. A missing `.harmont/`
64+
/// directory yields `(false, false)`; an unreadable one is an error.
65+
fn scan_extensions(repo_root: &Path) -> anyhow::Result<(bool, bool)> {
66+
let harmont_dir = repo_root.join(".harmont");
67+
if !harmont_dir.is_dir() {
68+
return Ok((false, false));
69+
}
2170

2271
let entries = std::fs::read_dir(&harmont_dir)
2372
.with_context(|| format!("failed to read {}", harmont_dir.display()))?;
2473

2574
let mut has_py = false;
2675
let mut has_ts = false;
27-
2876
for entry in entries {
2977
let entry = entry?;
30-
let path = entry.path();
31-
32-
match path.extension().and_then(|e| e.to_str()) {
78+
match entry.path().extension().and_then(|e| e.to_str()) {
3379
Some("py") => has_py = true,
3480
Some("ts") => has_ts = true,
3581
_ => {}
3682
}
3783
}
38-
39-
match (has_py, has_ts) {
40-
// When both languages are present, prefer TypeScript.
41-
(_, true) => Ok(DslLanguage::TypeScript),
42-
(true, false) => Ok(DslLanguage::Python),
43-
(false, false) => bail!("no .py or .ts files found in {}", harmont_dir.display()),
44-
}
84+
Ok((has_py, has_ts))
4585
}
4686

4787
#[cfg(test)]
@@ -107,4 +147,47 @@ mod tests {
107147
"unexpected error: {msg}"
108148
);
109149
}
150+
151+
#[test]
152+
fn python_first_prefers_python_when_mixed() {
153+
let tmp = setup(&["ci.py", "deploy.ts"]);
154+
assert_eq!(
155+
detect_language_python_first(tmp.path()).unwrap(),
156+
DslLanguage::Python
157+
);
158+
}
159+
160+
#[test]
161+
fn python_first_falls_back_to_typescript_when_only_ts() {
162+
let tmp = setup(&["ci.ts"]);
163+
assert_eq!(
164+
detect_language_python_first(tmp.path()).unwrap(),
165+
DslLanguage::TypeScript
166+
);
167+
}
168+
169+
#[test]
170+
fn python_first_no_harmont_dir_is_error() {
171+
let tmp = TempDir::new().unwrap();
172+
let err = detect_language_python_first(tmp.path()).unwrap_err();
173+
assert!(
174+
err.to_string().contains("no .harmont/ directory"),
175+
"unexpected error: {err}"
176+
);
177+
}
178+
179+
#[test]
180+
fn has_pipeline_files_true_for_py_and_ts() {
181+
assert!(has_pipeline_files(setup(&["ci.py"]).path()));
182+
assert!(has_pipeline_files(setup(&["ci.ts"]).path()));
183+
assert!(has_pipeline_files(setup(&["ci.py", "deploy.ts"]).path()));
184+
}
185+
186+
#[test]
187+
fn has_pipeline_files_false_for_missing_or_empty_harmont() {
188+
// No .harmont/ directory at all.
189+
assert!(!has_pipeline_files(TempDir::new().unwrap().path()));
190+
// .harmont/ exists but declares no .py/.ts files.
191+
assert!(!has_pipeline_files(setup(&["README.md"]).path()));
192+
}
110193
}

crates/hm/src/cli/pipelines.rs

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,36 @@ pub struct PipelinesArgs {
1111
pub dir: Option<PathBuf>,
1212
}
1313

14+
/// Empty discovery envelope, emitted when a repo declares no pipelines. Mirrors
15+
/// the shape of `harmont.dump_registry_json()` so backend discovery parses it
16+
/// the same way (it reads only the `pipelines` array).
17+
const EMPTY_ENVELOPE: &str = r#"{"schema_version":"1","pipelines":[]}"#;
18+
1419
/// Print the discovery envelope JSON (all pipelines) to stdout.
1520
///
21+
/// A repo with no `.harmont/` directory (or one with no `.py`/`.ts` files)
22+
/// declares no pipelines and yields the empty envelope rather than an error —
23+
/// the backend fans discovery out across every repo in an installation, most of
24+
/// which carry no pipelines. When both Python and TypeScript are present, Python
25+
/// wins (the registry dump is Python-only today).
26+
///
1627
/// # Errors
1728
///
18-
/// Returns an error if the language can't be detected, the engine can't start,
19-
/// or the DSL runtime fails to evaluate the pipelines.
29+
/// Returns an error if the engine can't start or the DSL runtime fails to
30+
/// evaluate the pipelines.
2031
pub async fn run(args: PipelinesArgs) -> Result<()> {
2132
let repo_root = match args.dir {
2233
Some(d) => d,
2334
None => std::env::current_dir().context("cannot determine current directory")?,
2435
};
2536

26-
let lang = detect::detect_language(&repo_root).context("detecting pipeline language")?;
37+
if !detect::has_pipeline_files(&repo_root) {
38+
print!("{EMPTY_ENVELOPE}");
39+
return Ok(());
40+
}
41+
42+
let lang =
43+
detect::detect_language_python_first(&repo_root).context("detecting pipeline language")?;
2744
let engine = engine_for(lang).context("initializing DSL engine")?;
2845
let json = engine
2946
.registry_json(&repo_root)

crates/hm/src/cli/render.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ pub struct RenderArgs {
1717

1818
/// Render one pipeline's v0 IR JSON to stdout without executing it.
1919
///
20+
/// When both Python and TypeScript are present, Python wins (the supported
21+
/// backend path), matching `hm pipelines`.
22+
///
2023
/// # Errors
2124
///
2225
/// Returns an error if the language can't be detected, the engine can't start,
@@ -28,7 +31,8 @@ pub async fn run(args: RenderArgs) -> Result<()> {
2831
None => std::env::current_dir().context("cannot determine current directory")?,
2932
};
3033

31-
let lang = detect::detect_language(&repo_root).context("detecting pipeline language")?;
34+
let lang =
35+
detect::detect_language_python_first(&repo_root).context("detecting pipeline language")?;
3236
let engine = engine_for(lang).context("initializing DSL engine")?;
3337
let json = engine
3438
.render_pipeline_json(&repo_root, &args.slug)

crates/hm/tests/cmd_pipelines.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,26 @@ def ci() -> hm.Step:
4545
assert_eq!(v["pipelines"][0]["slug"], "ci");
4646
assert_eq!(v["pipelines"][0]["triggers"][0]["event"], "push");
4747
}
48+
49+
#[test]
50+
fn pipelines_emits_empty_envelope_when_no_harmont_dir() {
51+
// A repo that declares no pipelines must yield an empty envelope, not an
52+
// error (the backend fans discovery across every repo in an installation,
53+
// most of which carry no `.harmont/`). No python3 needed — this short-
54+
// circuits before the DSL engine, so the test always runs.
55+
let dir = tempfile::tempdir().unwrap();
56+
57+
let out = Command::cargo_bin("hm")
58+
.unwrap()
59+
.arg("pipelines")
60+
.arg("--dir")
61+
.arg(dir.path())
62+
.assert()
63+
.success()
64+
.get_output()
65+
.stdout
66+
.clone();
67+
68+
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
69+
assert_eq!(v["pipelines"], serde_json::json!([]));
70+
}

0 commit comments

Comments
 (0)