1use 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
37pub struct PreparedExecution {
42 pub plan: ExecutionPlan,
44 pub options: InstallOptions,
46 pub selection: ExecutionSelection,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct ExecutionSelection {
53 pub components: Vec<String>,
55 pub reinstall: Vec<String>,
57}
58
59impl ExecutionSelection {
60 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
116pub 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
146pub 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}