Skip to main content

bot_forge/execution/
executor.rs

1//! Validated execution entry point. The executor receives a frozen plan and its lowered runtime.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::sync::atomic::AtomicUsize;
5use std::sync::{Arc, Mutex};
6
7use crate::backends::typed::CommandInvocation;
8use crate::cancellation::CancellationSession;
9use crate::config::schema::{InstallSpec, NetworkPolicy};
10use crate::error::ForgeError;
11use crate::execution::apt_mirror::{AptSourceOverride, configure_for_install};
12use crate::execution::command::{ShellDisplayMode, run_invocation_labeled};
13use crate::execution::environment::{
14    apply_planned_environment, refresh_install_finish_environment,
15};
16use crate::execution::install::{
17    finalize_scheduled_install, install_skill_item, install_typed_tool, record_tool_install,
18    run_certificate_preflight, select_install_components, select_outdated_components,
19};
20use crate::execution::managed_cargo::{CoordinatedCargoResources, install_coordinated};
21use crate::execution::runtime::from_plan;
22use crate::execution::scheduler::{
23    CancellationToken, NodeOutcome, NodeRunner, ResourceBudget, ResourceCoordinator, Scheduler,
24};
25use crate::execution::verifier::{preview_install, preview_plan};
26use crate::model::{
27    BackendKind, InstallConfig, InstallOptions, InstallReport, RegistryEntry, ToolDef,
28    ToolInstallOutcome, ToolInstallResult,
29};
30use crate::paths::managed_bin_dir;
31use crate::planning::{ExecutionNode, ExecutionPlan, NodeKind};
32use crate::state::cache::acquire_usage_lease;
33use crate::telemetry::{estimates, flush, record_cancellation, record_failure};
34use crate::ui::{StatusKind, print_install_preview, stderr_status, stdout_status};
35use crate::util::exe_name;
36
37/// Frozen plan, approved selection, and runtime options required by [`execute`].
38///
39/// [`execute`] validates dependency closure again before performing side effects; callers cannot
40/// use this carrier to inject components outside `plan`.
41pub struct PreparedExecution {
42    /// Immutable plan whose hashes and nodes define the execution contract.
43    pub plan: ExecutionPlan,
44    /// Runtime interaction and output options.
45    pub options: InstallOptions,
46    /// Component selection approved by the caller or interactive preview.
47    pub selection: ExecutionSelection,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
51/// Component IDs approved for execution; dependencies are added before execution begins.
52pub struct ExecutionSelection {
53    /// Selected component IDs in plan order.
54    pub components: Vec<String>,
55    /// Installed components explicitly approved for replacement because their versions are older.
56    pub reinstall: Vec<String>,
57}
58
59impl ExecutionSelection {
60    /// Select every component in plan order.
61    pub fn all(plan: &ExecutionPlan) -> Self {
62        Self {
63            components: plan
64                .components
65                .iter()
66                .map(|component| component.id.clone())
67                .collect(),
68            reinstall: Vec::new(),
69        }
70    }
71
72    fn dependency_closed(&self, plan: &ExecutionPlan) -> Result<Self, ForgeError> {
73        let known = plan
74            .components
75            .iter()
76            .map(|component| (component.id.as_str(), component))
77            .collect::<std::collections::BTreeMap<_, _>>();
78        let mut selected = self.components.iter().cloned().collect::<BTreeSet<_>>();
79        for id in &self.reinstall {
80            if !known.contains_key(id.as_str()) {
81                return Err(ForgeError::Config(format!(
82                    "execution reinstall selection contains an unplanned component: {id}"
83                )));
84            }
85            if !selected.contains(id) {
86                return Err(ForgeError::Config(format!(
87                    "execution reinstall selection is not part of the approved components: {id}"
88                )));
89            }
90        }
91        let mut pending = self.components.clone();
92        while let Some(id) = pending.pop() {
93            let component = known.get(id.as_str()).ok_or_else(|| {
94                ForgeError::Config(format!(
95                    "execution selection contains an unplanned component: {id}"
96                ))
97            })?;
98            for dependency in &component.dependencies {
99                if selected.insert(dependency.clone()) {
100                    pending.push(dependency.clone());
101                }
102            }
103        }
104        Ok(Self {
105            components: plan
106                .components
107                .iter()
108                .filter(|component| selected.contains(&component.id))
109                .map(|component| component.id.clone())
110                .collect(),
111            reinstall: self.reinstall.clone(),
112        })
113    }
114}
115
116/// Select components automatically or through the interactive installer preview.
117///
118/// # Errors
119///
120/// Returns [`ForgeError`] when detection, interactive input, or dependency-closure validation
121/// fails.
122pub fn select(plan: &ExecutionPlan, yes: bool) -> Result<ExecutionSelection, ForgeError> {
123    let preview = preview_plan(plan)?;
124    if yes {
125        let mut selection = ExecutionSelection::all(plan);
126        selection.reinstall = preview
127            .tools
128            .iter()
129            .filter(|status| status.outdated && status.installable)
130            .map(|status| status.name.clone())
131            .collect();
132        return Ok(selection);
133    }
134    let mut components = select_install_components(&preview, plan)?;
135    let reinstall = select_outdated_components(&preview)?;
136    components.extend(reinstall.iter().cloned());
137    components.sort();
138    components.dedup();
139    let selection = ExecutionSelection {
140        components,
141        reinstall,
142    };
143    selection.dependency_closed(plan)
144}
145
146/// Execute an approved frozen plan and return its final installation report.
147///
148/// # Errors
149///
150/// Returns [`ForgeError`] for invalid selections, detection failures, unavailable backends,
151/// scheduler or installation failures, cancellation-related persistence failures, or final report
152/// construction failures.
153pub fn execute(prepared: PreparedExecution) -> Result<InstallReport, ForgeError> {
154    let started = std::time::Instant::now();
155    let cancellation = CancellationSession::begin();
156    let _cache_lease = acquire_usage_lease()?;
157    let selection = prepared.selection.dependency_closed(&prepared.plan)?;
158    let runtime = from_plan(&prepared.plan);
159    run_certificate_preflight(
160        &prepared.plan,
161        if prepared.options.status_bar {
162            ShellDisplayMode::StatusBar
163        } else {
164            ShellDisplayMode::Plain
165        },
166    )?;
167    let preview = preview_install(&runtime)?;
168    print_install_preview(&preview);
169    let selected = selection
170        .components
171        .iter()
172        .cloned()
173        .collect::<BTreeSet<_>>();
174    let reinstall = selection.reinstall.iter().cloned().collect::<BTreeSet<_>>();
175    let missing_tools = tools_requiring_install(&preview, &selected, &reinstall);
176    let missing_skills = preview
177        .missing_skills()
178        .into_iter()
179        .filter(|status| selected.contains(&status.name))
180        .map(|status| status.name.clone())
181        .collect::<BTreeSet<_>>();
182    let not_installable = preview
183        .missing_tools()
184        .into_iter()
185        .filter(|status| selected.contains(&status.name) && !status.installable)
186        .map(|status| status.name.as_str())
187        .collect::<Vec<_>>();
188    if !not_installable.is_empty() {
189        return Err(ForgeError::Config(format!(
190            "selected dependency is missing an installation backend: {}",
191            not_installable.join(", ")
192        )));
193    }
194    if missing_tools.is_empty() && missing_skills.is_empty() {
195        stdout_status(
196            StatusKind::Success,
197            "All selected tools and skills are already installed.",
198        );
199        return finalize_scheduled_install(&runtime, &preview, Vec::new(), Vec::new(), started);
200    }
201
202    let required_apt_packages = required_apt_packages(&runtime, &selected, &missing_tools);
203    let apt_source = configure_for_install(
204        &prepared.plan,
205        &runtime,
206        &prepared.options,
207        &required_apt_packages,
208    )?;
209
210    let completed = run_selected_plan(
211        &prepared.plan,
212        &runtime,
213        ScheduledSelection {
214            components: selected,
215            missing_tools,
216            missing_skills,
217            apt_source,
218        },
219        &prepared.options,
220        cancellation.token(),
221    );
222    flush();
223    let completed = completed?;
224    refresh_install_finish_environment(&runtime)?;
225    finalize_scheduled_install(
226        &runtime,
227        &preview,
228        completed.tools,
229        completed.entries,
230        started,
231    )
232}
233
234fn tools_requiring_install(
235    preview: &crate::model::InstallPreview,
236    selected: &BTreeSet<String>,
237    reinstall: &BTreeSet<String>,
238) -> BTreeSet<String> {
239    preview
240        .tools
241        .iter()
242        .filter(|status| {
243            selected.contains(&status.name)
244                && (!status.installed || (status.outdated && reinstall.contains(&status.name)))
245        })
246        .map(|status| status.name.clone())
247        .collect()
248}
249
250struct ScheduledResults {
251    tools: Vec<ToolInstallResult>,
252    entries: Vec<RegistryEntry>,
253}
254
255struct ScheduledSelection {
256    components: BTreeSet<String>,
257    missing_tools: BTreeSet<String>,
258    missing_skills: BTreeSet<String>,
259    apt_source: Option<AptSourceOverride>,
260}
261
262fn required_apt_packages(
263    runtime: &InstallConfig,
264    selected: &BTreeSet<String>,
265    missing_tools: &BTreeSet<String>,
266) -> BTreeSet<String> {
267    runtime
268        .tools
269        .iter()
270        .filter(|tool| selected.contains(&tool.name) && missing_tools.contains(&tool.name))
271        .filter_map(|tool| match tool.install.as_ref() {
272            Some(InstallSpec::Apt(apt)) => Some(&apt.packages),
273            _ => None,
274        })
275        .flatten()
276        .cloned()
277        .collect()
278}
279
280fn run_selected_plan(
281    plan: &ExecutionPlan,
282    runtime: &InstallConfig,
283    selection: ScheduledSelection,
284    options: &InstallOptions,
285    cancellation: CancellationToken,
286) -> Result<ScheduledResults, ForgeError> {
287    let budget = ResourceBudget::for_plan(plan);
288    let cargo_components = runtime
289        .tools
290        .iter()
291        .filter(|tool| selection.components.contains(&tool.name))
292        .filter(|tool| selection.missing_tools.contains(&tool.name))
293        .filter(|tool| matches!(tool.install, Some(InstallSpec::Cargo(_))))
294        .count();
295    let parallelism = CargoParallelism::for_run(plan, &budget, cargo_components.max(1));
296    if options.status_bar && cargo_components > 0 {
297        stderr_status(
298            StatusKind::Info,
299            &format!(
300                "Cargo resources: {} tools · {} concurrent builds · {} jobs/build · {} CPU tokens",
301                parallelism.processes,
302                parallelism.build_slots,
303                parallelism.cargo_jobs,
304                parallelism.cpu_tokens
305            ),
306        );
307    }
308    let runner = Arc::new(PlanNodeRunner {
309        plan: plan.clone(),
310        runtime: runtime.clone(),
311        profile: options.profile.as_str().to_string(),
312        selected: selection.components,
313        missing_tools: selection.missing_tools,
314        missing_skills: selection.missing_skills,
315        display_mode: if options.status_bar {
316            ShellDisplayMode::StatusBar
317        } else {
318            ShellDisplayMode::Plain
319        },
320        tool_results: Mutex::new(Vec::new()),
321        entries: Mutex::new(Vec::new()),
322        error: Mutex::new(None),
323        cargo_jobs: parallelism.cargo_jobs,
324        cargo_build_slots: parallelism.build_slots,
325        cargo_remaining: AtomicUsize::new(cargo_components),
326        apt_source: selection.apt_source,
327    });
328    let reports = Scheduler::new(plan.policy.max_parallel.max(1), budget)
329        .with_priorities(cargo_priorities(plan, runtime, &runner.missing_tools))
330        .execute(plan, Arc::clone(&runner), cancellation)?;
331    if let Some(error) = runner
332        .error
333        .lock()
334        .map_err(|_| ForgeError::Command("execution error lock is corrupted".into()))?
335        .take()
336    {
337        return Err(error);
338    }
339    if reports
340        .iter()
341        .any(|report| report.outcome == NodeOutcome::Cancelled)
342    {
343        return Err(ForgeError::Command(
344            "installation execution was cancelled".into(),
345        ));
346    }
347    if let Some(report) = reports
348        .iter()
349        .find(|report| matches!(report.outcome, NodeOutcome::Failed | NodeOutcome::Blocked))
350    {
351        return Err(ForgeError::Command(format!(
352            "execution plan node did not complete: {}",
353            report.node
354        )));
355    }
356    let mut tools = runner
357        .tool_results
358        .lock()
359        .map_err(|_| ForgeError::Command("installation result lock is corrupted".into()))?
360        .clone();
361    tools.sort_by(|left, right| left.name.cmp(&right.name));
362    let mut entries = runner
363        .entries
364        .lock()
365        .map_err(|_| ForgeError::Command("managed record result lock is corrupted".into()))?
366        .clone();
367    entries.sort_by_key(RegistryEntry::stable_id);
368    Ok(ScheduledResults { tools, entries })
369}
370
371fn cargo_priorities(
372    plan: &ExecutionPlan,
373    runtime: &InstallConfig,
374    components: &BTreeSet<String>,
375) -> std::collections::BTreeMap<String, u64> {
376    let estimates = estimates();
377    let priorities = runtime
378        .tools
379        .iter()
380        .filter(|tool| components.contains(&tool.name))
381        .filter_map(|tool| {
382            let InstallSpec::Cargo(specification) = tool.install.as_ref()? else {
383                return None;
384            };
385            let source = specification.source_digest();
386            let fingerprint =
387                specification.artifact_fingerprint(&source, &specification.lock_digest());
388            Some((
389                tool.name.as_str(),
390                estimates.get(&fingerprint)?.average_build_ms,
391            ))
392        })
393        .collect::<std::collections::BTreeMap<_, _>>();
394    let own = plan
395        .nodes
396        .iter()
397        .filter_map(|node| {
398            priorities
399                .get(node.component.as_str())
400                .copied()
401                .map(|priority| (node.id.clone(), priority.max(1)))
402        })
403        .collect::<std::collections::BTreeMap<_, _>>();
404    let mut dependents = std::collections::BTreeMap::<String, Vec<String>>::new();
405    for node in &plan.nodes {
406        for dependency in &node.dependencies {
407            dependents
408                .entry(dependency.clone())
409                .or_default()
410                .push(node.id.clone());
411        }
412    }
413    let mut critical = std::collections::BTreeMap::new();
414    for node in plan.nodes.iter().rev() {
415        let downstream = dependents
416            .get(&node.id)
417            .into_iter()
418            .flatten()
419            .filter_map(|dependent| critical.get(dependent).copied())
420            .max()
421            .unwrap_or(0);
422        let own = own.get(&node.id).copied().unwrap_or(1);
423        critical.insert(node.id.clone(), own.saturating_add(downstream));
424    }
425    critical
426}
427
428#[derive(Debug, Clone, Copy, PartialEq, Eq)]
429struct CargoParallelism {
430    processes: usize,
431    build_slots: usize,
432    cpu_tokens: u32,
433    cargo_jobs: usize,
434}
435
436impl CargoParallelism {
437    fn for_run(plan: &ExecutionPlan, budget: &ResourceBudget, components: usize) -> Self {
438        let cpu_tokens = budget.capacity("cpu").max(1);
439        let memory_slots = budget.capacity("memory-mib") / 512;
440        let build_slots = components
441            .max(1)
442            .min(plan.policy.max_parallel.max(1))
443            .min(budget.capacity("network").max(1) as usize)
444            .min(budget.capacity("disk-io").max(1) as usize)
445            .min(memory_slots.max(1) as usize)
446            .min(cpu_tokens as usize);
447        let processes = components.max(1).min(plan.policy.max_parallel.max(1));
448        Self {
449            processes,
450            build_slots,
451            cpu_tokens,
452            cargo_jobs: (cpu_tokens as usize / build_slots).max(1),
453        }
454    }
455}
456
457struct PlanNodeRunner {
458    plan: ExecutionPlan,
459    runtime: InstallConfig,
460    profile: String,
461    selected: BTreeSet<String>,
462    missing_tools: BTreeSet<String>,
463    missing_skills: BTreeSet<String>,
464    display_mode: ShellDisplayMode,
465    tool_results: Mutex<Vec<ToolInstallResult>>,
466    entries: Mutex<Vec<RegistryEntry>>,
467    error: Mutex<Option<ForgeError>>,
468    cargo_jobs: usize,
469    cargo_build_slots: usize,
470    cargo_remaining: AtomicUsize,
471    apt_source: Option<AptSourceOverride>,
472}
473
474impl NodeRunner for PlanNodeRunner {
475    fn run(
476        &self,
477        node: &ExecutionNode,
478        cancellation: &CancellationToken,
479    ) -> Result<NodeOutcome, ForgeError> {
480        if node.kind == NodeKind::Environment {
481            return apply_planned_environment(&self.runtime)
482                .map(|()| NodeOutcome::Completed)
483                .or_else(|error| {
484                    self.store_error(error);
485                    Ok(NodeOutcome::Failed)
486                });
487        }
488        if !self.selected.contains(&node.component) {
489            return Ok(NodeOutcome::Skipped);
490        }
491        if cancellation.is_cancelled() {
492            return Ok(NodeOutcome::Cancelled);
493        }
494        if node.kind != NodeKind::Acquire {
495            self.store_error(ForgeError::Config(format!(
496                "execution plan contains an unsupported node type: {}",
497                node.id
498            )));
499            return Ok(NodeOutcome::Failed);
500        }
501        if let Some(tool) = self
502            .runtime
503            .tools
504            .iter()
505            .find(|tool| tool.name == node.component)
506        {
507            if !self.missing_tools.contains(&tool.name) {
508                return Ok(NodeOutcome::Skipped);
509            }
510            return self.install_tool(tool, None, cancellation);
511        }
512        if let Some(skill) = self
513            .runtime
514            .skills
515            .iter()
516            .find(|skill| skill.name == node.component)
517        {
518            if !self.missing_skills.contains(&skill.name) {
519                return Ok(NodeOutcome::Skipped);
520            }
521            return match install_skill_item(skill, &self.profile, false, self.plan.policy.network) {
522                Ok(entries) => {
523                    self.entries
524                        .lock()
525                        .map_err(|_| ForgeError::Command("skill result lock is corrupted".into()))?
526                        .extend(entries);
527                    Ok(NodeOutcome::Completed)
528                }
529                Err(error) => {
530                    self.store_error(error);
531                    Ok(NodeOutcome::Failed)
532                }
533            };
534        }
535        self.store_error(ForgeError::Config(format!(
536            "execution plan component {} has no runtime definition",
537            node.component
538        )));
539        Ok(NodeOutcome::Failed)
540    }
541
542    fn manages_resources(&self, node: &ExecutionNode) -> bool {
543        node.kind == NodeKind::Acquire
544            && self.selected.contains(&node.component)
545            && self.missing_tools.contains(&node.component)
546            && self
547                .runtime
548                .tools
549                .iter()
550                .find(|tool| tool.name == node.component)
551                .and_then(|tool| tool.install.as_ref())
552                .is_some_and(|install| matches!(install, InstallSpec::Cargo(_)))
553    }
554
555    fn run_with_resources(
556        &self,
557        node: &ExecutionNode,
558        cancellation: &CancellationToken,
559        coordinator: &ResourceCoordinator,
560    ) -> Result<NodeOutcome, ForgeError> {
561        if !self.manages_resources(node) {
562            return self.run(node, cancellation);
563        }
564        let Some(tool) = self
565            .runtime
566            .tools
567            .iter()
568            .find(|tool| tool.name == node.component)
569        else {
570            self.store_error(ForgeError::Config(format!(
571                "execution plan Cargo component {} has no runtime definition",
572                node.component
573            )));
574            return Ok(NodeOutcome::Failed);
575        };
576        let Some(InstallSpec::Cargo(specification)) = tool.install.as_ref() else {
577            self.store_error(ForgeError::Config(format!(
578                "component {} is not a Cargo typed backend",
579                node.component
580            )));
581            return Ok(NodeOutcome::Failed);
582        };
583        if cancellation.is_cancelled() {
584            record_cancellation(
585                &specification.artifact_fingerprint(
586                    &specification.source_digest(),
587                    &specification.lock_digest(),
588                ),
589                &tool.name,
590            );
591            return Ok(NodeOutcome::Cancelled);
592        }
593        match install_coordinated(
594            &self.plan,
595            tool,
596            specification,
597            self.display_mode,
598            self.cargo_jobs,
599            CoordinatedCargoResources {
600                coordinator,
601                remaining_builds: &self.cargo_remaining,
602                baseline_jobs: self.cargo_jobs,
603                max_builds: self.cargo_build_slots,
604                cancellation,
605            },
606        ) {
607            Ok(result) => self.store_tool_result(result, tool, cancellation),
608            Err(error) => {
609                let fingerprint = specification.artifact_fingerprint(
610                    &specification.source_digest(),
611                    &specification.lock_digest(),
612                );
613                if cancellation.is_cancelled() {
614                    record_cancellation(&fingerprint, &tool.name);
615                } else {
616                    record_failure(&fingerprint, &tool.name);
617                }
618                self.store_error(error);
619                Ok(NodeOutcome::Failed)
620            }
621        }
622    }
623}
624
625impl PlanNodeRunner {
626    fn install_tool(
627        &self,
628        tool: &ToolDef,
629        coordinator: Option<&ResourceCoordinator>,
630        cancellation: &CancellationToken,
631    ) -> Result<NodeOutcome, ForgeError> {
632        if cancellation.is_cancelled() {
633            return Ok(NodeOutcome::Cancelled);
634        }
635        let Some(specification) = tool.install.as_ref() else {
636            self.store_error(ForgeError::Config(format!(
637                "tool {} is missing an installation backend",
638                tool.name
639            )));
640            return Ok(NodeOutcome::Failed);
641        };
642        let result = match (specification, coordinator) {
643            (InstallSpec::Cargo(cargo), Some(coordinator)) => install_coordinated(
644                &self.plan,
645                tool,
646                cargo,
647                self.display_mode,
648                self.cargo_jobs,
649                CoordinatedCargoResources {
650                    coordinator,
651                    remaining_builds: &self.cargo_remaining,
652                    baseline_jobs: self.cargo_jobs,
653                    max_builds: self.cargo_build_slots,
654                    cancellation,
655                },
656            ),
657            _ => install_typed_tool(
658                &self.plan,
659                tool,
660                specification,
661                self.display_mode,
662                None,
663                self.apt_source
664                    .as_ref()
665                    .map(|source| source.source_file.as_path()),
666                self.apt_source
667                    .as_ref()
668                    .map(|source| source.lists_dir.as_path()),
669            ),
670        };
671        match result {
672            Ok(result) => self.store_tool_result(result, tool, cancellation),
673            Err(error) => {
674                self.store_error(error);
675                Ok(NodeOutcome::Failed)
676            }
677        }
678    }
679    fn store_tool_result(
680        &self,
681        result: ToolInstallResult,
682        tool: &ToolDef,
683        cancellation: &CancellationToken,
684    ) -> Result<NodeOutcome, ForgeError> {
685        if result.outcome == ToolInstallOutcome::Installed
686            && tool.name == "rust-bot"
687            && let Err(error) = self.install_rust_bot_skills(cancellation)
688        {
689            self.store_error(error);
690            return Ok(NodeOutcome::Failed);
691        }
692        if result.outcome == ToolInstallOutcome::Installed
693            && !matches!(tool.backend(), Some(BackendKind::Cargo | BackendKind::Git))
694        {
695            record_tool_install(tool, &self.profile)?;
696        }
697        let outcome = if result.outcome == ToolInstallOutcome::VerificationFailed {
698            NodeOutcome::Failed
699        } else {
700            NodeOutcome::Completed
701        };
702        self.tool_results
703            .lock()
704            .map_err(|_| ForgeError::Command("installation result lock is corrupted".into()))?
705            .push(result);
706        Ok(outcome)
707    }
708
709    fn store_error(&self, error: ForgeError) {
710        if let Ok(mut slot) = self.error.lock()
711            && slot.is_none()
712        {
713            *slot = Some(error);
714        }
715    }
716
717    fn install_rust_bot_skills(&self, cancellation: &CancellationToken) -> Result<(), ForgeError> {
718        if cancellation.is_cancelled() {
719            return Err(ForgeError::Command(
720                "rust-bot skill installation was cancelled".into(),
721            ));
722        }
723        if self.plan.policy.network != NetworkPolicy::Online {
724            return Err(ForgeError::Config(
725                "rust-bot skill installation requires network=online".into(),
726            ));
727        }
728        let invocation = rust_bot_skill_install_invocation();
729        run_invocation_labeled(
730            "rust-bot skill packages",
731            &invocation,
732            self.display_mode,
733            None,
734        )
735        .map_err(|error| {
736            ForgeError::Command(format!("rust-bot skill installation failed: {error}"))
737        })
738    }
739}
740
741fn rust_bot_skill_install_invocation() -> CommandInvocation {
742    let managed = managed_bin_dir().join(exe_name("rust-bot"));
743    let program = if managed.is_file() {
744        managed.display().to_string()
745    } else {
746        "rust-bot".to_string()
747    };
748    CommandInvocation {
749        program,
750        args: vec!["install".into(), "stable".into()],
751        env: BTreeMap::new(),
752        current_dir: None,
753        clear_env: false,
754        null_stdin: true,
755        timeout_secs: Some(300),
756        inactivity_timeout_secs: Some(120),
757        idempotent: true,
758        success_codes: vec![0],
759        stdout_contains: None,
760    }
761}
762
763#[cfg(test)]
764mod tests {
765    use std::collections::BTreeSet;
766    use std::sync::Mutex;
767    use std::sync::atomic::AtomicUsize;
768
769    use crate::config::schema::{AptInstall, InstallSpec, NetworkPolicy};
770    use crate::execution::command::ShellDisplayMode;
771    use crate::execution::executor::{
772        CargoParallelism, ExecutionSelection, PlanNodeRunner, required_apt_packages,
773        rust_bot_skill_install_invocation, tools_requiring_install,
774    };
775    use crate::execution::scheduler::{CancellationToken, NodeOutcome, NodeRunner, ResourceBudget};
776    use crate::model::{InstallConfig, InstallKind, InstallPreview, ToolDef, ToolStatus};
777    use crate::planning::{
778        ExecutionNode, ExecutionPlan, NodeKind, PlanPolicy, ResolvedComponent, TargetPlatform,
779    };
780
781    fn component(id: &str, dependencies: &[&str]) -> ResolvedComponent {
782        ResolvedComponent {
783            id: id.into(),
784            display_name: None,
785            version: None,
786            optional: false,
787            allow_insecure_hosts: Vec::new(),
788            kind: InstallKind::Tool,
789            variant: None,
790            requested_by: Vec::new(),
791            dependencies: dependencies.iter().map(|value| (*value).into()).collect(),
792            provides: Vec::new(),
793            conflicts: Vec::new(),
794            detect: None,
795            install: None,
796            verify: None,
797            source: None,
798            revision: None,
799            agents: Vec::new(),
800        }
801    }
802
803    fn plan() -> ExecutionPlan {
804        ExecutionPlan {
805            profile: "test".into(),
806            target: TargetPlatform::host(),
807            config_hash: "a".repeat(64),
808            plan_hash: "b".repeat(64),
809            policy: PlanPolicy {
810                network: NetworkPolicy::Online,
811                max_parallel: 1,
812                max_downloads: 1,
813                max_memory_mib: None,
814            },
815            certificate_preflight: None,
816            components: vec![component("base", &[]), component("tool", &["base"])],
817            unsupported_components: Vec::new(),
818            nodes: Vec::new(),
819            environment: Vec::new(),
820            apt_mirror: None,
821            origins: Default::default(),
822        }
823    }
824
825    #[test]
826    fn selection_is_closed_over_plan_dependencies() {
827        let selection = ExecutionSelection {
828            components: vec!["tool".into()],
829            reinstall: Vec::new(),
830        }
831        .dependency_closed(&plan())
832        .unwrap();
833        assert_eq!(selection.components, ["base", "tool"]);
834    }
835
836    #[test]
837    fn selection_rejects_components_outside_the_frozen_plan() {
838        let error = ExecutionSelection {
839            components: vec!["unknown".into()],
840            reinstall: Vec::new(),
841        }
842        .dependency_closed(&plan())
843        .unwrap_err();
844        assert!(error.to_string().contains("unplanned component"));
845    }
846
847    #[test]
848    fn reinstall_selection_must_be_explicitly_approved_and_planned() {
849        let error = ExecutionSelection {
850            components: vec!["tool".into()],
851            reinstall: vec!["base".into()],
852        }
853        .dependency_closed(&plan())
854        .unwrap_err();
855        assert!(
856            error
857                .to_string()
858                .contains("not part of the approved components")
859        );
860    }
861
862    #[test]
863    fn overwrite_approval_controls_the_actual_scheduled_tool_set() {
864        let preview = InstallPreview {
865            tools: ["first", "second"]
866                .into_iter()
867                .map(|name| ToolStatus {
868                    name: name.into(),
869                    display_name: None,
870                    optional: false,
871                    installed: true,
872                    version: Some("1.0.0".into()),
873                    required_version: Some("2.0.0".into()),
874                    outdated: true,
875                    installable: true,
876                })
877                .collect(),
878            skills: Vec::new(),
879        };
880        let selected = BTreeSet::from(["first".into(), "second".into()]);
881
882        assert!(tools_requiring_install(&preview, &selected, &BTreeSet::new()).is_empty());
883        assert_eq!(
884            tools_requiring_install(
885                &preview,
886                &selected,
887                &BTreeSet::from(["first".into(), "second".into()]),
888            ),
889            selected
890        );
891        assert_eq!(
892            tools_requiring_install(&preview, &selected, &BTreeSet::from(["second".into()]),),
893            BTreeSet::from(["second".into()])
894        );
895        let mut refreshed = preview;
896        refreshed.tools[1].outdated = false;
897        refreshed.tools[1].version = Some("2.0.0".into());
898        assert!(
899            tools_requiring_install(&refreshed, &selected, &BTreeSet::from(["second".into()]),)
900                .is_empty()
901        );
902    }
903
904    #[test]
905    fn custom_overwrite_selection_reaches_only_the_chosen_runner_path() {
906        let preview = InstallPreview {
907            tools: ["first", "second"]
908                .into_iter()
909                .map(|name| ToolStatus {
910                    name: name.into(),
911                    display_name: None,
912                    optional: false,
913                    installed: true,
914                    version: Some("1.0.0".into()),
915                    required_version: Some("2.0.0".into()),
916                    outdated: true,
917                    installable: true,
918                })
919                .collect(),
920            skills: Vec::new(),
921        };
922        let selected = BTreeSet::from(["first".into(), "second".into()]);
923        let chosen = BTreeSet::from(["second".into()]);
924        let runtime = InstallConfig {
925            tools: ["first", "second"]
926                .into_iter()
927                .map(|name| ToolDef {
928                    name: name.into(),
929                    ..ToolDef::default()
930                })
931                .collect(),
932            ..InstallConfig::default()
933        };
934        let runner = PlanNodeRunner {
935            plan: plan(),
936            runtime,
937            profile: "test".into(),
938            selected: selected.clone(),
939            missing_tools: tools_requiring_install(&preview, &selected, &chosen),
940            missing_skills: BTreeSet::new(),
941            display_mode: ShellDisplayMode::Plain,
942            tool_results: Mutex::new(Vec::new()),
943            entries: Mutex::new(Vec::new()),
944            error: Mutex::new(None),
945            cargo_jobs: 1,
946            cargo_build_slots: 1,
947            cargo_remaining: AtomicUsize::new(0),
948            apt_source: None,
949        };
950        let run = |component: &str| {
951            runner.run(
952                &ExecutionNode {
953                    id: format!("{component}:acquire"),
954                    component: component.into(),
955                    kind: NodeKind::Acquire,
956                    dependencies: Vec::new(),
957                    resources: Vec::new(),
958                },
959                &CancellationToken::default(),
960            )
961        };
962        assert_eq!(run("first").unwrap(), NodeOutcome::Skipped);
963        assert_eq!(run("second").unwrap(), NodeOutcome::Failed);
964        assert!(
965            runner
966                .error
967                .lock()
968                .unwrap()
969                .as_ref()
970                .is_some_and(|error| error.to_string().contains("second"))
971        );
972    }
973
974    #[test]
975    fn apt_probe_is_scoped_to_selected_missing_packages() {
976        let mut runtime = InstallConfig::default();
977        for (name, package) in [
978            ("selected", "cmake"),
979            ("installed", "git"),
980            ("other", "ninja"),
981        ] {
982            runtime.tools.push(ToolDef {
983                name: name.into(),
984                display_name: None,
985                version: None,
986                optional: false,
987                allow_insecure_hosts: Vec::new(),
988                detect: None,
989                install: Some(InstallSpec::Apt(AptInstall {
990                    packages: vec![package.into()],
991                    update: true,
992                })),
993                verify: None,
994            });
995        }
996        let selected = BTreeSet::from(["selected".into(), "installed".into()]);
997        let missing = BTreeSet::from(["selected".into(), "other".into()]);
998
999        assert_eq!(
1000            required_apt_packages(&runtime, &selected, &missing),
1001            BTreeSet::from(["cmake".into()])
1002        );
1003    }
1004
1005    #[test]
1006    fn cargo_parallelism_coordinates_processes_and_inner_jobs() {
1007        let mut plan = plan();
1008        plan.policy.max_parallel = 8;
1009        plan.policy.max_downloads = 4;
1010        plan.policy.max_memory_mib = Some(4096);
1011        let budget = ResourceBudget::for_plan(&plan);
1012        let allocation = CargoParallelism::for_run(&plan, &budget, 12);
1013        let cores = std::thread::available_parallelism().map_or(1, usize::from);
1014
1015        assert_eq!(allocation.processes, 8);
1016        let expected = cores.min(8).min(budget.capacity("disk-io") as usize).min(4);
1017        assert_eq!(allocation.build_slots, expected);
1018        assert_eq!(allocation.cpu_tokens as usize, cores);
1019        assert!(allocation.build_slots * allocation.cargo_jobs <= cores);
1020    }
1021
1022    #[test]
1023    fn cargo_parallelism_respects_memory_and_download_budgets() {
1024        let mut plan = plan();
1025        plan.policy.max_parallel = 8;
1026        plan.policy.max_downloads = 1;
1027        plan.policy.max_memory_mib = Some(512);
1028        let budget = ResourceBudget::for_plan(&plan);
1029
1030        let allocation = CargoParallelism::for_run(&plan, &budget, 8);
1031        assert_eq!(allocation.processes, 8);
1032        assert_eq!(allocation.build_slots, 1);
1033    }
1034
1035    #[test]
1036    fn rust_bot_skill_install_targets_latest_stable_skills() {
1037        let invocation = rust_bot_skill_install_invocation();
1038        assert_eq!(invocation.args, ["install", "stable"]);
1039        assert_eq!(invocation.timeout_secs, Some(300));
1040        assert_eq!(invocation.inactivity_timeout_secs, Some(120));
1041        assert!(invocation.null_stdin);
1042        assert!(invocation.idempotent);
1043    }
1044}