Skip to main content

horto_os_ui_shared/ops/
runner.rs

1//! Run Full/Minimal pipelines and individual steps with resume bookkeeping.
2
3use crate::context::{require_root_for_apply, ApplyMode, HostContext};
4use crate::error::{HortoError, Result};
5use crate::pipeline::{self, SetupKind};
6use crate::resume;
7use crate::step::Step;
8
9/// Execute every step in `kind`'s pipeline (dry-run or apply per `ctx.mode`).
10///
11/// # Errors
12///
13/// Returns [`HortoError::RootRequired`] in apply mode without root, or step / resume errors.
14pub fn setup_run(ctx: &mut HostContext, kind: SetupKind) -> Result<()> {
15    require_root_for_apply(ctx.mode)?;
16    ctx.setup_kind = kind;
17    for step in pipeline::pipeline(kind) {
18        run_one(ctx, *step)?;
19    }
20    Ok(())
21}
22
23/// Execute a single step `id` if it belongs to `kind`'s pipeline.
24///
25/// # Errors
26///
27/// Returns [`HortoError::RootRequired`], [`HortoError::UnknownStep`],
28/// [`HortoError::NotInPipeline`], [`HortoError::MissingDependency`], or step errors.
29pub fn setup_step(ctx: &mut HostContext, kind: SetupKind, id: &str) -> Result<()> {
30    require_root_for_apply(ctx.mode)?;
31    ctx.setup_kind = kind;
32    let step = pipeline::lookup(id).ok_or_else(|| HortoError::UnknownStep(id.into()))?;
33    // Ensure the step is part of this kind's pipeline (d1 on full only, etc.)
34    let in_pipe = pipeline::pipeline(kind).iter().any(|s| s.id() == id);
35    if !in_pipe {
36        return Err(HortoError::NotInPipeline {
37            step: id.into(),
38            kind: kind.as_str().into(),
39        });
40    }
41    check_deps(ctx, step)?;
42    run_one(ctx, step)
43}
44
45fn check_deps(ctx: &HostContext, step: &dyn Step) -> Result<()> {
46    let state = resume::load(&ctx.paths.resume_file()).unwrap_or_default();
47    let mut missing = Vec::new();
48    for dep in step.depends_on() {
49        let Some(dep_step) = pipeline::lookup(dep) else {
50            continue;
51        };
52        let st = resume::status_for(&state, dep_step);
53        let done = matches!(st, resume::StepStatus::Done) || dep_step.is_done(ctx);
54        if !done {
55            missing.push(*dep);
56        }
57    }
58    if missing.is_empty() {
59        Ok(())
60    } else {
61        Err(HortoError::MissingDependency(
62            step.id().into(),
63            missing.join(", "),
64        ))
65    }
66}
67
68fn run_one(ctx: &mut HostContext, step: &dyn Step) -> Result<()> {
69    ctx.log(format!("==> {} ({})", step.title(), step.id()));
70    ctx.planned.clear();
71    if ctx.mode == ApplyMode::DryRun {
72        step.plan(ctx)?;
73        // Also exercise apply path in dry-run so PlannedActions match real work.
74        step.apply(ctx)?;
75        return Ok(());
76    }
77    match step.apply(ctx) {
78        Ok(()) => {
79            resume::record_ok(&ctx.paths, step)?;
80            if step.needs_reboot_after() {
81                ctx.log("Note: a reboot is recommended after this step.");
82            }
83            Ok(())
84        }
85        Err(e) => {
86            tracing::error!(step = step.id(), error = %e, "step failed");
87            let _ = resume::mark_failed(&ctx.paths, step.id(), step.step_version());
88            Err(e)
89        }
90    }
91}