1#![allow(clippy::cognitive_complexity, clippy::too_many_lines)]
11
12use crate::affected::{compute_affected_tasks, matched_inputs_for_task};
13use crate::compiler::Compiler;
14use crate::discovery::evaluate_module_from_cwd;
15use crate::ir::CachePolicy;
16use crate::provider::CIProvider;
17use crate::report::json::write_report;
18use crate::report::{ContextReport, PipelineReport, PipelineStatus, TaskReport, TaskStatus};
19use chrono::Utc;
20use cuenv_core::lockfile::{LOCKFILE_NAME, LockedToolPlatform, Lockfile};
21use cuenv_core::manifest::Project;
22use cuenv_core::tasks::{TaskGraph, TaskIndex};
23use cuenv_core::tools::{Platform, ResolvedTool, ToolOptions, ToolRegistry, ToolSource};
24use cuenv_core::{DryRun, Result};
25use std::collections::{BTreeMap, HashSet};
26use std::path::{Path, PathBuf};
27use std::sync::Arc;
28
29use super::ExecutorError;
30use super::config::CIExecutorConfig;
31use super::runner::{IRTaskRunner, TaskOutput};
32
33#[allow(clippy::too_many_lines)]
49pub async fn run_ci(
50 provider: Arc<dyn CIProvider>,
51 dry_run: DryRun,
52 specific_pipeline: Option<String>,
53 environment: Option<String>,
54 path_filter: Option<&str>,
55) -> Result<()> {
56 let context = provider.context();
57 cuenv_events::emit_ci_context!(&context.provider, &context.event, &context.ref_name);
58
59 let changed_files = provider.changed_files().await?;
61 cuenv_events::emit_ci_changed_files!(changed_files.len());
62
63 let module = evaluate_module_from_cwd()?;
65 let project_count = module.project_count();
66 if project_count == 0 {
67 return Err(cuenv_core::Error::configuration(
68 "No cuenv projects found. Ensure env.cue files declare 'package cuenv'",
69 ));
70 }
71 cuenv_events::emit_ci_projects_discovered!(project_count);
72
73 let mut projects: Vec<(PathBuf, Project)> = Vec::new();
75 for instance in module.projects() {
76 let config = Project::try_from(instance)?;
77 let project_path = module.root.join(&instance.path);
78 projects.push((project_path, config));
79 }
80
81 let projects: Vec<(PathBuf, Project)> = match path_filter {
83 Some(filter) if filter != "." => {
84 let filter_path = module.root.join(filter);
85 projects
86 .into_iter()
87 .filter(|(path, _)| path.starts_with(&filter_path))
88 .collect()
89 }
90 _ => projects,
91 };
92
93 if projects.is_empty() {
94 return Err(cuenv_core::Error::configuration(format!(
95 "No cuenv projects found under path '{}'",
96 path_filter.unwrap_or(".")
97 )));
98 }
99
100 let mut project_map = std::collections::HashMap::new();
102 for (path, config) in &projects {
103 let name = config.name.trim();
104 if !name.is_empty() {
105 project_map.insert(name.to_string(), (path.clone(), config.clone()));
106 }
107 }
108
109 let mut failures: Vec<(String, cuenv_core::Error)> = Vec::new();
111
112 for (project_path, config) in &projects {
114 let pipeline_name = specific_pipeline
116 .clone()
117 .unwrap_or_else(|| "default".to_string());
118
119 let Some(ci) = &config.ci else {
121 return Err(cuenv_core::Error::configuration(format!(
122 "Project {} has no CI configuration",
123 project_path.display()
124 )));
125 };
126
127 let available_pipelines: Vec<&str> = ci.pipelines.keys().map(String::as_str).collect();
128 let Some(pipeline) = ci.pipelines.get(&pipeline_name) else {
129 return Err(cuenv_core::Error::configuration(format!(
130 "Pipeline '{}' not found in project {}. Available pipelines: {}",
131 pipeline_name,
132 project_path.display(),
133 available_pipelines.join(", ")
134 )));
135 };
136
137 let resolved_environment =
138 resolve_environment(environment.as_deref(), pipeline.environment.as_deref());
139
140 let pipeline_task_names: Vec<String> = pipeline
142 .tasks
143 .iter()
144 .map(|t| t.task_name().to_string())
145 .collect();
146
147 let tasks_to_run = if context.event == "release" {
149 pipeline_task_names
150 } else {
151 compute_affected_tasks(
152 &changed_files,
153 &pipeline_task_names,
154 project_path,
155 config,
156 &project_map,
157 )
158 };
159
160 if tasks_to_run.is_empty() {
161 cuenv_events::emit_ci_project_skipped!(project_path.display(), "No affected tasks");
162 continue;
163 }
164
165 tracing::info!(
166 project = %project_path.display(),
167 tasks = ?tasks_to_run,
168 "Running tasks for project"
169 );
170
171 if !dry_run.is_dry_run() {
172 let result = execute_project_pipeline(&PipelineExecutionRequest {
173 project_path,
174 config,
175 pipeline_name: &pipeline_name,
176 tasks_to_run: &tasks_to_run,
177 environment: resolved_environment.as_deref(),
178 context,
179 changed_files: &changed_files,
180 provider: provider.as_ref(),
181 })
182 .await;
183
184 match result {
185 Err(e) => {
186 tracing::error!(error = %e, "Pipeline execution error");
187 let project_name = project_path.display().to_string();
188 failures.push((project_name, e));
189 }
190 Ok((status, task_errors)) => {
191 if status == PipelineStatus::Failed {
192 failures.extend(task_errors);
193 }
194 }
195 }
196 }
197 }
198
199 if !failures.is_empty() {
200 let details = failures
201 .iter()
202 .map(|(project, err)| format!(" [{project}]\n {err}"))
203 .collect::<Vec<_>>()
204 .join("\n\n");
205 return Err(cuenv_core::Error::execution(format!(
206 "CI pipeline failed:\n\n{details}"
207 )));
208 }
209
210 Ok(())
211}
212
213pub struct PipelineExecutionRequest<'a> {
215 pub project_path: &'a Path,
216 pub config: &'a Project,
217 pub pipeline_name: &'a str,
218 pub tasks_to_run: &'a [String],
219 pub environment: Option<&'a str>,
220 pub context: &'a crate::context::CIContext,
221 pub changed_files: &'a [PathBuf],
222 pub provider: &'a dyn CIProvider,
223}
224
225#[allow(clippy::too_many_lines)] async fn execute_project_pipeline(
230 request: &PipelineExecutionRequest<'_>,
231) -> Result<(PipelineStatus, Vec<(String, cuenv_core::Error)>)> {
232 let project_path = request.project_path;
233 let config = request.config;
234 let pipeline_name = request.pipeline_name;
235 let tasks_to_run = request.tasks_to_run;
236 let environment = request.environment;
237 let context = request.context;
238 let changed_files = request.changed_files;
239 let provider = request.provider;
240
241 let start_time = Utc::now();
242 let mut tasks_reports = Vec::new();
243 let mut pipeline_status = PipelineStatus::Success;
244 let mut task_errors: Vec<(String, cuenv_core::Error)> = Vec::new();
245 let project_display = project_path.display().to_string();
246
247 let cache_policy_override = if is_fork_pr(context) {
249 Some(CachePolicy::Readonly)
250 } else {
251 None
252 };
253
254 let mut executor_config = CIExecutorConfig::new(project_path.to_path_buf())
256 .with_capture_output(cuenv_core::OutputCapture::Capture)
257 .with_dry_run(DryRun::No)
258 .with_secret_salt(std::env::var("CUENV_SECRET_SALT").unwrap_or_default());
259
260 if let Ok(prev_salt) = std::env::var("CUENV_SECRET_SALT_PREV")
262 && !prev_salt.is_empty()
263 {
264 executor_config = executor_config.with_secret_salt_prev(prev_salt);
265 }
266
267 let _executor_config = if let Some(policy) = cache_policy_override {
268 executor_config.with_cache_policy_override(policy)
269 } else {
270 executor_config
271 };
272
273 register_ci_secrets();
276
277 for task_name in tasks_to_run {
279 let inputs_matched =
280 matched_inputs_for_task(task_name, config, changed_files, project_path);
281 let outputs = config
282 .tasks
283 .get(task_name)
284 .and_then(|def| def.as_task())
285 .map(|task| task.outputs.clone())
286 .unwrap_or_default();
287
288 cuenv_events::emit_ci_task_executing!(&project_display, task_name);
289 let task_start = std::time::Instant::now();
290
291 let result = execute_task_with_deps(
293 config,
294 task_name,
295 project_path,
296 cache_policy_override,
297 environment,
298 )
299 .await;
300
301 let duration = u64::try_from(task_start.elapsed().as_millis()).unwrap_or(0);
302
303 let (status, exit_code, cache_key) = match result {
304 Ok(output) => {
305 if output.success {
306 cuenv_events::emit_ci_task_result!(&project_display, task_name, true);
307 (
308 TaskStatus::Success,
309 Some(output.exit_code),
310 if output.from_cache {
311 Some(format!("cached:{}", output.task_id))
312 } else {
313 Some(output.task_id)
314 },
315 )
316 } else {
317 cuenv_events::emit_ci_task_result!(&project_display, task_name, false);
318 pipeline_status = PipelineStatus::Failed;
319 task_errors.push((
321 project_display.clone(),
322 cuenv_core::Error::task_failed(
323 task_name,
324 output.exit_code,
325 &output.stdout,
326 &output.stderr,
327 ),
328 ));
329 (TaskStatus::Failed, Some(output.exit_code), None)
330 }
331 }
332 Err(e) => {
333 tracing::error!(error = %e, task = task_name, "Task execution error");
334 cuenv_events::emit_ci_task_result!(&project_display, task_name, false);
335 pipeline_status = PipelineStatus::Failed;
336 task_errors.push((project_display.clone(), e.into()));
338 (TaskStatus::Failed, None, None)
339 }
340 };
341
342 tasks_reports.push(TaskReport {
343 name: task_name.clone(),
344 status,
345 duration_ms: duration,
346 exit_code,
347 cache_key,
348 inputs_matched,
349 outputs,
350 });
351 }
352
353 let completed_at = Utc::now();
354 #[allow(clippy::cast_sign_loss)]
355 let duration_ms = (completed_at - start_time).num_milliseconds() as u64;
356
357 let report = PipelineReport {
359 version: cuenv_core::VERSION.to_string(),
360 project: project_path.display().to_string(),
361 pipeline: pipeline_name.to_string(),
362 context: ContextReport {
363 provider: context.provider.clone(),
364 event: context.event.clone(),
365 ref_name: context.ref_name.clone(),
366 base_ref: context.base_ref.clone(),
367 sha: context.sha.clone(),
368 changed_files: changed_files
369 .iter()
370 .map(|p| p.to_string_lossy().to_string())
371 .collect(),
372 },
373 started_at: start_time,
374 completed_at: Some(completed_at),
375 duration_ms: Some(duration_ms),
376 status: pipeline_status,
377 tasks: tasks_reports,
378 };
379
380 write_pipeline_report(&report, context, project_path);
382 notify_provider(provider, &report, pipeline_name).await;
383
384 Ok((pipeline_status, task_errors))
385}
386
387fn write_pipeline_report(
389 report: &PipelineReport,
390 context: &crate::context::CIContext,
391 project_path: &Path,
392) {
393 let report_dir = Path::new(".cuenv/reports");
395 if let Err(e) = std::fs::create_dir_all(report_dir) {
396 tracing::warn!(error = %e, "Failed to create report directory");
397 return;
398 }
399
400 let sha_dir = report_dir.join(&context.sha);
401 let _ = std::fs::create_dir_all(&sha_dir);
402
403 let project_filename = project_path.display().to_string().replace(['/', '\\'], "-") + ".json";
404 let report_path = sha_dir.join(project_filename);
405
406 if let Err(e) = write_report(report, &report_path) {
407 tracing::warn!(error = %e, "Failed to write report");
408 } else {
409 cuenv_events::emit_ci_report!(report_path.display());
410 }
411
412 if let Err(e) = crate::report::markdown::write_job_summary(report) {
414 tracing::warn!(error = %e, "Failed to write job summary");
415 }
416}
417
418async fn notify_provider(provider: &dyn CIProvider, report: &PipelineReport, pipeline_name: &str) {
420 let check_name = format!("cuenv: {pipeline_name}");
422 match provider.create_check(&check_name).await {
423 Ok(handle) => {
424 if let Err(e) = provider.complete_check(&handle, report).await {
425 tracing::warn!(error = %e, "Failed to complete check run");
426 }
427 }
428 Err(e) => {
429 tracing::warn!(error = %e, "Failed to create check run");
430 }
431 }
432
433 if let Err(e) = provider.upload_report(report).await {
435 tracing::warn!(error = %e, "Failed to post PR comment");
436 }
437}
438
439fn is_fork_pr(context: &crate::context::CIContext) -> bool {
441 context.event == "pull_request" && context.ref_name.starts_with("refs/pull/")
444}
445
446fn register_ci_secrets() {
451 const SECRET_PATTERNS: &[&str] = &[
453 "GITHUB_TOKEN",
454 "GH_TOKEN",
455 "ACTIONS_RUNTIME_TOKEN",
456 "ACTIONS_ID_TOKEN_REQUEST_TOKEN",
457 "AWS_SECRET_ACCESS_KEY",
458 "AWS_SESSION_TOKEN",
459 "AZURE_CLIENT_SECRET",
460 "GCP_SERVICE_ACCOUNT_KEY",
461 "CACHIX_AUTH_TOKEN",
462 "CODECOV_TOKEN",
463 "CUE_REGISTRY_TOKEN",
464 "VSCE_PAT",
465 "NPM_TOKEN",
466 "CARGO_REGISTRY_TOKEN",
467 "PYPI_TOKEN",
468 "DOCKER_PASSWORD",
469 "CLOUDFLARE_API_TOKEN",
470 "OP_SERVICE_ACCOUNT_TOKEN",
471 "CUENV_SECRET_SALT",
472 "CUENV_SECRET_SALT_PREV",
473 ];
474
475 for pattern in SECRET_PATTERNS {
476 if let Ok(value) = std::env::var(pattern) {
477 cuenv_events::register_secret(value);
478 }
479 }
480}
481
482fn resolve_environment(
483 cli_environment: Option<&str>,
484 pipeline_environment: Option<&str>,
485) -> Option<String> {
486 if let Some(env) = cli_environment.filter(|name| !name.is_empty()) {
487 return Some(env.to_string());
488 }
489
490 if let Ok(env) = std::env::var("CUENV_ENVIRONMENT")
491 && !env.is_empty()
492 {
493 return Some(env);
494 }
495
496 pipeline_environment
497 .filter(|name| !name.is_empty())
498 .map(|name| name.to_string())
499}
500
501async fn execute_task_with_deps(
506 config: &Project,
507 task_name: &str,
508 project_root: &Path,
509 cache_policy_override: Option<CachePolicy>,
510 environment: Option<&str>,
511) -> std::result::Result<TaskOutput, ExecutorError> {
512 let index =
514 TaskIndex::build(&config.tasks).map_err(|e| ExecutorError::Compilation(e.to_string()))?;
515
516 let entry = index
518 .resolve(task_name)
519 .map_err(|e| ExecutorError::Compilation(e.to_string()))?;
520 let canonical_name = entry.name.clone();
521
522 let flattened_tasks = index.to_tasks();
524
525 let mut graph = TaskGraph::new();
527 graph
528 .build_for_task(&canonical_name, &flattened_tasks)
529 .map_err(|e| ExecutorError::Compilation(e.to_string()))?;
530
531 let execution_order = graph
533 .topological_sort()
534 .map_err(|e| ExecutorError::Compilation(e.to_string()))?;
535
536 tracing::info!(
537 task = task_name,
538 canonical = %canonical_name,
539 execution_order = ?execution_order.iter().map(|n| &n.name).collect::<Vec<_>>(),
540 "Resolved task dependencies"
541 );
542
543 let mut final_output = None;
545 for node in execution_order {
546 let output = compile_and_execute_ir(
547 config,
548 &node.name,
549 project_root,
550 cache_policy_override,
551 environment,
552 )
553 .await?;
554
555 if !output.success {
556 return Ok(output); }
558 final_output = Some(output);
559 }
560
561 final_output.ok_or_else(|| ExecutorError::Compilation("No tasks to execute".into()))
562}
563
564async fn compile_and_execute_ir(
570 config: &Project,
571 task_name: &str,
572 project_root: &Path,
573 cache_policy_override: Option<CachePolicy>,
574 environment: Option<&str>,
575) -> std::result::Result<TaskOutput, ExecutorError> {
576 let start = std::time::Instant::now();
577
578 let options = crate::compiler::CompilerOptions {
580 project_root: Some(project_root.to_path_buf()),
581 ..Default::default()
582 };
583 let compiler = Compiler::with_options(config.clone(), options);
584 let ir = compiler
585 .compile_task(task_name)
586 .map_err(|e| ExecutorError::Compilation(e.to_string()))?;
587
588 if ir.tasks.is_empty() {
589 return Err(ExecutorError::Compilation(format!(
590 "Task '{task_name}' produced no executable tasks"
591 )));
592 }
593
594 let env_name = environment
597 .filter(|name| !name.is_empty())
598 .map(str::to_string)
599 .or_else(|| {
600 std::env::var("CUENV_ENVIRONMENT")
601 .ok()
602 .filter(|name| !name.is_empty())
603 });
604 let project_env_vars = config
605 .env
606 .as_ref()
607 .map(|env| match env_name.as_deref() {
608 Some(name) => env.for_environment(name),
609 None => env.base.clone(),
610 })
611 .unwrap_or_default();
612 let (resolved_env, secrets) =
613 cuenv_core::environment::Environment::resolve_for_task_with_secrets(
614 task_name,
615 &project_env_vars,
616 )
617 .await
618 .map_err(|e| ExecutorError::Compilation(format!("Secret resolution failed: {e}")))?;
619
620 cuenv_events::register_secrets(secrets.into_iter());
622
623 let runner = IRTaskRunner::new(
625 project_root.to_path_buf(),
626 cuenv_core::OutputCapture::Capture,
627 );
628 let mut combined_stdout = String::new();
629 let mut combined_stderr = String::new();
630 let mut all_success = true;
631 let mut last_exit_code = 0;
632
633 ensure_tools_downloaded(project_root).await;
635
636 let tool_bin_dirs = get_tool_bin_dirs(project_root);
638 let tool_path_prepend = if tool_bin_dirs.is_empty() {
639 String::new()
640 } else {
641 tool_bin_dirs
642 .iter()
643 .map(|p| p.display().to_string())
644 .collect::<Vec<_>>()
645 .join(":")
646 };
647
648 for ir_task in &ir.tasks {
649 let mut env: BTreeMap<String, String> = ir_task.env.clone();
651
652 for (key, value) in &resolved_env {
655 env.entry(key.clone()).or_insert_with(|| value.clone());
656 }
657
658 if let Ok(system_path) = std::env::var("PATH") {
660 let path = if tool_path_prepend.is_empty() {
661 system_path
662 } else {
663 format!("{tool_path_prepend}:{system_path}")
664 };
665 env.insert("PATH".to_string(), path);
666 } else if !tool_path_prepend.is_empty() {
667 env.insert("PATH".to_string(), tool_path_prepend.clone());
668 }
669 if let Ok(home) = std::env::var("HOME") {
670 env.insert("HOME".to_string(), home);
671 }
672
673 let mut task_to_run = ir_task.clone();
675 if let Some(policy) = cache_policy_override {
676 task_to_run.cache_policy = policy;
677 }
678
679 let output = runner.execute(&task_to_run, env).await?;
680
681 combined_stdout.push_str(&output.stdout);
682 combined_stderr.push_str(&output.stderr);
683 last_exit_code = output.exit_code;
684
685 if !output.success {
686 all_success = false;
687 break; }
689 }
690
691 let duration = start.elapsed();
692 let duration_ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX);
693
694 Ok(TaskOutput {
695 task_id: task_name.to_string(),
696 exit_code: last_exit_code,
697 stdout: combined_stdout,
698 stderr: combined_stderr,
699 success: all_success,
700 from_cache: false,
701 duration_ms,
702 })
703}
704
705fn find_lockfile(start_dir: &Path) -> Option<PathBuf> {
716 let lockfile_path = start_dir.join(LOCKFILE_NAME);
717 if lockfile_path.exists() {
718 return Some(lockfile_path);
719 }
720
721 let mut current = start_dir.parent();
723 while let Some(dir) = current {
724 let lockfile_path = dir.join(LOCKFILE_NAME);
725 if lockfile_path.exists() {
726 return Some(lockfile_path);
727 }
728 current = dir.parent();
729 }
730
731 None
732}
733
734fn create_tool_registry() -> ToolRegistry {
736 let mut registry = ToolRegistry::new();
737
738 registry.register(cuenv_tools_nix::NixToolProvider::new());
739 registry.register(cuenv_tools_github::GitHubToolProvider::new());
740 registry.register(cuenv_tools_rustup::RustupToolProvider::new());
741
742 registry
743}
744
745fn lockfile_entry_to_source(locked: &LockedToolPlatform) -> Option<ToolSource> {
747 match locked.provider.as_str() {
748 "oci" => {
749 let image = locked
750 .source
751 .get("image")
752 .and_then(|v| v.as_str())
753 .unwrap_or_default();
754 let path = locked
755 .source
756 .get("path")
757 .and_then(|v| v.as_str())
758 .unwrap_or_default();
759 Some(ToolSource::Oci {
760 image: image.to_string(),
761 path: path.to_string(),
762 })
763 }
764 "github" => {
765 let repo = locked
766 .source
767 .get("repo")
768 .and_then(|v| v.as_str())
769 .unwrap_or_default();
770 let tag = locked
771 .source
772 .get("tag")
773 .and_then(|v| v.as_str())
774 .unwrap_or_default();
775 let asset = locked
776 .source
777 .get("asset")
778 .and_then(|v| v.as_str())
779 .unwrap_or_default();
780 let path = locked
781 .source
782 .get("path")
783 .and_then(|v| v.as_str())
784 .map(String::from);
785 Some(ToolSource::GitHub {
786 repo: repo.to_string(),
787 tag: tag.to_string(),
788 asset: asset.to_string(),
789 path,
790 })
791 }
792 "nix" => {
793 let flake = locked
794 .source
795 .get("flake")
796 .and_then(|v| v.as_str())
797 .unwrap_or_default();
798 let package = locked
799 .source
800 .get("package")
801 .and_then(|v| v.as_str())
802 .unwrap_or_default();
803 let output = locked
804 .source
805 .get("output")
806 .and_then(|v| v.as_str())
807 .map(String::from);
808 Some(ToolSource::Nix {
809 flake: flake.to_string(),
810 package: package.to_string(),
811 output,
812 })
813 }
814 "rustup" => {
815 let toolchain = locked
816 .source
817 .get("toolchain")
818 .and_then(|v| v.as_str())
819 .unwrap_or("stable");
820 let profile = locked
821 .source
822 .get("profile")
823 .and_then(|v| v.as_str())
824 .map(String::from);
825 let components: Vec<String> = locked
826 .source
827 .get("components")
828 .and_then(|v| v.as_array())
829 .map(|arr| {
830 arr.iter()
831 .filter_map(|v| v.as_str().map(String::from))
832 .collect()
833 })
834 .unwrap_or_default();
835 let targets: Vec<String> = locked
836 .source
837 .get("targets")
838 .and_then(|v| v.as_array())
839 .map(|arr| {
840 arr.iter()
841 .filter_map(|v| v.as_str().map(String::from))
842 .collect()
843 })
844 .unwrap_or_default();
845 Some(ToolSource::Rustup {
846 toolchain: toolchain.to_string(),
847 profile,
848 components,
849 targets,
850 })
851 }
852 _ => None,
853 }
854}
855
856fn get_tool_bin_dirs(project_root: &Path) -> Vec<PathBuf> {
858 let mut bin_dirs: HashSet<PathBuf> = HashSet::new();
859
860 let Some(lockfile_path) = find_lockfile(project_root) else {
861 return vec![];
862 };
863
864 let Ok(Some(lockfile)) = Lockfile::load(&lockfile_path) else {
865 return vec![];
866 };
867
868 if lockfile.tools.is_empty() {
869 return vec![];
870 }
871
872 let platform = Platform::current();
873 let platform_str = platform.to_string();
874 let cache_dir = ToolOptions::default().cache_dir();
875
876 let lockfile_dir = lockfile_path.parent().unwrap_or(Path::new("."));
878 if let Ok(profile_path) = cuenv_tools_nix::profile::profile_path_for_project(lockfile_dir) {
879 let bin = profile_path.join("bin");
880 if bin.exists() {
881 bin_dirs.insert(bin);
882 }
883 }
884
885 for (name, tool) in &lockfile.tools {
887 let Some(locked) = tool.platforms.get(&platform_str) else {
888 continue;
889 };
890
891 if locked.provider == "nix" {
893 continue;
894 }
895
896 if locked.provider == "rustup" {
898 if let Some(toolchain) = locked.source.get("toolchain").and_then(|v| v.as_str()) {
899 let rustup_home = std::env::var("RUSTUP_HOME").map_or_else(
900 |_| {
901 dirs::home_dir()
902 .unwrap_or_else(|| PathBuf::from("."))
903 .join(".rustup")
904 },
905 PathBuf::from,
906 );
907
908 let host_triple = format!(
909 "{}-{}",
910 match platform.arch {
911 cuenv_core::tools::Arch::Arm64 => "aarch64",
912 cuenv_core::tools::Arch::X86_64 => "x86_64",
913 },
914 match platform.os {
915 cuenv_core::tools::Os::Darwin => "apple-darwin",
916 cuenv_core::tools::Os::Linux => "unknown-linux-gnu",
917 }
918 );
919 let toolchain_name = format!("{toolchain}-{host_triple}");
920 let bin = rustup_home
921 .join("toolchains")
922 .join(toolchain_name)
923 .join("bin");
924 if bin.exists() {
925 bin_dirs.insert(bin);
926 }
927 }
928 continue;
929 }
930
931 let tool_dir = cache_dir
933 .join(&locked.provider)
934 .join(name)
935 .join(&tool.version);
936
937 if tool_dir.exists() {
938 if tool_dir.join(name).exists() || tool_dir.join(format!("{name}.exe")).exists() {
940 bin_dirs.insert(tool_dir.clone());
941 }
942 let bin = tool_dir.join("bin");
944 if bin.exists() {
945 bin_dirs.insert(bin);
946 }
947 }
948 }
949
950 bin_dirs.into_iter().collect()
951}
952
953async fn ensure_tools_downloaded(project_root: &Path) {
955 let Some(lockfile_path) = find_lockfile(project_root) else {
956 tracing::debug!("No lockfile found - skipping tool download");
957 return;
958 };
959
960 let lockfile = match Lockfile::load(&lockfile_path) {
961 Ok(Some(lf)) => lf,
962 Ok(None) => {
963 tracing::debug!("Empty lockfile - skipping tool download");
964 return;
965 }
966 Err(e) => {
967 tracing::warn!("Failed to load lockfile: {e}");
968 return;
969 }
970 };
971
972 if lockfile.tools.is_empty() {
973 tracing::debug!("No tools in lockfile - skipping download");
974 return;
975 }
976
977 let platform = Platform::current();
978 let platform_str = platform.to_string();
979 let options = ToolOptions::default();
980 let registry = create_tool_registry();
981
982 let mut providers_used = HashSet::new();
984 for tool in lockfile.tools.values() {
985 if let Some(locked) = tool.platforms.get(&platform_str) {
986 providers_used.insert(locked.provider.clone());
987 }
988 }
989
990 for provider_name in &providers_used {
991 if let Some(provider) = registry.get(provider_name)
992 && let Err(e) = provider.check_prerequisites().await
993 {
994 tracing::warn!(
995 "Provider '{}' prerequisites check failed: {} - skipping tools from this provider",
996 provider_name,
997 e
998 );
999 }
1000 }
1001
1002 for (name, tool) in &lockfile.tools {
1004 let Some(locked) = tool.platforms.get(&platform_str) else {
1005 continue;
1006 };
1007
1008 let Some(source) = lockfile_entry_to_source(locked) else {
1009 tracing::debug!(
1010 "Unknown provider '{}' for tool '{}' - skipping",
1011 locked.provider,
1012 name
1013 );
1014 continue;
1015 };
1016
1017 let Some(provider) = registry.find_for_source(&source) else {
1018 tracing::debug!("No provider found for tool '{}' - skipping", name);
1019 continue;
1020 };
1021
1022 let resolved = ResolvedTool {
1023 name: name.clone(),
1024 version: tool.version.clone(),
1025 platform: platform.clone(),
1026 source,
1027 };
1028
1029 if provider.is_cached(&resolved, &options) {
1031 continue;
1032 }
1033
1034 tracing::info!("Downloading {} v{}...", name, tool.version);
1036 match provider.fetch(&resolved, &options).await {
1037 Ok(fetched) => {
1038 tracing::info!("Downloaded {} -> {}", name, fetched.binary_path.display());
1039 }
1040 Err(e) => {
1041 tracing::warn!("Failed to download tool '{}': {}", name, e);
1042 }
1043 }
1044 }
1045}