Skip to main content

cuenv_ci/executor/
orchestrator.rs

1//! CI Pipeline Orchestrator
2//!
3//! Main entry point for CI pipeline execution, integrating with the provider
4//! system for context detection, file change tracking, and reporting.
5//!
6//! This module orchestrates complex async workflows with caching, concurrency control,
7//! and multi-project coordination. The complexity is inherent to the domain.
8
9// CI orchestration has inherent complexity - coordinates async tasks, caching, reporting
10#![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/// Run the CI pipeline logic
34///
35/// This is the main entry point for CI execution, integrating with the provider
36/// system for context detection, file change tracking, and reporting.
37///
38/// # Arguments
39///
40/// * `provider` - The CI provider to use for changed files detection and reporting
41/// * `dry_run` - Whether to skip actual task execution
42/// * `specific_pipeline` - If set, only run tasks from this pipeline
43/// * `environment` - Optional environment override for secrets resolution
44/// * `path_filter` - If set, only process projects under this path (relative to module root)
45///
46/// # Errors
47/// Returns error if IO errors occur or tasks fail
48#[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    // Get changed files
60    let changed_files = provider.changed_files().await?;
61    cuenv_events::emit_ci_changed_files!(changed_files.len());
62
63    // Evaluate module and discover projects
64    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    // Collect projects with their configs
74    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    // Filter projects by path if specified (and not the default ".")
82    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    // Build project map for cross-project dependency resolution
101    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    // Track failures with structured errors
110    let mut failures: Vec<(String, cuenv_core::Error)> = Vec::new();
111
112    // Process each project
113    for (project_path, config) in &projects {
114        // Determine pipeline to run
115        let pipeline_name = specific_pipeline
116            .clone()
117            .unwrap_or_else(|| "default".to_string());
118
119        // Find pipeline in config
120        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        // Extract task names from pipeline tasks (which can be simple strings or matrix tasks)
141        let pipeline_task_names: Vec<String> = pipeline
142            .tasks
143            .iter()
144            .map(|t| t.task_name().to_string())
145            .collect();
146
147        // For release events, run all tasks unconditionally (no affected-file filtering)
148        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
213/// All parameters needed to execute a project pipeline.
214pub 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/// Execute a project's pipeline and handle reporting
226///
227/// Returns the pipeline status and a list of task failures (project path, error).
228#[allow(clippy::too_many_lines)] // Complex orchestration logic
229async 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    // Determine cache policy override based on context
248    let cache_policy_override = if is_fork_pr(context) {
249        Some(CachePolicy::Readonly)
250    } else {
251        None
252    };
253
254    // Create executor configuration with salt rotation support
255    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    // Add previous salt for rotation support
261    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 common CI secret patterns for redaction.
274    // These are typically passed via GitHub Actions secrets or similar.
275    register_ci_secrets();
276
277    // Execute tasks
278    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        // Execute the task with all dependencies (uses TaskGraph for proper ordering)
292        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                    // Capture task failure with structured error
320                    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                // Capture execution error with structured error
337                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    // Generate report
358    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 reports and notify provider
381    write_pipeline_report(&report, context, project_path);
382    notify_provider(provider, &report, pipeline_name).await;
383
384    Ok((pipeline_status, task_errors))
385}
386
387/// Write pipeline report to disk
388fn write_pipeline_report(
389    report: &PipelineReport,
390    context: &crate::context::CIContext,
391    project_path: &Path,
392) {
393    // Ensure report directory exists
394    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    // Write GitHub Job Summary
413    if let Err(e) = crate::report::markdown::write_job_summary(report) {
414        tracing::warn!(error = %e, "Failed to write job summary");
415    }
416}
417
418/// Notify CI provider about pipeline results
419async fn notify_provider(provider: &dyn CIProvider, report: &PipelineReport, pipeline_name: &str) {
420    // Post results to CI provider
421    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    // Post PR comment with report summary
434    if let Err(e) = provider.upload_report(report).await {
435        tracing::warn!(error = %e, "Failed to post PR comment");
436    }
437}
438
439/// Check if this is a fork PR (should use readonly cache)
440fn is_fork_pr(context: &crate::context::CIContext) -> bool {
441    // Fork PRs typically have a different head repo than base repo
442    // This is a simplified check - providers may need more sophisticated detection
443    context.event == "pull_request" && context.ref_name.starts_with("refs/pull/")
444}
445
446/// Register common CI secret environment variables for redaction.
447///
448/// This ensures that secrets passed via CI provider (GitHub Actions, etc.)
449/// are automatically redacted from task output.
450fn register_ci_secrets() {
451    // Common secret environment variable patterns
452    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
501/// Execute a task with all its dependencies in correct order.
502///
503/// Uses TaskIndex to flatten nested tasks and TaskGraph to resolve dependencies,
504/// ensuring tasks run in proper topological order (same as CLI).
505async 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    // 1. Build TaskIndex (same flattening as CLI)
513    let index =
514        TaskIndex::build(&config.tasks).map_err(|e| ExecutorError::Compilation(e.to_string()))?;
515
516    // 2. Resolve to canonical name
517    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    // 3. Get flattened tasks where all names are top-level
523    let flattened_tasks = index.to_tasks();
524
525    // 4. Build TaskGraph (respects dependsOn!)
526    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    // 5. Get topological execution order
532    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    // 6. Execute each task in dependency order
544    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); // Stop on first failure
557        }
558        final_output = Some(output);
559    }
560
561    final_output.ok_or_else(|| ExecutorError::Compilation("No tasks to execute".into()))
562}
563
564/// Compile a single task to IR and execute it.
565///
566/// This is the inner execution loop - it does NOT handle dependencies.
567/// Dependencies are resolved by the outer loop using TaskGraph.
568/// Uses the Compiler to convert task definitions to IR.
569async 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    // Use the Compiler to compile the task (handles both single tasks and groups)
579    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    // Resolve secrets from project environment (same as CLI).
595    // Prefer an explicit environment name, then fall back to CUENV_ENVIRONMENT.
596    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    // Register resolved secrets for redaction
621    cuenv_events::register_secrets(secrets.into_iter());
622
623    // Execute all compiled IR tasks sequentially
624    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 are downloaded before getting their paths
634    ensure_tools_downloaded(project_root).await;
635
636    // Get tool bin directories from lockfile
637    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        // Build environment: start with IR task env (task-specific vars)
650        let mut env: BTreeMap<String, String> = ir_task.env.clone();
651
652        // Merge project-level resolved environment (includes secrets)
653        // Project env is lower priority - task-specific overrides win
654        for (key, value) in &resolved_env {
655            env.entry(key.clone()).or_insert_with(|| value.clone());
656        }
657
658        // Add PATH with tool directories prepended, then HOME
659        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        // Apply cache policy override if specified
674        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; // Stop on first failure
688        }
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
705// ============================================================================
706// Tool Activation Helpers
707//
708// These functions match the CLI's implementation in commands/tools.rs.
709// They are duplicated here because cuenv-core cannot depend on tool providers
710// (it would create a cyclic dependency). A future refactor could extract these
711// into a dedicated cuenv-tools-activation crate that both CLI and CI use.
712// ============================================================================
713
714/// Find the lockfile starting from a directory.
715fn 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    // Check parent directories
722    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
734/// Create a tool registry with all available providers.
735fn 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
745/// Convert a lockfile entry to a `ToolSource`.
746fn 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
856/// Get tool bin directories from the lockfile for PATH injection.
857fn 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    // 1. Check for Nix profile
877    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    // 2. Process non-Nix tools
886    for (name, tool) in &lockfile.tools {
887        let Some(locked) = tool.platforms.get(&platform_str) else {
888            continue;
889        };
890
891        // Skip Nix tools - they use profile
892        if locked.provider == "nix" {
893            continue;
894        }
895
896        // Handle rustup tools
897        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        // GitHub and other cache-based tools
932        let tool_dir = cache_dir
933            .join(&locked.provider)
934            .join(name)
935            .join(&tool.version);
936
937        if tool_dir.exists() {
938            // Check if tool_dir itself contains the binary (flat structure)
939            if tool_dir.join(name).exists() || tool_dir.join(format!("{name}.exe")).exists() {
940                bin_dirs.insert(tool_dir.clone());
941            }
942            // Also check bin subdirectory
943            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
953/// Ensure all tools from the lockfile are downloaded for the current platform.
954async 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    // Check prerequisites for all providers we'll use
983    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    // Download tools that aren't cached
1003    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        // Check if already cached
1030        if provider.is_cached(&resolved, &options) {
1031            continue;
1032        }
1033
1034        // Fetch the tool
1035        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}