use std::collections::HashMap;
use anyhow::{Context, Result};
use hm_dsl_engine::detect;
use crate::cli::RunArgs;
use crate::context::RunContext;
use crate::error::{ErrorCategory, HmError};
pub async fn handle(args: RunArgs, ctx: RunContext) -> Result<i32> {
let backend_name = args
.backend
.clone()
.or_else(|| {
if args.cloud {
Some("cloud".to_string())
} else {
None
}
})
.unwrap_or_else(|| ctx.config.backend.to_string());
let cloud_creds = if backend_name == "cloud" {
let api_url = ctx.config.cloud.api_url.clone();
let token = hm_config::creds::cloud_token(&api_url).context(
"`hm run --backend cloud` requires authentication — run `hm cloud login` or set HM_API_TOKEN",
)?;
let org = args
.org
.clone()
.or_else(|| ctx.config.cloud.org.clone())
.context("no organization — pass --org or set `[cloud] org = \"…\"` in .hm/config.toml or ~/.config/hm/config.toml")?;
Some((api_url, token, org))
} else if backend_name != "docker" {
anyhow::bail!("unknown --backend '{backend_name}'\n available: docker, cloud");
} else {
None
};
let (repo_root, slug, ir_json) = render_pipeline(&args, &ctx).await?;
let plan = hm_exec::Plan::parse(ir_json).map_err(|e| backend_anyhow(&e))?;
let use_logs = args.logs
|| std::env::var_os("CI").is_some_and(|v| !v.is_empty())
|| !hm_render::stderr_interactive();
let renderer = hm_render::renderer_for(&args.format, ctx.output.color_enabled(), use_logs)?;
let backend: Box<dyn hm_exec::ExecutionBackend> =
if let Some((api_url, token, org)) = cloud_creds {
let client = harmont_cloud::HarmontClient::with_base_url(token, &api_url);
let app_url = hm_config::app_url(&api_url, std::env::var("HM_APP_URL").ok().as_deref());
Box::new(hm_exec::CloudBackend::new(client, api_url, app_url, org))
} else {
let vm_backend: std::sync::Arc<dyn hm_vm::VmBackend> = std::sync::Arc::new(
hm_vm::docker::DockerBackend::connect().map_err(|e| anyhow::anyhow!("{e:#}"))?,
);
Box::new(hm_exec::LocalBackend::new(
resolve_parallelism(&args),
vm_backend,
))
};
let caps = backend.capabilities();
if args.no_watch && !caps.supports_no_watch {
anyhow::bail!(
"--no-watch is not supported by the {} backend",
backend.name()
);
}
if args.parallelism.is_some() && !caps.honors_parallelism {
tracing::warn!(
"--parallelism is ignored by the {} backend (the server schedules)",
backend.name()
);
}
if args.keep_going && !caps.honors_keep_going {
tracing::warn!(
"-k/--keep-going is ignored by the {} backend (the server schedules)",
backend.name()
);
}
let (branch, commit) = git_metadata(&repo_root, args.branch.clone());
let req = hm_exec::RunRequest {
plan,
repo_root,
pipeline_slug: slug,
env: parse_env(&args.env).into_iter().collect(),
source: hm_exec::SourceMeta {
branch,
commit,
message: args.message.clone(),
},
options: hm_exec::RunOptions {
no_cache: false,
timeout: None,
watch: !args.no_watch,
keep_going: args.keep_going,
},
};
let handle = backend.start(req).await.map_err(|e| backend_anyhow(&e))?;
let (events, control) = handle.into_parts();
let _ctrlc = crate::signal::install_ctrlc(control.cancel_token());
let render = tokio::spawn(hm_render::drive_stream(renderer, events));
let outcome = control.wait().await.map_err(|e| backend_anyhow(&e))?;
let _ = render.await;
Ok(outcome.status.exit_code())
}
fn resolve_parallelism(args: &RunArgs) -> std::num::NonZeroUsize {
use std::num::NonZeroUsize;
const FALLBACK: NonZeroUsize = NonZeroUsize::new(4).unwrap();
args.parallelism.map_or_else(
|| std::thread::available_parallelism().unwrap_or(FALLBACK),
|n| NonZeroUsize::new(n).unwrap_or(NonZeroUsize::MIN),
)
}
#[must_use]
fn parse_env(pairs: &[String]) -> HashMap<String, String> {
pairs
.iter()
.filter_map(|p| {
p.split_once('=')
.map(|(k, v)| (k.to_string(), v.to_string()))
})
.collect()
}
fn git_metadata(root: &std::path::Path, branch_override: Option<String>) -> (String, String) {
let run = |a: &[&str]| {
std::process::Command::new("git")
.arg("-C")
.arg(root)
.args(a)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
};
let branch = branch_override
.or_else(|| run(&["rev-parse", "--abbrev-ref", "HEAD"]))
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "HEAD".to_string());
let commit = run(&["rev-parse", "HEAD"])
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "0".repeat(40));
(branch, commit)
}
async fn render_pipeline(
args: &RunArgs,
_ctx: &RunContext,
) -> Result<(std::path::PathBuf, String, String)> {
let repo_root = match args.dir.clone() {
Some(p) => p,
None => std::env::current_dir().context("cannot determine current directory")?,
};
let lang =
detect::detect_language(&repo_root).map_err(|e| HmError::DslEngine(format!("{e:#}")))?;
let engine =
hm_dsl_engine::engine_for(lang).map_err(|e| HmError::DslEngine(format!("{e:#}")))?;
let slug = if let Some(s) = &args.pipeline {
s.clone()
} else {
let metas: Vec<hm_dsl_engine::PipelineMeta> = engine
.list_pipelines(&repo_root)
.await
.map_err(|e| HmError::PipelineRender(format!("{e:#}")))?;
let slugs: Vec<String> = metas.into_iter().map(|m| m.slug).collect();
match slugs.as_slice() {
[only] => only.clone(),
[] => anyhow::bail!(
"no pipelines declared in this repo\n \
hint: define one with `@hm.pipeline(\"slug\")` in `.hm/pipeline.py`"
),
many => anyhow::bail!(
"this repo declares pipelines: {}\n → pass one as the first argument",
many.join(", ")
),
}
};
let json_str = engine
.render_pipeline_json(&repo_root, &slug)
.await
.map_err(|e| HmError::PipelineRender(format!("{e:#}")))?;
Ok((repo_root, slug, json_str))
}
fn backend_anyhow(err: &hm_exec::BackendError) -> anyhow::Error {
HmError::Backend(explain(err), exit_category(err)).into()
}
const fn exit_category(err: &hm_exec::BackendError) -> ErrorCategory {
use hm_exec::BackendError as E;
match err {
E::Rejected { .. } => ErrorCategory::PipelineInvalid,
E::SourceTooLarge { .. } => ErrorCategory::Usage,
E::Unauthorized => ErrorCategory::Auth,
E::Transport(_) | E::Local(_) => ErrorCategory::Network,
E::NotFound(_) => ErrorCategory::Api,
_ => ErrorCategory::BuildFailed,
}
}
fn explain(err: &hm_exec::BackendError) -> String {
use hm_exec::BackendError as E;
match err {
E::Unauthorized => "\
error[auth_required]: not authenticated
fix run `hm cloud login` (or set HM_API_TOKEN)
docs https://harmont.dev/docs/errors/auth_required"
.to_string(),
E::Rejected { code, message } => format!(
"\
error[{code}]: {message}
fix fix the pipeline and re-run `hm run`
docs https://harmont.dev/docs/errors/{code}"
),
E::NotFound(what) => format!(
"\
error[not_found]: {what}
fix check the org, pipeline, and build number are correct
docs https://harmont.dev/docs/errors/not_found"
),
E::Transport(m) => format!(
"\
error[network]: {m}
fix check your connection and the API URL (HM_API_URL)
docs https://harmont.dev/docs/errors/network"
),
E::LogStream(m) => format!(
"\
error[log_stream]: live logs interrupted — {m}
fix the build continues; re-attach with `hm cloud build show`
docs https://harmont.dev/docs/errors/log_stream"
),
E::Local(m) => format!(
"\
error[local]: {m}
fix check that the Docker daemon is running (`docker version`)
docs https://harmont.dev/docs/errors/local"
),
E::SourceTooLarge {
observed_bytes,
cap_bytes,
largest_paths,
} => {
#[allow(clippy::cast_precision_loss)] let mb = |b: u64| format!("{:.1} MB", b as f64 / (1024.0 * 1024.0));
let biggest = if largest_paths.is_empty() {
" (no large top-level paths identified)".to_string()
} else {
largest_paths
.iter()
.map(|(name, sz)| format!(" {name} — {}", mb(*sz)))
.collect::<Vec<_>>()
.join("\n")
};
format!(
"\
error[source_too_large]: worktree archive is {observed} (cap {cap})
biggest\n{biggest}
fix add the offending paths to .gitignore (build output, caches, vendored deps), then re-run `hm run`
docs https://harmont.dev/docs/errors/source_too_large",
observed = mb(*observed_bytes),
cap = mb(*cap_bytes),
)
}
other => format!(
"\
error[backend]: {other}
docs https://harmont.dev/docs/errors/backend"
),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
#[test]
fn parse_env_splits_pairs() {
let m = parse_env(&["A=1".into(), "B=x=y".into(), "bad".into()]);
assert_eq!(m.get("A").unwrap(), "1");
assert_eq!(m.get("B").unwrap(), "x=y");
assert!(!m.contains_key("bad"));
}
#[test]
fn git_metadata_falls_back_outside_repo() {
let (b, c) = git_metadata(std::path::Path::new("/"), None);
assert!(!b.is_empty() && !c.is_empty());
assert_eq!(c.len(), 40); }
#[test]
fn explain_carries_stable_codes_and_docs() {
use hm_exec::BackendError as E;
assert!(explain(&E::Unauthorized).contains("error[auth_required]"));
assert!(explain(&E::NotFound("x".into())).contains("error[not_found]"));
assert!(explain(&E::LogStream("x".into())).contains("error[log_stream]"));
assert!(explain(&E::Transport("x".into())).contains("error[network]"));
assert!(explain(&E::Local("x".into())).contains("error[local]"));
let r = explain(&E::Rejected {
code: "invalid_ir".into(),
message: "bad IR".into(),
});
assert!(r.contains("error[invalid_ir]") && r.contains("bad IR"));
let big = explain(&E::SourceTooLarge {
observed_bytes: 7 * 1024 * 1024,
cap_bytes: 6 * 1024 * 1024,
largest_paths: vec![("node_modules".into(), 5 * 1024 * 1024)],
});
assert!(big.contains("error[source_too_large]"));
assert!(big.contains("7.0 MB") && big.contains("6.0 MB"));
assert!(big.contains("node_modules") && big.contains(".gitignore"));
assert!(big.contains("docs https://harmont.dev/docs/errors/source_too_large"));
for s in [
explain(&E::Unauthorized),
explain(&E::NotFound("x".into())),
explain(&E::Transport("x".into())),
explain(&E::Local("x".into())),
] {
assert!(s.contains("docs https://harmont.dev/docs/errors/"));
}
}
#[test]
fn exit_category_preserves_taxonomy() {
use hm_exec::BackendError as E;
assert_eq!(
exit_category(&E::Rejected {
code: "invalid_ir".into(),
message: String::new()
}),
ErrorCategory::PipelineInvalid
);
assert_eq!(exit_category(&E::Unauthorized), ErrorCategory::Auth);
assert_eq!(
exit_category(&E::Transport("x".into())),
ErrorCategory::Network
);
assert_eq!(exit_category(&E::Local("x".into())), ErrorCategory::Network);
assert_eq!(exit_category(&E::NotFound("x".into())), ErrorCategory::Api);
assert_eq!(
exit_category(&E::SourceTooLarge {
observed_bytes: 1,
cap_bytes: 0,
largest_paths: vec![],
}),
ErrorCategory::Usage
);
}
}