diff --git a/crates/hm-dsl-engine/src/lib.rs b/crates/hm-dsl-engine/src/lib.rs index bb4b4b5a..2d80324f 100644 --- a/crates/hm-dsl-engine/src/lib.rs +++ b/crates/hm-dsl-engine/src/lib.rs @@ -25,6 +25,11 @@ pub struct PipelineMeta { pub trait DslEngine: Send + Sync { async fn list_pipelines(&self, project_dir: &Path) -> anyhow::Result>; async fn render_pipeline_json(&self, project_dir: &Path, slug: &str) -> anyhow::Result; + /// Emit the full discovery envelope JSON for every pipeline in the repo: + /// `{"schema_version": "...", "pipelines": [{slug, name, allow_manual, + /// triggers, definition}, ...]}`. Returned verbatim from the DSL runtime so + /// the backend's pipeline discovery can consume it directly. + async fn registry_json(&self, project_dir: &Path) -> anyhow::Result; } /// Return an appropriate [`DslEngine`] for the given language. diff --git a/crates/hm-dsl-engine/src/python_engine.rs b/crates/hm-dsl-engine/src/python_engine.rs index 654f0a2c..4ff3f21a 100644 --- a/crates/hm-dsl-engine/src/python_engine.rs +++ b/crates/hm-dsl-engine/src/python_engine.rs @@ -23,6 +23,20 @@ envelope = json.loads(hm.dump_registry_json()) print(json.dumps([{'slug': p['slug'], 'name': p['name']} for p in envelope['pipelines']])) "; +const REGISTRY_JSON_SCRIPT: &str = "\ +import sys, pathlib, importlib.util +try: + import harmont as hm +except ImportError as e: + print(f'error: {e}\\n -> install with: pip install croniter python-dateutil', file=sys.stderr) + sys.exit(1) +for p in sorted(pathlib.Path('.harmont').glob('*.py')): + spec = importlib.util.spec_from_file_location(f'_harmont_{p.stem}', p) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) +sys.stdout.write(hm.dump_registry_json()) +"; + const RENDER_PIPELINE_SCRIPT: &str = "\ import sys, json, pathlib, importlib.util try: @@ -114,4 +128,10 @@ impl DslEngine for SubprocessPythonEngine { .await .context("rendering pipeline via python3") } + + async fn registry_json(&self, project_dir: &Path) -> Result { + self.run_script(project_dir, REGISTRY_JSON_SCRIPT, &[]) + .await + .context("dumping pipeline registry via python3") + } } diff --git a/crates/hm-dsl-engine/src/ts_engine.rs b/crates/hm-dsl-engine/src/ts_engine.rs index e25b7c58..7e688298 100644 --- a/crates/hm-dsl-engine/src/ts_engine.rs +++ b/crates/hm-dsl-engine/src/ts_engine.rs @@ -223,4 +223,11 @@ impl DslEngine for SubprocessTsEngine { .await .context("rendering pipeline via JS runtime") } + + async fn registry_json(&self, _project_dir: &Path) -> Result { + bail!( + "the discovery envelope (hm pipelines) is not yet supported for \ + TypeScript pipelines; only Python pipelines are supported today" + ) + } } diff --git a/crates/hm-dsl-engine/tests/python_engine_test.rs b/crates/hm-dsl-engine/tests/python_engine_test.rs index b6817891..413b671a 100644 --- a/crates/hm-dsl-engine/tests/python_engine_test.rs +++ b/crates/hm-dsl-engine/tests/python_engine_test.rs @@ -39,3 +39,37 @@ def ci() -> hm.Step: let v: serde_json::Value = serde_json::from_str(&json_str).unwrap(); assert_eq!(v["version"], "0"); } + +#[tokio::test] +async fn python_registry_json_carries_triggers_and_allow_manual() { + if which::which("python3").is_err() { + eprintln!("skipping: python3 not on PATH"); + return; + } + + let dir = tempfile::tempdir().unwrap(); + let harmont = dir.path().join(".harmont"); + std::fs::create_dir_all(&harmont).unwrap(); + std::fs::write( + harmont.join("ci.py"), + r#"import harmont as hm + +@hm.pipeline('ci', name='CI', triggers=[hm.push(branch='main')], allow_manual=False) +def ci() -> hm.Step: + return hm.scratch().sh('echo test', label='test') +"#, + ) + .unwrap(); + + let engine = hm_dsl_engine::engine_for(hm_dsl_engine::DslLanguage::Python).unwrap(); + let json = engine.registry_json(dir.path()).await.unwrap(); + let v: serde_json::Value = serde_json::from_str(&json).unwrap(); + + let p = &v["pipelines"][0]; + assert_eq!(p["slug"], "ci"); + assert_eq!(p["name"], "CI"); + assert_eq!(p["allow_manual"], false); + assert_eq!(p["triggers"][0]["event"], "push"); + assert_eq!(p["triggers"][0]["branches"][0], "main"); + assert_eq!(p["definition"]["version"], "0"); +} diff --git a/crates/hm/src/cli/mod.rs b/crates/hm/src/cli/mod.rs index bc1c8d1a..60b31000 100644 --- a/crates/hm/src/cli/mod.rs +++ b/crates/hm/src/cli/mod.rs @@ -1,4 +1,6 @@ +pub mod pipelines; pub mod plugin; +pub mod render; pub mod run; pub mod version; @@ -45,6 +47,12 @@ pub enum Command { /// Run a pipeline locally via Docker. Run(RunArgs), + /// Print the pipeline discovery envelope (JSON) for every pipeline in the repo. + Pipelines(pipelines::PipelinesArgs), + + /// Render one pipeline's v0 IR (JSON) without running it. + Render(render::RenderArgs), + /// Show hm version. Version, @@ -91,6 +99,8 @@ pub struct CacheRestoreArgs { pub async fn dispatch(command: Command, ctx: RunContext) -> Result { match command { Command::Run(args) => crate::commands::run::handle(args, ctx).await, + Command::Pipelines(args) => crate::cli::pipelines::run(args).await.map(|()| 0), + Command::Render(args) => crate::cli::render::run(args).await.map(|()| 0), Command::Cache(cmd) => match cmd { CacheCommand::Save(args) => crate::commands::cache::handle_save(&args.dir).await, CacheCommand::Restore(args) => crate::commands::cache::handle_restore(&args.dir).await, diff --git a/crates/hm/src/cli/pipelines.rs b/crates/hm/src/cli/pipelines.rs new file mode 100644 index 00000000..acc2c971 --- /dev/null +++ b/crates/hm/src/cli/pipelines.rs @@ -0,0 +1,36 @@ +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use clap::Parser; +use hm_dsl_engine::{detect, engine_for}; + +#[derive(Debug, Clone, Parser)] +pub struct PipelinesArgs { + /// Source root containing `.harmont/` (defaults to cwd). + #[arg(short, long)] + pub dir: Option, +} + +/// Print the discovery envelope JSON (all pipelines) to stdout. +/// +/// # 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. +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")?; + let engine = engine_for(lang).context("initializing DSL engine")?; + let json = engine + .registry_json(&repo_root) + .await + .context("dumping pipeline registry")?; + + // Machine-facing: raw envelope JSON on stdout, nothing else. + print!("{json}"); + Ok(()) +} diff --git a/crates/hm/src/cli/render.rs b/crates/hm/src/cli/render.rs new file mode 100644 index 00000000..4a7d6202 --- /dev/null +++ b/crates/hm/src/cli/render.rs @@ -0,0 +1,41 @@ +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use clap::Parser; +use hm_dsl_engine::{detect, engine_for}; + +#[derive(Debug, Clone, Parser)] +pub struct RenderArgs { + /// Pipeline slug to render. + #[arg()] + pub slug: String, + + /// Source root containing `.harmont/` (defaults to cwd). + #[arg(short, long)] + pub dir: Option, +} + +/// Render one pipeline's v0 IR JSON to stdout without executing it. +/// +/// # Errors +/// +/// Returns an error if the language can't be detected, the engine can't start, +/// or the slug is unknown / fails to render (the available slugs are written to +/// stderr by the DSL runtime). +pub async fn run(args: RenderArgs) -> 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")?; + let engine = engine_for(lang).context("initializing DSL engine")?; + let json = engine + .render_pipeline_json(&repo_root, &args.slug) + .await + .with_context(|| format!("rendering pipeline {:?}", args.slug))?; + + // Machine-facing: raw v0 IR JSON on stdout, nothing else. + print!("{json}"); + Ok(()) +} diff --git a/crates/hm/tests/cmd_pipelines.rs b/crates/hm/tests/cmd_pipelines.rs new file mode 100644 index 00000000..5e282575 --- /dev/null +++ b/crates/hm/tests/cmd_pipelines.rs @@ -0,0 +1,47 @@ +//! `hm pipelines` emits the discovery envelope JSON to stdout. + +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::print_stderr +)] + +use assert_cmd::Command; + +#[test] +fn pipelines_emits_discovery_envelope() { + if which::which("python3").is_err() { + eprintln!("skipping: python3 not on PATH"); + return; + } + + let dir = tempfile::tempdir().unwrap(); + let harmont = dir.path().join(".harmont"); + std::fs::create_dir_all(&harmont).unwrap(); + std::fs::write( + harmont.join("ci.py"), + r"import harmont as hm + +@hm.pipeline('ci', name='CI', triggers=[hm.push(branch='main')]) +def ci() -> hm.Step: + return hm.scratch().sh('echo test', label='test') +", + ) + .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"][0]["slug"], "ci"); + assert_eq!(v["pipelines"][0]["triggers"][0]["event"], "push"); +} diff --git a/crates/hm/tests/cmd_render.rs b/crates/hm/tests/cmd_render.rs new file mode 100644 index 00000000..af649b98 --- /dev/null +++ b/crates/hm/tests/cmd_render.rs @@ -0,0 +1,78 @@ +//! `hm render ` emits one pipeline's v0 IR JSON to stdout. + +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::print_stderr +)] + +use assert_cmd::Command; + +fn write_fixture(dir: &std::path::Path) { + let harmont = dir.join(".harmont"); + std::fs::create_dir_all(&harmont).unwrap(); + std::fs::write( + harmont.join("ci.py"), + r"import harmont as hm + +@hm.pipeline('ci') +def ci() -> hm.Step: + return hm.scratch().sh('echo test', label='test') +", + ) + .unwrap(); +} + +#[test] +fn render_emits_v0_ir_for_slug() { + if which::which("python3").is_err() { + eprintln!("skipping: python3 not on PATH"); + return; + } + + let dir = tempfile::tempdir().unwrap(); + write_fixture(dir.path()); + + let out = Command::cargo_bin("hm") + .unwrap() + .arg("render") + .arg("ci") + .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["version"], "0"); + assert!( + v["graph"].is_object(), + "expected a graph object in the v0 IR, got: {v}" + ); +} + +#[test] +fn render_unknown_slug_fails_with_available_on_stderr() { + if which::which("python3").is_err() { + eprintln!("skipping: python3 not on PATH"); + return; + } + + let dir = tempfile::tempdir().unwrap(); + write_fixture(dir.path()); + + Command::cargo_bin("hm") + .unwrap() + .arg("render") + .arg("nope") + .arg("--dir") + .arg(dir.path()) + .assert() + .failure() + // Both the bad slug and the list of available slugs must reach stderr. + .stderr(predicates::str::contains("nope")) + .stderr(predicates::str::contains("available: ci")); +}