Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 96 additions & 13 deletions crates/hm-dsl-engine/src/detect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,43 +5,83 @@ use anyhow::{Context, bail};
use crate::DslLanguage;

/// Detect the DSL language used in a project by scanning `.harmont/` for file
/// extensions.
/// extensions. Prefers **TypeScript** when both are present (the `hm run`
/// default).
///
/// # Errors
///
/// - The `.harmont/` directory does not exist.
/// - No `.py` or `.ts` files are found inside `.harmont/`.
/// - Both `.py` and `.ts` files are present (mixed languages).
pub fn detect_language(repo_root: &Path) -> anyhow::Result<DslLanguage> {
let harmont_dir = repo_root.join(".harmont");
if !harmont_dir.is_dir() {
bail!("no .harmont/ directory found in {}", repo_root.display());
}
match scan_extensions(repo_root)? {
// When both languages are present, prefer TypeScript.
(_, true) => Ok(DslLanguage::TypeScript),
(true, false) => Ok(DslLanguage::Python),
(false, false) => bail!("no .py or .ts files found in {}", harmont_dir.display()),
}
}

/// Like [`detect_language`] but prefers **Python** when both are present.
///
/// Used by the machine-facing `hm pipelines` / `hm render` commands that the
/// backend shells out to: the Python path is the fully-supported one (the
/// discovery envelope is Python-only today), so a repo carrying both a `.py`
/// and a redundant `.ts` resolves to Python rather than the unsupported TS
/// registry. `hm run` keeps the TypeScript-preferring [`detect_language`].
///
/// # Errors
///
/// - The `.harmont/` directory does not exist.
/// - No `.py` or `.ts` files are found inside `.harmont/`.
pub fn detect_language_python_first(repo_root: &Path) -> anyhow::Result<DslLanguage> {
let harmont_dir = repo_root.join(".harmont");
if !harmont_dir.is_dir() {
bail!("no .harmont/ directory found in {}", repo_root.display());
}
match scan_extensions(repo_root)? {
(true, _) => Ok(DslLanguage::Python),
(false, true) => Ok(DslLanguage::TypeScript),
(false, false) => bail!("no .py or .ts files found in {}", harmont_dir.display()),
}
}

/// True when `.harmont/` exists and holds at least one `.py` or `.ts` file.
///
/// The backend fans pipeline discovery out across every repo in an
/// installation, most of which declare no pipelines at all. Those repos should
/// yield an empty registry, not an error — callers use this to short-circuit to
/// an empty envelope instead of calling [`detect_language_python_first`].
#[must_use]
pub fn has_pipeline_files(repo_root: &Path) -> bool {
matches!(scan_extensions(repo_root), Ok((py, ts)) if py || ts)
}

/// Scan `.harmont/` and report `(has_py, has_ts)`. A missing `.harmont/`
/// directory yields `(false, false)`; an unreadable one is an error.
fn scan_extensions(repo_root: &Path) -> anyhow::Result<(bool, bool)> {
let harmont_dir = repo_root.join(".harmont");
if !harmont_dir.is_dir() {
return Ok((false, false));
}

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

let mut has_py = false;
let mut has_ts = false;

for entry in entries {
let entry = entry?;
let path = entry.path();

match path.extension().and_then(|e| e.to_str()) {
match entry.path().extension().and_then(|e| e.to_str()) {
Some("py") => has_py = true,
Some("ts") => has_ts = true,
_ => {}
}
}

match (has_py, has_ts) {
// When both languages are present, prefer TypeScript.
(_, true) => Ok(DslLanguage::TypeScript),
(true, false) => Ok(DslLanguage::Python),
(false, false) => bail!("no .py or .ts files found in {}", harmont_dir.display()),
}
Ok((has_py, has_ts))
}

#[cfg(test)]
Expand Down Expand Up @@ -107,4 +147,47 @@ mod tests {
"unexpected error: {msg}"
);
}

#[test]
fn python_first_prefers_python_when_mixed() {
let tmp = setup(&["ci.py", "deploy.ts"]);
assert_eq!(
detect_language_python_first(tmp.path()).unwrap(),
DslLanguage::Python
);
}

#[test]
fn python_first_falls_back_to_typescript_when_only_ts() {
let tmp = setup(&["ci.ts"]);
assert_eq!(
detect_language_python_first(tmp.path()).unwrap(),
DslLanguage::TypeScript
);
}

#[test]
fn python_first_no_harmont_dir_is_error() {
let tmp = TempDir::new().unwrap();
let err = detect_language_python_first(tmp.path()).unwrap_err();
assert!(
err.to_string().contains("no .harmont/ directory"),
"unexpected error: {err}"
);
}

#[test]
fn has_pipeline_files_true_for_py_and_ts() {
assert!(has_pipeline_files(setup(&["ci.py"]).path()));
assert!(has_pipeline_files(setup(&["ci.ts"]).path()));
assert!(has_pipeline_files(setup(&["ci.py", "deploy.ts"]).path()));
}

#[test]
fn has_pipeline_files_false_for_missing_or_empty_harmont() {
// No .harmont/ directory at all.
assert!(!has_pipeline_files(TempDir::new().unwrap().path()));
// .harmont/ exists but declares no .py/.ts files.
assert!(!has_pipeline_files(setup(&["README.md"]).path()));
}
}
23 changes: 20 additions & 3 deletions crates/hm/src/cli/pipelines.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,36 @@ pub struct PipelinesArgs {
pub dir: Option<PathBuf>,
}

/// Empty discovery envelope, emitted when a repo declares no pipelines. Mirrors
/// the shape of `harmont.dump_registry_json()` so backend discovery parses it
/// the same way (it reads only the `pipelines` array).
const EMPTY_ENVELOPE: &str = r#"{"schema_version":"1","pipelines":[]}"#;

/// Print the discovery envelope JSON (all pipelines) to stdout.
///
/// A repo with no `.harmont/` directory (or one with no `.py`/`.ts` files)
/// declares no pipelines and yields the empty envelope rather than an error —
/// the backend fans discovery out across every repo in an installation, most of
/// which carry no pipelines. When both Python and TypeScript are present, Python
/// wins (the registry dump is Python-only today).
///
/// # Errors
///
/// Returns an error if the language can't be detected, the engine can't start,
/// or the DSL runtime fails to evaluate the pipelines.
/// Returns an error if the engine can't start or the DSL runtime fails to
/// evaluate the pipelines.
pub async fn run(args: PipelinesArgs) -> Result<()> {
let repo_root = match args.dir {
Some(d) => d,
None => std::env::current_dir().context("cannot determine current directory")?,
};

let lang = detect::detect_language(&repo_root).context("detecting pipeline language")?;
if !detect::has_pipeline_files(&repo_root) {
print!("{EMPTY_ENVELOPE}");
return Ok(());
}

let lang =
detect::detect_language_python_first(&repo_root).context("detecting pipeline language")?;
let engine = engine_for(lang).context("initializing DSL engine")?;
let json = engine
.registry_json(&repo_root)
Expand Down
6 changes: 5 additions & 1 deletion crates/hm/src/cli/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ pub struct RenderArgs {

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

let lang = detect::detect_language(&repo_root).context("detecting pipeline language")?;
let lang =
detect::detect_language_python_first(&repo_root).context("detecting pipeline language")?;
let engine = engine_for(lang).context("initializing DSL engine")?;
let json = engine
.render_pipeline_json(&repo_root, &args.slug)
Expand Down
23 changes: 23 additions & 0 deletions crates/hm/tests/cmd_pipelines.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,26 @@ def ci() -> hm.Step:
assert_eq!(v["pipelines"][0]["slug"], "ci");
assert_eq!(v["pipelines"][0]["triggers"][0]["event"], "push");
}

#[test]
fn pipelines_emits_empty_envelope_when_no_harmont_dir() {
// A repo that declares no pipelines must yield an empty envelope, not an
// error (the backend fans discovery across every repo in an installation,
// most of which carry no `.harmont/`). No python3 needed — this short-
// circuits before the DSL engine, so the test always runs.
let dir = tempfile::tempdir().unwrap();

let out = Command::cargo_bin("hm")
.unwrap()
.arg("pipelines")
.arg("--dir")
.arg(dir.path())
.assert()
.success()
.get_output()
.stdout
.clone();

let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert_eq!(v["pipelines"], serde_json::json!([]));
}
Loading