harmont_cli/commands/run/
mod.rs1use std::collections::HashMap;
2
3use anyhow::{Context, Result};
4
5use hm_dsl_engine::detect;
6
7use crate::cli::RunArgs;
8use crate::context::RunContext;
9use crate::error::{ErrorCategory, HmError};
10
11pub async fn handle(args: RunArgs, ctx: RunContext) -> Result<i32> {
34 let backend_name = args
37 .backend
38 .clone()
39 .or_else(|| {
40 if args.cloud {
41 Some("cloud".to_string())
42 } else {
43 None
44 }
45 })
46 .unwrap_or_else(|| ctx.config.backend.to_string());
47
48 let cloud_creds = if backend_name == "cloud" {
55 let api_url = ctx.config.cloud.api_url.clone();
56 let token = hm_config::creds::cloud_token(&api_url).context(
57 "`hm run --backend cloud` requires authentication — run `hm cloud login` or set HM_API_TOKEN",
58 )?;
59 let org = args
60 .org
61 .clone()
62 .or_else(|| ctx.config.cloud.org.clone())
63 .context("no organization — pass --org or set `[cloud] org = \"…\"` in .hm/config.toml or ~/.config/hm/config.toml")?;
64 Some((api_url, token, org))
65 } else if backend_name != "docker" {
66 anyhow::bail!("unknown --backend '{backend_name}'\n available: docker, cloud");
67 } else {
68 None
69 };
70
71 let (repo_root, slug, ir_json) = render_pipeline(&args, &ctx).await?;
75 let plan = hm_exec::Plan::parse(ir_json).map_err(|e| backend_anyhow(&e))?;
76
77 let use_logs = args.logs
80 || std::env::var_os("CI").is_some_and(|v| !v.is_empty())
81 || !hm_render::stderr_interactive();
82 let renderer = hm_render::renderer_for(&args.format, ctx.output.color_enabled(), use_logs)?;
83
84 let backend: Box<dyn hm_exec::ExecutionBackend> =
86 if let Some((api_url, token, org)) = cloud_creds {
87 let client = harmont_cloud::HarmontClient::with_base_url(token, &api_url);
88 let app_url = hm_config::app_url(&api_url, std::env::var("HM_APP_URL").ok().as_deref());
91 Box::new(hm_exec::CloudBackend::new(client, api_url, app_url, org))
92 } else {
93 let vm_backend: std::sync::Arc<dyn hm_vm::VmBackend> = std::sync::Arc::new(
95 hm_vm::docker::DockerBackend::connect().map_err(|e| anyhow::anyhow!("{e:#}"))?,
96 );
97 Box::new(hm_exec::LocalBackend::new(
98 resolve_parallelism(&args),
99 vm_backend,
100 ))
101 };
102
103 let caps = backend.capabilities();
105 if args.no_watch && !caps.supports_no_watch {
106 anyhow::bail!(
107 "--no-watch is not supported by the {} backend",
108 backend.name()
109 );
110 }
111 if args.parallelism.is_some() && !caps.honors_parallelism {
112 tracing::warn!(
113 "--parallelism is ignored by the {} backend (the server schedules)",
114 backend.name()
115 );
116 }
117 if args.keep_going && !caps.honors_keep_going {
118 tracing::warn!(
119 "-k/--keep-going is ignored by the {} backend (the server schedules)",
120 backend.name()
121 );
122 }
123
124 let (branch, commit) = git_metadata(&repo_root, args.branch.clone());
126 let req = hm_exec::RunRequest {
127 plan,
128 repo_root,
129 pipeline_slug: slug,
130 env: parse_env(&args.env).into_iter().collect(),
131 source: hm_exec::SourceMeta {
132 branch,
133 commit,
134 message: args.message.clone(),
135 },
136 options: hm_exec::RunOptions {
137 no_cache: false,
138 timeout: None,
139 watch: !args.no_watch,
140 keep_going: args.keep_going,
141 },
142 };
143
144 let handle = backend.start(req).await.map_err(|e| backend_anyhow(&e))?;
146 let (events, control) = handle.into_parts();
147 let _ctrlc = crate::signal::install_ctrlc(control.cancel_token());
148 let render = tokio::spawn(hm_render::drive_stream(renderer, events));
149 let outcome = control.wait().await.map_err(|e| backend_anyhow(&e))?;
150 let _ = render.await;
151
152 Ok(outcome.status.exit_code())
153}
154
155fn resolve_parallelism(args: &RunArgs) -> std::num::NonZeroUsize {
160 use std::num::NonZeroUsize;
161 const FALLBACK: NonZeroUsize = NonZeroUsize::new(4).unwrap();
164 args.parallelism.map_or_else(
165 || std::thread::available_parallelism().unwrap_or(FALLBACK),
166 |n| NonZeroUsize::new(n).unwrap_or(NonZeroUsize::MIN),
167 )
168}
169
170#[must_use]
172fn parse_env(pairs: &[String]) -> HashMap<String, String> {
173 pairs
174 .iter()
175 .filter_map(|p| {
176 p.split_once('=')
177 .map(|(k, v)| (k.to_string(), v.to_string()))
178 })
179 .collect()
180}
181
182fn git_metadata(root: &std::path::Path, branch_override: Option<String>) -> (String, String) {
185 let run = |a: &[&str]| {
186 std::process::Command::new("git")
187 .arg("-C")
188 .arg(root)
189 .args(a)
190 .output()
191 .ok()
192 .filter(|o| o.status.success())
193 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
194 };
195 let branch = branch_override
196 .or_else(|| run(&["rev-parse", "--abbrev-ref", "HEAD"]))
197 .filter(|s| !s.is_empty())
198 .unwrap_or_else(|| "HEAD".to_string());
199 let commit = run(&["rev-parse", "HEAD"])
200 .filter(|s| !s.is_empty())
201 .unwrap_or_else(|| "0".repeat(40));
202 (branch, commit)
203}
204
205async fn render_pipeline(
218 args: &RunArgs,
219 _ctx: &RunContext,
220) -> Result<(std::path::PathBuf, String, String)> {
221 let repo_root = match args.dir.clone() {
222 Some(p) => p,
223 None => std::env::current_dir().context("cannot determine current directory")?,
224 };
225
226 let lang =
227 detect::detect_language(&repo_root).map_err(|e| HmError::DslEngine(format!("{e:#}")))?;
228 let engine =
229 hm_dsl_engine::engine_for(lang).map_err(|e| HmError::DslEngine(format!("{e:#}")))?;
230
231 let slug = if let Some(s) = &args.pipeline {
232 s.clone()
233 } else {
234 let metas: Vec<hm_dsl_engine::PipelineMeta> = engine
235 .list_pipelines(&repo_root)
236 .await
237 .map_err(|e| HmError::PipelineRender(format!("{e:#}")))?;
238 let slugs: Vec<String> = metas.into_iter().map(|m| m.slug).collect();
239 match slugs.as_slice() {
240 [only] => only.clone(),
241 [] => anyhow::bail!(
242 "no pipelines declared in this repo\n \
243 hint: define one with `@hm.pipeline(\"slug\")` in `.hm/pipeline.py`"
244 ),
245 many => anyhow::bail!(
246 "this repo declares pipelines: {}\n → pass one as the first argument",
247 many.join(", ")
248 ),
249 }
250 };
251
252 let json_str = engine
253 .render_pipeline_json(&repo_root, &slug)
254 .await
255 .map_err(|e| HmError::PipelineRender(format!("{e:#}")))?;
256
257 Ok((repo_root, slug, json_str))
258}
259
260fn backend_anyhow(err: &hm_exec::BackendError) -> anyhow::Error {
267 HmError::Backend(explain(err), exit_category(err)).into()
268}
269
270const fn exit_category(err: &hm_exec::BackendError) -> ErrorCategory {
278 use hm_exec::BackendError as E;
279 match err {
280 E::Rejected { .. } => ErrorCategory::PipelineInvalid,
282 E::SourceTooLarge { .. } => ErrorCategory::Usage,
284 E::Unauthorized => ErrorCategory::Auth,
286 E::Transport(_) | E::Local(_) => ErrorCategory::Network,
289 E::NotFound(_) => ErrorCategory::Api,
291 _ => ErrorCategory::BuildFailed,
294 }
295}
296
297fn explain(err: &hm_exec::BackendError) -> String {
302 use hm_exec::BackendError as E;
303 match err {
304 E::Unauthorized => "\
305error[auth_required]: not authenticated
306 fix run `hm cloud login` (or set HM_API_TOKEN)
307 docs https://harmont.dev/docs/errors/auth_required"
308 .to_string(),
309 E::Rejected { code, message } => format!(
310 "\
311error[{code}]: {message}
312 fix fix the pipeline and re-run `hm run`
313 docs https://harmont.dev/docs/errors/{code}"
314 ),
315 E::NotFound(what) => format!(
316 "\
317error[not_found]: {what}
318 fix check the org, pipeline, and build number are correct
319 docs https://harmont.dev/docs/errors/not_found"
320 ),
321 E::Transport(m) => format!(
322 "\
323error[network]: {m}
324 fix check your connection and the API URL (HM_API_URL)
325 docs https://harmont.dev/docs/errors/network"
326 ),
327 E::LogStream(m) => format!(
328 "\
329error[log_stream]: live logs interrupted — {m}
330 fix the build continues; re-attach with `hm cloud build show`
331 docs https://harmont.dev/docs/errors/log_stream"
332 ),
333 E::Local(m) => format!(
334 "\
335error[local]: {m}
336 fix check that the Docker daemon is running (`docker version`)
337 docs https://harmont.dev/docs/errors/local"
338 ),
339 E::SourceTooLarge {
340 observed_bytes,
341 cap_bytes,
342 largest_paths,
343 } => {
344 #[allow(clippy::cast_precision_loss)] let mb = |b: u64| format!("{:.1} MB", b as f64 / (1024.0 * 1024.0));
346 let biggest = if largest_paths.is_empty() {
347 " (no large top-level paths identified)".to_string()
348 } else {
349 largest_paths
350 .iter()
351 .map(|(name, sz)| format!(" {name} — {}", mb(*sz)))
352 .collect::<Vec<_>>()
353 .join("\n")
354 };
355 format!(
356 "\
357error[source_too_large]: worktree archive is {observed} (cap {cap})
358 biggest\n{biggest}
359 fix add the offending paths to .gitignore (build output, caches, vendored deps), then re-run `hm run`
360 docs https://harmont.dev/docs/errors/source_too_large",
361 observed = mb(*observed_bytes),
362 cap = mb(*cap_bytes),
363 )
364 }
365 other => format!(
366 "\
367error[backend]: {other}
368 docs https://harmont.dev/docs/errors/backend"
369 ),
370 }
371}
372
373#[cfg(test)]
374#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
375mod tests {
376 use super::*;
377
378 #[test]
379 fn parse_env_splits_pairs() {
380 let m = parse_env(&["A=1".into(), "B=x=y".into(), "bad".into()]);
381 assert_eq!(m.get("A").unwrap(), "1");
382 assert_eq!(m.get("B").unwrap(), "x=y");
383 assert!(!m.contains_key("bad"));
384 }
385
386 #[test]
387 fn git_metadata_falls_back_outside_repo() {
388 let (b, c) = git_metadata(std::path::Path::new("/"), None);
389 assert!(!b.is_empty() && !c.is_empty());
390 assert_eq!(c.len(), 40); }
392
393 #[test]
394 fn explain_carries_stable_codes_and_docs() {
395 use hm_exec::BackendError as E;
396 assert!(explain(&E::Unauthorized).contains("error[auth_required]"));
397 assert!(explain(&E::NotFound("x".into())).contains("error[not_found]"));
398 assert!(explain(&E::LogStream("x".into())).contains("error[log_stream]"));
399 assert!(explain(&E::Transport("x".into())).contains("error[network]"));
400 assert!(explain(&E::Local("x".into())).contains("error[local]"));
401 let r = explain(&E::Rejected {
402 code: "invalid_ir".into(),
403 message: "bad IR".into(),
404 });
405 assert!(r.contains("error[invalid_ir]") && r.contains("bad IR"));
406 let big = explain(&E::SourceTooLarge {
407 observed_bytes: 7 * 1024 * 1024,
408 cap_bytes: 6 * 1024 * 1024,
409 largest_paths: vec![("node_modules".into(), 5 * 1024 * 1024)],
410 });
411 assert!(big.contains("error[source_too_large]"));
412 assert!(big.contains("7.0 MB") && big.contains("6.0 MB"));
414 assert!(big.contains("node_modules") && big.contains(".gitignore"));
415 assert!(big.contains("docs https://harmont.dev/docs/errors/source_too_large"));
416 for s in [
417 explain(&E::Unauthorized),
418 explain(&E::NotFound("x".into())),
419 explain(&E::Transport("x".into())),
420 explain(&E::Local("x".into())),
421 ] {
422 assert!(s.contains("docs https://harmont.dev/docs/errors/"));
423 }
424 }
425
426 #[test]
427 fn exit_category_preserves_taxonomy() {
428 use hm_exec::BackendError as E;
429 assert_eq!(
430 exit_category(&E::Rejected {
431 code: "invalid_ir".into(),
432 message: String::new()
433 }),
434 ErrorCategory::PipelineInvalid
435 );
436 assert_eq!(exit_category(&E::Unauthorized), ErrorCategory::Auth);
437 assert_eq!(
438 exit_category(&E::Transport("x".into())),
439 ErrorCategory::Network
440 );
441 assert_eq!(exit_category(&E::Local("x".into())), ErrorCategory::Network);
442 assert_eq!(exit_category(&E::NotFound("x".into())), ErrorCategory::Api);
443 assert_eq!(
444 exit_category(&E::SourceTooLarge {
445 observed_bytes: 1,
446 cap_bytes: 0,
447 largest_paths: vec![],
448 }),
449 ErrorCategory::Usage
450 );
451 }
452}