Skip to main content

harmont_cli/commands/run/
mod.rs

1use 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
11/// Top-level driver for `hm run`.
12///
13/// Runs the local worktree on the selected execution backend: `docker`
14/// (default) runs it locally on the Docker VM backend; `cloud` submits it to
15/// Harmont Cloud and streams logs.
16///
17/// Backend resolution (flag wins over config):
18/// - `--backend <name>` → that backend (`cloud`, `docker`, …)
19/// - `--cloud`          → `cloud` (deprecated alias)
20/// - neither            → `ctx.config.backend` (figment-layered, default `docker`)
21///
22/// This is a THIN driver over the `hm-exec` backends: it builds an
23/// [`hm_exec::ExecutionBackend`], renders the pipeline to v0 IR once, starts
24/// the build, drives its event stream through an `hm_render` renderer, owns
25/// Ctrl-C, and returns the build's process exit code. Cloud authentication is
26/// resolved BEFORE the (local) render work so a missing token fails fast.
27///
28/// # Errors
29///
30/// Returns a doctrine-shaped error (carrying the right process exit code) when
31/// the backend rejects the build, authentication fails, the network is
32/// unreachable, the local daemon is down, or the pipeline fails to render.
33pub async fn handle(args: RunArgs, ctx: RunContext) -> Result<i32> {
34    // 1. Resolve the backend name: explicit --backend > legacy --cloud alias >
35    //    config.backend (figment-layered default "docker").
36    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    // 2. Cloud needs auth + org resolution up front — fail fast on a missing
49    //    token before any render work. We resolve the credentials here but
50    //    defer *constructing* the backend (and, for local runs, *connecting* to
51    //    Docker) until after the pipeline renders, so an unknown slug or a
52    //    missing/ambiguous pipeline argument fails with a helpful message
53    //    instead of a daemon-connection error.
54    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    // 3. Render + parse the plan once (shared by every backend). This validates
72    //    the pipeline argument — unknown slug, or zero/many declared pipelines
73    //    — before we connect to any daemon.
74    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    // 4. Pick the renderer — this validates `--format` — before any daemon
78    //    connection, so an unknown format fails fast without a running Docker.
79    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    // 5. Build the backend. For local runs this is where we connect to Docker.
85    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            // The watch link must point at the dashboard (app.) host, not the
89            // API host — a link built from `api_url` lands on raw JSON.
90            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            // Local execution on a hm-vm VmBackend (docker).
94            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    // 6. Capability-driven flag validation (replaces the old silent ignoring).
104    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    // 7. Assemble the run request.
125    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    // 8. Start, drive events, own Ctrl-C, await the outcome.
145    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
155/// Resolve local-run parallelism: the explicit `--parallelism`, else the
156/// number of logical CPUs (4 as a last resort). Matches `hm run`'s prior
157/// behavior exactly. A `--parallelism 0` is clamped to `1` at this boundary
158/// so the backend never has to defend against a zero count.
159fn resolve_parallelism(args: &RunArgs) -> std::num::NonZeroUsize {
160    use std::num::NonZeroUsize;
161    /// Last-resort parallelism when neither `--parallelism` nor
162    /// `available_parallelism()` yields a usable value.
163    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/// Parse `KEY=VALUE` pairs into a map, dropping malformed entries.
171#[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
182/// Resolve `(branch, commit)` from git at `root`, best-effort. An explicit
183/// `branch_override` wins; missing values fall back to `HEAD` / the zero SHA.
184fn 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
205/// Resolve repo root, detect the DSL, select the pipeline slug, and render
206/// the v0 IR JSON. Shared by local and cloud runs.
207///
208/// Returns `(repo_root, slug, ir_json_string)`. The JSON is returned as a
209/// string so a backend (e.g. cloud) can ship it verbatim; the driver parses
210/// it into an [`hm_exec::Plan`] once.
211///
212/// # Errors
213///
214/// Returns an error if the working directory cannot be resolved, no pipeline
215/// slug was given when more than one is declared (or none are declared), or
216/// the DSL detection / pipeline-render step fails.
217async 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
260/// Convert an [`hm_exec::BackendError`] into an [`anyhow::Error`] that carries
261/// BOTH the doctrine message ([`explain`]) AND the right process exit code.
262///
263/// The exit code is preserved by wrapping in [`HmError::Backend`], whose
264/// [`HmError::category`] returns the embedded [`ErrorCategory`]; `main`'s
265/// `handle_error` downcasts to `HmError` and reads `exit_code()`.
266fn backend_anyhow(err: &hm_exec::BackendError) -> anyhow::Error {
267    HmError::Backend(explain(err), exit_category(err)).into()
268}
269
270/// Map a [`hm_exec::BackendError`] to the process exit-code category.
271///
272/// Note: the old taxonomy distinguished a downed Docker daemon
273/// (`EXIT_NETWORK`) from an unknown-runner pipeline error
274/// (`EXIT_PIPELINE_INVALID`). Both now arrive as
275/// [`hm_exec::BackendError::Local`], so they collapse to a single category
276/// (`Network`) here — an acceptable loss of resolution.
277const fn exit_category(err: &hm_exec::BackendError) -> ErrorCategory {
278    use hm_exec::BackendError as E;
279    match err {
280        // A plan/IR rejection is a pipeline-config problem.
281        E::Rejected { .. } => ErrorCategory::PipelineInvalid,
282        // An oversized source archive is a user-fixable setup mistake.
283        E::SourceTooLarge { .. } => ErrorCategory::Usage,
284        // Auth failures map to the dedicated auth exit code.
285        E::Unauthorized => ErrorCategory::Auth,
286        // Network unreachability and local-infra failures (Docker down) are
287        // both "the thing that runs builds isn't reachable".
288        E::Transport(_) | E::Local(_) => ErrorCategory::Network,
289        // A NotFound is an API-level miss (bad org/pipeline/build).
290        E::NotFound(_) => ErrorCategory::Api,
291        // Everything else (interrupted log streams, opaque errors, and any
292        // future `#[non_exhaustive]` variant) is a build-level failure.
293        _ => ErrorCategory::BuildFailed,
294    }
295}
296
297/// Render a [`hm_exec::BackendError`] in the project's error doctrine: point
298/// precisely, say what was observed, say the fix, give a stable code + doc URL.
299///
300/// Adapted from the legacy `executor/cloud.rs::explain(&HarmontError)`.
301fn 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)] // display-only
345            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); // zero-sha fallback
391    }
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        // Points precisely (observed + cap), names the offender, states the fix.
413        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}