diff --git a/dev_docs/project_plan.md b/dev_docs/project_plan.md index 8f9ef48..fb9d0f5 100644 --- a/dev_docs/project_plan.md +++ b/dev_docs/project_plan.md @@ -25,6 +25,7 @@ This plan outlines the major development phases and tasks for building `dela`, a - [x] [DTKT-6] Implement parser for `pyproject.toml` scripts (`pyproject-toml`). - [x] [DTKT-106] For `package.json`, detect if there is a lock file `pnpm` or `npm` or `yarn` or `bun` use that to run tasks. - [x] [DTKT-104] Update makefile-lossless to new version supporting trailing text. + - [x] [DTKT-200] Refactor `src/task_discovery.rs` into a registry-based `TaskDiscovery` trait with per-runner modules under `src/task_discovery/`. - [ ] **Structs and Runners** - [x] [DTKT-7] Define `Task` and `TaskRunner` enums in `types.rs`. diff --git a/src/commands/list.rs b/src/commands/list.rs index 0f188e3..8ff7939 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -257,17 +257,13 @@ pub fn execute(verbose: bool) -> Result<(), String> { let runner_paths: HashSet<_> = sorted_tasks.iter().map(|task| &task.file_path).collect(); - let display_path = if runner_paths.len() == 1 { - format_runner_path_for_display(&runner, &sorted_tasks[0].file_path, ¤t_dir) + let section_runner_path = + (runner_paths.len() == 1).then_some(sorted_tasks[0].file_path.as_path()); + let display_path = if let Some(runner_path) = section_runner_path { + format_runner_path_for_display(&runner, runner_path, ¤t_dir) } else { "multiple files".to_string() }; - let show_task_sources = sorted_tasks - .iter() - .map(|task| task.definition_path().to_path_buf()) - .collect::>() - .len() - > 1; // Write section header let colored_runner = if tool_not_installed { @@ -290,8 +286,8 @@ pub fn execute(verbose: bool) -> Result<(), String> { used_footnotes.insert('‖', true); } - if task.shadowed_by.is_some() { - match task.shadowed_by.as_ref().unwrap() { + if let Some(shadowed_by) = &task.shadowed_by { + match shadowed_by { ShadowType::ShellBuiltin(_) => { used_footnotes.insert('†', true); } @@ -303,9 +299,7 @@ pub fn execute(verbose: bool) -> Result<(), String> { // Format the task entry let formatted_task = format_task_entry(task, is_ambiguous, display_width); - let source_label = show_task_sources.then(|| { - format_definition_path_for_display(task.definition_path(), ¤t_dir) - }); + let source_label = task_source_label(task, section_runner_path, ¤t_dir); let formatted_task = format_task_entry_with_source(formatted_task, source_label.as_deref()); write_line(&format!(" {}", formatted_task))?; @@ -455,6 +449,20 @@ fn format_task_entry_with_source(formatted_task: String, source_label: Option<&s } } +fn task_source_label( + task: &Task, + section_runner_path: Option<&Path>, + current_dir: &Path, +) -> Option { + match section_runner_path { + Some(runner_path) if task.definition_path() == runner_path => None, + _ => Some(format_definition_path_for_display( + task.definition_path(), + current_dir, + )), + } +} + fn format_definition_path_for_display(path: &Path, current_dir: &Path) -> String { if let Ok(relative_path) = path.strip_prefix(current_dir) { relative_path.to_string_lossy().to_string() @@ -1016,4 +1024,42 @@ mod tests { assert!(formatted.contains("Included task")); assert!(formatted.contains("[mk/common.mk]")); } + + #[test] + fn test_task_source_label_omits_section_runner_path_and_keeps_composed_source() { + let current_dir = Path::new("/project"); + let runner_path = Path::new("/project/Makefile"); + + let root_task = Task { + name: "build".to_string(), + file_path: runner_path.to_path_buf(), + definition_path: None, + definition_type: TaskDefinitionType::Makefile, + runner: TaskRunner::Make, + source_name: "build".to_string(), + description: Some("Build task".to_string()), + shadowed_by: None, + disambiguated_name: None, + }; + let included_task = Task { + name: "release_notes".to_string(), + file_path: runner_path.to_path_buf(), + definition_path: Some(PathBuf::from("/project/mk/common.mk")), + definition_type: TaskDefinitionType::Makefile, + runner: TaskRunner::Make, + source_name: "release_notes".to_string(), + description: Some("Release task".to_string()), + shadowed_by: None, + disambiguated_name: None, + }; + + assert_eq!( + task_source_label(&root_task, Some(runner_path), current_dir), + None + ); + assert_eq!( + task_source_label(&included_task, Some(runner_path), current_dir), + Some("mk/common.mk".to_string()) + ); + } } diff --git a/src/task_discovery.rs b/src/task_discovery.rs index 451003f..2c3dc88 100644 --- a/src/task_discovery.rs +++ b/src/task_discovery.rs @@ -1,1399 +1,91 @@ -use crate::composed_paths::{ComposedDefinitionSource, RecursiveDiscoveryState, VisitState}; -use crate::parsers::{ - parse_cmake, parse_docker_compose, parse_github_actions, parse_gradle, parse_justfile, - parse_makefile, parse_package_json, parse_pom_xml, parse_pyproject_toml, parse_taskfile, - parse_travis_ci, parse_turbo_json, -}; -use crate::repo_root::find_git_repo_root; -use crate::task_shadowing::check_shadowing; -use crate::types::{Task, TaskDefinitionFile, TaskDefinitionType, TaskFileStatus, TaskRunner}; -use std::collections::{BTreeMap, HashMap}; -use std::fs; -use std::path::Path; -use std::path::PathBuf; - -// Define the DiscoveredTaskDefinitions type directly here -#[derive(Debug, Clone, Default)] -pub struct DiscoveredTaskDefinitions { - pub makefile: Option, - pub package_json: Option, - pub pyproject_toml: Option, - pub taskfile: Option, - pub turbo_json: Option, - pub maven_pom: Option, - pub gradle: Option, - pub github_actions: Option, - pub docker_compose: Option, - pub travis_ci: Option, - pub cmake: Option, - pub justfile: Option, -} - -/// Result of task discovery -#[derive(Debug, Clone, Default)] -pub struct DiscoveredTasks { - /// Task definition files found - pub definitions: DiscoveredTaskDefinitions, - /// Tasks found - pub tasks: Vec, - /// Errors encountered during discovery - pub errors: Vec, - /// Map of task names to the number of occurrences (for disambiguation) - pub task_name_counts: HashMap, -} - -impl DiscoveredTasks { - /// Creates a new empty DiscoveredTasks - #[cfg(test)] - pub fn new() -> Self { - DiscoveredTasks::default() - } - - /// Adds a task to the discovered tasks and updates task_name_counts - #[cfg(test)] - pub fn add_task(&mut self, task: Task) { - // Update the task name count - *self.task_name_counts.entry(task.name.clone()).or_insert(0) += 1; - - // Add the task to the list - self.tasks.push(task); - } -} - -/// Discover tasks in a directory -pub fn discover_tasks(dir: &Path) -> DiscoveredTasks { - let mut discovered = DiscoveredTasks::default(); - - // Discover tasks from each type of definition file - let _ = discover_makefile_tasks(dir, &mut discovered); - let _ = discover_npm_tasks(dir, &mut discovered); - let _ = discover_python_tasks(dir, &mut discovered); - let _ = discover_taskfile_tasks(dir, &mut discovered); - let _ = discover_turbo_tasks(dir, &mut discovered); - let _ = discover_maven_tasks(dir, &mut discovered); - let _ = discover_gradle_tasks(dir, &mut discovered); - let _ = discover_github_actions_tasks(dir, &mut discovered); - let _ = discover_docker_compose_tasks(dir, &mut discovered); - let _ = discover_travis_ci_tasks(dir, &mut discovered); - let _ = discover_cmake_tasks(dir, &mut discovered); - let _ = discover_justfile_tasks(dir, &mut discovered); - discover_shell_script_tasks(dir, &mut discovered); - - // Process tasks to identify name collisions - process_task_disambiguation(&mut discovered); - - discovered -} - -/// Processes tasks to identify name collisions and populate disambiguated_name fields -pub fn process_task_disambiguation(discovered: &mut DiscoveredTasks) { - // Step 1: Identify tasks with name collisions - let mut task_name_counts: HashMap = HashMap::new(); - let mut tasks_by_name: HashMap> = HashMap::new(); - - // Count occurrences of each task name - for (i, task) in discovered.tasks.iter().enumerate() { - *task_name_counts.entry(task.name.clone()).or_insert(0) += 1; - tasks_by_name.entry(task.name.clone()).or_default().push(i); - } - - // Save task name counts for reference - discovered.task_name_counts = task_name_counts.clone(); - - // Step 2: Add disambiguated names to tasks with name collisions - for (name, count) in task_name_counts.iter() { - if *count > 1 { - // This task name has collisions - let task_indices = tasks_by_name.get(name).unwrap(); - - // Track which runner prefix suffixes we've used for this task name - let mut used_prefixes = std::collections::HashSet::new(); - - for &idx in task_indices { - let task = &mut discovered.tasks[idx]; - let runner_prefix = generate_runner_prefix(&task.runner, &used_prefixes); - used_prefixes.insert(runner_prefix.clone()); - - // Add a disambiguated name - task.disambiguated_name = Some(format!("{}-{}", task.name, runner_prefix)); - } - } - } - - // Step 3: Add disambiguated names to shadowed tasks - for task in &mut discovered.tasks { - // Skip tasks that already have disambiguated names (from name collisions) - if task.disambiguated_name.is_some() { - continue; - } - - // If task is shadowed, add a disambiguated name with runner prefix - if task.shadowed_by.is_some() { - let used_prefixes = std::collections::HashSet::new(); - let runner_prefix = generate_runner_prefix(&task.runner, &used_prefixes); - task.disambiguated_name = Some(format!("{}-{}", task.name, runner_prefix)); - } - } -} - -/// Generates a unique prefix for a task runner for disambiguation -fn generate_runner_prefix( - runner: &TaskRunner, - used_prefixes: &std::collections::HashSet, -) -> String { - let short_name = runner.short_name().to_lowercase(); - - // Try single character first for common runners - let single_char = short_name.chars().next().unwrap().to_string(); - if !used_prefixes.contains(&single_char) { - return single_char; - } - - // Then try to use the first three characters (or all if shorter than 3) - let prefix_length = std::cmp::min(3, short_name.len()); - let mut prefix = short_name[0..prefix_length].to_string(); - - // If unique, return it - if !used_prefixes.contains(&prefix) { - return prefix; - } - - // If that's taken, try adding more letters until we have a unique prefix - for i in (prefix_length + 1)..=short_name.len() { - prefix = short_name[0..i].to_string(); - if !used_prefixes.contains(&prefix) { - return prefix; - } - } - - // If we somehow get here, we'll make it unique by adding a number - let mut i = 1; - loop { - let numbered_prefix = format!("{}{}", short_name, i); - if !used_prefixes.contains(&numbered_prefix) { - return numbered_prefix; - } - i += 1; - } -} - -/// Checks if a task name is ambiguous (has multiple implementations) -pub fn is_task_ambiguous(discovered: &DiscoveredTasks, task_name: &str) -> bool { - discovered - .task_name_counts - .get(task_name) - .is_some_and(|&count| count > 1) -} - -/// Returns a list of disambiguated task names for tasks with the given name -#[allow(dead_code)] -pub fn get_disambiguated_task_names(discovered: &DiscoveredTasks, task_name: &str) -> Vec { - discovered - .tasks - .iter() - .filter(|t| t.name == task_name) - .filter_map(|t| t.disambiguated_name.clone()) - .collect() -} - -/// Returns all tasks matching a given name (both original and disambiguated) -pub fn get_matching_tasks<'a>(discovered: &'a DiscoveredTasks, task_name: &str) -> Vec<&'a Task> { - let mut result = Vec::new(); - - // Check if this matches a disambiguated name - if let Some(task) = discovered.tasks.iter().find(|t| { - t.disambiguated_name - .as_ref() - .is_some_and(|dn| dn == task_name) - }) { - result.push(task); - return result; - } - - // Otherwise, find all tasks with this original name - result.extend(discovered.tasks.iter().filter(|t| t.name == task_name)); - result -} - -/// Returns a standardized error message for ambiguous tasks -pub fn format_ambiguous_task_error(task_name: &str, matching_tasks: &[&Task]) -> String { - let mut msg = format!("Multiple tasks named '{}' found. Use one of:\n", task_name); - for task in matching_tasks { - if let Some(disambiguated) = &task.disambiguated_name { - msg.push_str(&format!( - " • {} ({} from {})\n", - disambiguated, - task.runner.short_name(), - task.definition_path().display() - )); - } - } - msg.push_str("Please use the specific task name with its suffix to disambiguate."); - msg -} - -/// Helper function to set task definition based on type -fn set_definition(discovered: &mut DiscoveredTasks, definition: TaskDefinitionFile) { - match definition.definition_type { - TaskDefinitionType::Makefile => discovered.definitions.makefile = Some(definition), - TaskDefinitionType::PackageJson => discovered.definitions.package_json = Some(definition), - TaskDefinitionType::PyprojectToml => { - discovered.definitions.pyproject_toml = Some(definition) - } - TaskDefinitionType::Taskfile => discovered.definitions.taskfile = Some(definition), - TaskDefinitionType::TurboJson => discovered.definitions.turbo_json = Some(definition), - TaskDefinitionType::MavenPom => discovered.definitions.maven_pom = Some(definition), - TaskDefinitionType::Gradle => discovered.definitions.gradle = Some(definition), - TaskDefinitionType::GitHubActions => { - discovered.definitions.github_actions = Some(definition) - } - TaskDefinitionType::DockerCompose => { - discovered.definitions.docker_compose = Some(definition) - } - TaskDefinitionType::TravisCi => discovered.definitions.travis_ci = Some(definition), - TaskDefinitionType::CMake => discovered.definitions.cmake = Some(definition), - TaskDefinitionType::Justfile => discovered.definitions.justfile = Some(definition), - _ => {} - } -} - -/// Helper function to handle task file discovery errors -fn handle_discovery_error( - error: String, - file_path: PathBuf, - definition_type: TaskDefinitionType, - discovered: &mut DiscoveredTasks, -) { - discovered.errors.push(format!( - "Failed to parse {}: {}", - file_path.display(), - error - )); - let definition = TaskDefinitionFile { - path: file_path, - definition_type, - status: TaskFileStatus::ParseError(error), - }; - set_definition(discovered, definition); -} - -/// Helper function to handle successful task discovery -fn handle_discovery_success( - mut tasks: Vec, - file_path: PathBuf, - definition_type: TaskDefinitionType, - discovered: &mut DiscoveredTasks, -) { - // Add shadow information - for task in &mut tasks { - task.shadowed_by = check_shadowing(&task.name); - } - let definition = TaskDefinitionFile { - path: file_path, - definition_type, - status: TaskFileStatus::Parsed, - }; - set_definition(discovered, definition); - discovered.tasks.extend(tasks); -} - -fn discover_makefile_tasks(dir: &Path, discovered: &mut DiscoveredTasks) -> Result<(), String> { - let makefile_path = dir.join("Makefile"); - - if !makefile_path.exists() { - discovered.definitions.makefile = Some(TaskDefinitionFile { - path: makefile_path.clone(), - definition_type: TaskDefinitionType::Makefile, - status: TaskFileStatus::NotFound, - }); - return Ok(()); - } - - let root_source = ComposedDefinitionSource::direct(makefile_path.clone()); - let mut traversal_state = RecursiveDiscoveryState::new(); - let mut seen_task_names = std::collections::HashSet::new(); - let mut tasks = Vec::new(); - let mut include_errors = Vec::new(); - - let result = collect_makefile_tasks_recursive( - &makefile_path, - &root_source, - &mut traversal_state, - &mut seen_task_names, - &mut tasks, - &mut include_errors, - ); - - for task in &mut tasks { - task.shadowed_by = check_shadowing(&task.name); - } - - discovered.tasks.extend(tasks); - discovered.errors.extend(include_errors); - - let status = match result { - Ok(()) => TaskFileStatus::Parsed, - Err(error) => { - discovered.errors.push(format!( - "Failed to parse {}: {}", - makefile_path.display(), - error - )); - TaskFileStatus::ParseError(error) - } - }; - discovered.definitions.makefile = Some(TaskDefinitionFile { - path: makefile_path, - definition_type: TaskDefinitionType::Makefile, - status, - }); - - Ok(()) -} - -fn collect_makefile_tasks_recursive( - root_makefile_path: &Path, - current_source: &ComposedDefinitionSource, - traversal_state: &mut RecursiveDiscoveryState, - seen_task_names: &mut std::collections::HashSet, - collected_tasks: &mut Vec, - include_errors: &mut Vec, -) -> Result<(), String> { - match traversal_state.mark_visited(current_source.definition_path()) { - VisitState::AlreadyVisited(_) => return Ok(()), - VisitState::New(_) => {} - } - - let mut first_error: Option = None; - - let mut tasks = parse_makefile::parse(current_source.definition_path())?; - for task in &mut tasks { - current_source.apply_to_task(task); - } - for task in tasks { - if seen_task_names.insert(task.name.clone()) { - collected_tasks.push(task); - } - } - - let includes = parse_makefile::extract_include_directives(current_source.definition_path())?; - - for include in includes { - let resolved_include = current_source.resolve_child(&include.path); - - if !resolved_include.is_file() { - continue; - } - - let include_source = - ComposedDefinitionSource::composed(root_makefile_path, resolved_include.clone()); - if let Err(error) = collect_makefile_tasks_recursive( - root_makefile_path, - &include_source, - traversal_state, - seen_task_names, - collected_tasks, - include_errors, - ) { - let error = format!( - "Failed to parse included makefile '{}': {}", - resolved_include.display(), - error - ); - include_errors.push(error.clone()); - if first_error.is_none() { - first_error = Some(error); - } - } - } - - if let Some(error) = first_error { - Err(error) - } else { - Ok(()) - } -} - -fn discover_npm_tasks(dir: &Path, discovered: &mut DiscoveredTasks) -> Result<(), String> { - let package_json = dir.join("package.json"); - - if !package_json.exists() { - discovered.definitions.package_json = Some(TaskDefinitionFile { - path: package_json.clone(), - definition_type: TaskDefinitionType::PackageJson, - status: TaskFileStatus::NotFound, - }); - return Ok(()); - } - - match parse_package_json::parse(&package_json) { - Ok(tasks) => { - handle_discovery_success( - tasks, - package_json, - TaskDefinitionType::PackageJson, - discovered, - ); - } - Err(e) => { - handle_discovery_error(e, package_json, TaskDefinitionType::PackageJson, discovered); - } - } - - Ok(()) -} - -fn discover_python_tasks(dir: &Path, discovered: &mut DiscoveredTasks) -> Result<(), String> { - let pyproject_toml = dir.join("pyproject.toml"); - - if !pyproject_toml.exists() { - discovered.definitions.pyproject_toml = Some(TaskDefinitionFile { - path: pyproject_toml.clone(), - definition_type: TaskDefinitionType::PyprojectToml, - status: TaskFileStatus::NotFound, - }); - return Ok(()); - } - - match parse_pyproject_toml::parse(&pyproject_toml) { - Ok(tasks) => { - handle_discovery_success( - tasks, - pyproject_toml, - TaskDefinitionType::PyprojectToml, - discovered, - ); - } - Err(e) => { - handle_discovery_error( - e, - pyproject_toml, - TaskDefinitionType::PyprojectToml, - discovered, - ); - } - } - - Ok(()) -} - -fn discover_taskfile_tasks(dir: &Path, discovered: &mut DiscoveredTasks) -> Result<(), String> { - let default_path = dir.join(parse_taskfile::SUPPORTED_TASKFILE_NAMES[0]); - let Some(taskfile_path) = parse_taskfile::find_taskfile_in_dir(dir) else { - discovered.definitions.taskfile = Some(TaskDefinitionFile { - path: default_path, - definition_type: TaskDefinitionType::Taskfile, - status: TaskFileStatus::NotFound, - }); - return Ok(()); - }; - - let root_source = ComposedDefinitionSource::direct(taskfile_path.clone()); - let mut traversal_state = RecursiveDiscoveryState::new(); - let mut seen_task_names = std::collections::HashSet::new(); - let mut tasks = Vec::new(); - let mut include_errors = Vec::new(); - let no_excludes = std::collections::HashSet::new(); - let mut traversal = TaskfileTraversal { - root_taskfile_path: &taskfile_path, - traversal_state: &mut traversal_state, - seen_task_names: &mut seen_task_names, - collected_tasks: &mut tasks, - include_errors: &mut include_errors, - }; - - let result = collect_taskfile_tasks_recursive( - &root_source, - "", - None, - false, - &no_excludes, - &mut traversal, - ); - - for task in &mut tasks { - task.shadowed_by = check_shadowing(&task.name); - } - - discovered.tasks.extend(tasks); - discovered.errors.extend(include_errors); - - let status = match result { - Ok(()) => TaskFileStatus::Parsed, - Err(error) => { - discovered.errors.push(format!( - "Failed to parse {}: {}", - taskfile_path.display(), - error - )); - TaskFileStatus::ParseError(error) - } - }; - discovered.definitions.taskfile = Some(TaskDefinitionFile { - path: taskfile_path, - definition_type: TaskDefinitionType::Taskfile, - status, - }); - - Ok(()) -} - -struct TaskfileTraversal<'a> { - root_taskfile_path: &'a Path, - traversal_state: &'a mut RecursiveDiscoveryState, - seen_task_names: &'a mut std::collections::HashSet, - collected_tasks: &'a mut Vec, - include_errors: &'a mut Vec, -} - -fn collect_taskfile_tasks_recursive( - current_source: &ComposedDefinitionSource, - namespace_prefix: &str, - include_label: Option<&str>, - hide_tasks: bool, - excluded_tasks: &std::collections::HashSet, - traversal: &mut TaskfileTraversal<'_>, -) -> Result<(), String> { - match traversal - .traversal_state - .mark_visited(current_source.definition_path()) - { - VisitState::AlreadyVisited(_) => return Ok(()), - VisitState::New(_) => {} - } - - let mut first_error: Option = None; - - let mut tasks = parse_taskfile::parse(current_source.definition_path())?; - tasks.sort_by(|a, b| a.name.cmp(&b.name)); - - if !hide_tasks { - for mut task in tasks { - let original_name = task.name.clone(); - if excluded_tasks.contains(&original_name) { - continue; - } - - let effective_name = prefix_taskfile_task_name(namespace_prefix, &original_name); - task.name = effective_name.clone(); - task.source_name = effective_name; - current_source.apply_to_task(&mut task); - - if !traversal.seen_task_names.insert(task.name.clone()) { - let error = match include_label { - Some(include_label) => { - format!( - "Found multiple tasks ({}) included by \"{}\"", - task.name, include_label - ) - } - None => format!("Found multiple Taskfile tasks named '{}'", task.name), - }; - traversal.include_errors.push(error.clone()); - if first_error.is_none() { - first_error = Some(error); - } - continue; - } - - traversal.collected_tasks.push(task); - } - } - - let includes = parse_taskfile::extract_include_directives(current_source.definition_path())?; - - for include in includes { - let resolved_candidate = current_source.resolve_child(&include.taskfile); - let resolved_include = parse_taskfile::resolve_taskfile_include_path(&resolved_candidate); - - if !resolved_include.is_file() { - continue; - } - - let child_source = ComposedDefinitionSource::composed( - traversal.root_taskfile_path, - resolved_include.clone(), - ); - let child_namespace = if include.flatten { - namespace_prefix.to_string() - } else { - prefix_taskfile_task_name(namespace_prefix, &include.namespace) - }; - let child_include_label = prefix_taskfile_task_name(namespace_prefix, &include.namespace); - let child_hide_tasks = hide_tasks || include.internal; - let child_excludes = include.excludes.into_iter().collect(); - - if let Err(error) = collect_taskfile_tasks_recursive( - &child_source, - &child_namespace, - Some(child_include_label.as_str()), - child_hide_tasks, - &child_excludes, - traversal, - ) { - let error = format!( - "Failed to parse included Taskfile '{}': {}", - resolved_include.display(), - error - ); - traversal.include_errors.push(error.clone()); - if first_error.is_none() { - first_error = Some(error); - } - } - } - - if let Some(error) = first_error { - Err(error) - } else { - Ok(()) - } -} - -fn prefix_taskfile_task_name(namespace_prefix: &str, task_name: &str) -> String { - if namespace_prefix.is_empty() { - task_name.to_string() - } else { - format!("{}:{}", namespace_prefix, task_name) - } -} - -fn discover_turbo_tasks(dir: &Path, discovered: &mut DiscoveredTasks) -> Result<(), String> { - let repo_root = find_git_repo_root(dir).unwrap_or_else(|| dir.to_path_buf()); - let turbo_json = repo_root.join("turbo.json"); - - if !turbo_json.exists() { - discovered.definitions.turbo_json = Some(TaskDefinitionFile { - path: turbo_json, - definition_type: TaskDefinitionType::TurboJson, - status: TaskFileStatus::NotFound, - }); - return Ok(()); - } - - let mut tasks_by_name = BTreeMap::new(); - let mut config_errors = Vec::new(); - - let result = collect_turbo_tasks_for_context( - &repo_root, - dir, - &turbo_json, - &mut tasks_by_name, - &mut config_errors, - ); - - let mut tasks: Vec<_> = tasks_by_name.into_values().collect(); - for task in &mut tasks { - task.shadowed_by = check_shadowing(&task.name); - } - - discovered.tasks.extend(tasks); - discovered.errors.extend(config_errors); - - let status = match result { - Ok(()) => TaskFileStatus::Parsed, - Err(error) => { - discovered.errors.push(format!( - "Failed to parse {}: {}", - turbo_json.display(), - error - )); - TaskFileStatus::ParseError(error) - } - }; - discovered.definitions.turbo_json = Some(TaskDefinitionFile { - path: turbo_json, - definition_type: TaskDefinitionType::TurboJson, - status, - }); - - Ok(()) -} - -fn collect_turbo_tasks_for_context( - repo_root: &Path, - dir: &Path, - root_turbo_json: &Path, - collected_tasks: &mut BTreeMap, - config_errors: &mut Vec, -) -> Result<(), String> { - let root_source = ComposedDefinitionSource::direct(root_turbo_json.to_path_buf()); - let mut package_configs_by_name = None; - let root_tasks = resolve_effective_turbo_tasks( - &root_source, - repo_root, - root_turbo_json, - &mut package_configs_by_name, - &mut RecursiveDiscoveryState::new(), - )?; - collected_tasks.extend(root_tasks); - - let mut first_error = None; - - if dir == repo_root { - for config_path in collect_descendant_turbo_config_paths(repo_root) { - let config_source = - ComposedDefinitionSource::composed(root_turbo_json, config_path.clone()); - match resolve_effective_turbo_tasks( - &config_source, - repo_root, - root_turbo_json, - &mut package_configs_by_name, - &mut RecursiveDiscoveryState::new(), - ) { - Ok(tasks) => { - for (name, task) in tasks { - collected_tasks.entry(name).or_insert(task); - } - } - Err(error) => { - let error = format!( - "Failed to parse workspace-local turbo config '{}': {}", - config_path.display(), - error - ); - config_errors.push(error.clone()); - if first_error.is_none() { - first_error = Some(error); - } - } - } - } - } else { - for config_path in collect_turbo_ancestor_config_paths(dir, repo_root) { - let config_source = - ComposedDefinitionSource::composed(root_turbo_json, config_path.clone()); - match resolve_effective_turbo_tasks( - &config_source, - repo_root, - root_turbo_json, - &mut package_configs_by_name, - &mut RecursiveDiscoveryState::new(), - ) { - Ok(tasks) if !tasks.is_empty() => { - *collected_tasks = tasks; - break; - } - Ok(_) => {} - Err(error) => { - let error = format!( - "Failed to parse workspace-local turbo config '{}': {}", - config_path.display(), - error - ); - config_errors.push(error.clone()); - if first_error.is_none() { - first_error = Some(error); - } - break; - } - } - } - } - - if let Some(error) = first_error { - Err(error) - } else { - Ok(()) - } -} - -fn resolve_effective_turbo_tasks( - current_source: &ComposedDefinitionSource, - repo_root: &Path, - root_turbo_json: &Path, - package_configs_by_name: &mut Option>, - traversal_state: &mut RecursiveDiscoveryState, -) -> Result, String> { - match traversal_state.mark_visited(current_source.definition_path()) { - VisitState::AlreadyVisited(_) => return Ok(BTreeMap::new()), - VisitState::New(_) => {} - } - - let config = parse_turbo_json::load_config(current_source.definition_path())?; - - if current_source.definition_path() != root_turbo_json && config.extends.is_empty() { - return Ok(BTreeMap::new()); - } - - let mut tasks = BTreeMap::new(); - - if current_source.definition_path() != root_turbo_json { - for extend_entry in &config.extends { - let Some(parent_config_path) = resolve_turbo_extends_entry( - current_source, - extend_entry, - repo_root, - root_turbo_json, - package_configs_by_name, - ) else { - continue; - }; - - if !parent_config_path.is_file() { - continue; - } - - let parent_source = - ComposedDefinitionSource::composed(root_turbo_json, parent_config_path.clone()); - let inherited_tasks = resolve_effective_turbo_tasks( - &parent_source, - repo_root, - root_turbo_json, - package_configs_by_name, - traversal_state, - )?; - tasks.extend(inherited_tasks); - } - } - - for (name, task_config) in &config.tasks { - if !task_config.is_effective_task_definition() { - tasks.remove(name.as_str()); - } - } - - let mut local_tasks = parse_turbo_json::parse(current_source.definition_path())?; - for task in &mut local_tasks { - current_source.apply_to_task(task); - } - for task in local_tasks { - tasks.insert(task.name.clone(), task); - } - - Ok(tasks) -} - -fn resolve_turbo_extends_entry( - current_source: &ComposedDefinitionSource, - extend_entry: &str, - repo_root: &Path, - root_turbo_json: &Path, - package_configs_by_name: &mut Option>, -) -> Option { - if extend_entry == "//" { - return Some(root_turbo_json.to_path_buf()); - } - - if looks_like_turbo_config_path(extend_entry) { - let candidate = current_source.resolve_child(extend_entry); - return Some(resolve_turbo_config_path_candidate(&candidate)); - } - - let package_configs_by_name = - package_configs_by_name.get_or_insert_with(|| build_turbo_package_config_index(repo_root)); - package_configs_by_name.get(extend_entry).cloned() -} - -fn collect_turbo_ancestor_config_paths(dir: &Path, repo_root: &Path) -> Vec { - let mut current = dir.to_path_buf(); - let mut config_paths = Vec::new(); - - while current.starts_with(repo_root) && current != repo_root { - let candidate = current.join("turbo.json"); - if candidate.is_file() { - config_paths.push(candidate); - } - - if !current.pop() { - break; - } - } - - config_paths -} - -fn collect_descendant_turbo_config_paths(repo_root: &Path) -> Vec { - let mut config_paths = Vec::new(); - collect_descendant_turbo_config_paths_recursive(repo_root, repo_root, &mut config_paths); - config_paths.sort(); - config_paths -} - -fn collect_descendant_turbo_config_paths_recursive( - repo_root: &Path, - current_dir: &Path, - config_paths: &mut Vec, -) { - let Ok(entries) = fs::read_dir(current_dir) else { - return; - }; - - for entry in entries.flatten() { - let Ok(file_type) = entry.file_type() else { - continue; - }; - if file_type.is_symlink() || !file_type.is_dir() { - continue; - } - - let path = entry.path(); - - let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else { - continue; - }; - if should_skip_turbo_config_scan(file_name) { - continue; - } - - let candidate = path.join("turbo.json"); - if candidate.is_file() && candidate != repo_root.join("turbo.json") { - config_paths.push(candidate); - } - - collect_descendant_turbo_config_paths_recursive(repo_root, &path, config_paths); - } -} - -fn should_skip_turbo_config_scan(file_name: &str) -> bool { - matches!(file_name, ".git" | "node_modules") -} - -fn looks_like_turbo_config_path(extend_entry: &str) -> bool { - let extend_path = Path::new(extend_entry); - extend_path.is_absolute() - || extend_entry.starts_with('.') - || extend_entry.contains(std::path::MAIN_SEPARATOR) - || extend_entry.contains('/') - || extend_entry.contains('\\') -} - -fn resolve_turbo_config_path_candidate(candidate: &Path) -> PathBuf { - if candidate - .file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name == "turbo.json") - { - return candidate.to_path_buf(); - } - - if candidate.extension().is_none() || candidate.is_dir() { - return candidate.join("turbo.json"); - } - - candidate.to_path_buf() -} - -fn build_turbo_package_config_index(repo_root: &Path) -> HashMap { - let mut package_configs = HashMap::new(); - - let root_turbo_json = repo_root.join("turbo.json"); - if root_turbo_json.is_file() - && let Some(package_name) = read_package_name(repo_root) - { - package_configs.insert(package_name, root_turbo_json); - } - - for config_path in collect_descendant_turbo_config_paths(repo_root) { - let Some(config_dir) = config_path.parent() else { - continue; - }; - let Some(package_name) = read_package_name(config_dir) else { - continue; - }; - package_configs.entry(package_name).or_insert(config_path); - } - - package_configs -} - -fn read_package_name(dir: &Path) -> Option { - let package_json_path = dir.join("package.json"); - let contents = fs::read_to_string(package_json_path).ok()?; - let json: serde_json::Value = serde_json::from_str(&contents).ok()?; - json.get("name") - .and_then(serde_json::Value::as_str) - .map(str::to_string) -} - -fn discover_maven_tasks(dir: &Path, discovered: &mut DiscoveredTasks) -> Result<(), String> { - let pom_path = dir.join("pom.xml"); - if !pom_path.exists() { - return Ok(()); - } - - match parse_pom_xml(&pom_path) { - Ok(tasks) => { - handle_discovery_success( - tasks, - pom_path.clone(), - TaskDefinitionType::MavenPom, - discovered, - ); - Ok(()) - } - Err(e) => { - handle_discovery_error(e, pom_path, TaskDefinitionType::MavenPom, discovered); - Err("Error parsing pom.xml".to_string()) - } - } -} - -/// Discover Gradle tasks from build.gradle or build.gradle.kts -fn discover_gradle_tasks(dir: &Path, discovered: &mut DiscoveredTasks) -> Result<(), String> { - // Check for build.gradle first - let build_gradle_path = dir.join("build.gradle"); - if build_gradle_path.exists() { - match parse_gradle::parse(&build_gradle_path) { - Ok(tasks) => { - handle_discovery_success( - tasks, - build_gradle_path.clone(), - TaskDefinitionType::Gradle, - discovered, - ); - return Ok(()); - } - Err(e) => { - handle_discovery_error( - e, - build_gradle_path, - TaskDefinitionType::Gradle, - discovered, - ); - return Err("Error parsing build.gradle".to_string()); - } - } - } - - // If no build.gradle, try build.gradle.kts - let build_gradle_kts_path = dir.join("build.gradle.kts"); - if build_gradle_kts_path.exists() { - match parse_gradle::parse(&build_gradle_kts_path) { - Ok(tasks) => { - handle_discovery_success( - tasks, - build_gradle_kts_path.clone(), - TaskDefinitionType::Gradle, - discovered, - ); - Ok(()) - } - Err(e) => { - handle_discovery_error( - e, - build_gradle_kts_path, - TaskDefinitionType::Gradle, - discovered, - ); - Err("Error parsing build.gradle.kts".to_string()) - } - } - } else { - // No Gradle files found - discovered.definitions.gradle = Some(TaskDefinitionFile { - path: build_gradle_path, - definition_type: TaskDefinitionType::Gradle, - status: TaskFileStatus::NotFound, - }); - Ok(()) - } -} - -fn discover_github_actions_tasks( - dir: &Path, - discovered: &mut DiscoveredTasks, -) -> Result<(), String> { - let mut workflow_files = Vec::new(); - - // 1. Check .github/workflows/ (standard location) - let workflows_dir = dir.join(".github").join("workflows"); - if workflows_dir.exists() && workflows_dir.is_dir() { - match fs::read_dir(&workflows_dir) { - Ok(entries) => { - // Find all workflow files (*.yml, *.yaml) in the standard directory - let files: Vec = entries - .filter_map(Result::ok) - .map(|entry| entry.path()) - .filter(|path| { - if let Some(ext) = path.extension() { - ext == "yml" || ext == "yaml" - } else { - false - } - }) - .collect(); - workflow_files.extend(files); - } - Err(e) => { - discovered - .errors - .push(format!("Failed to read .github/workflows directory: {}", e)); - } - } - } - - // 2. Check root directory for workflow.yml or .github/workflow.yml - for filename in &[ - "workflow.yml", - "workflow.yaml", - ".github/workflow.yml", - ".github/workflow.yaml", - ] { - let file_path = dir.join(filename); - if file_path.exists() && file_path.is_file() { - workflow_files.push(file_path); - } - } - - // 3. Check custom directories that might contain workflows - for custom_dir in &["workflows", "custom/workflows", ".gitlab/workflows"] { - let custom_path = dir.join(custom_dir); - if custom_path.exists() - && custom_path.is_dir() - && let Ok(entries) = fs::read_dir(&custom_path) - { - let files: Vec = entries - .filter_map(Result::ok) - .map(|entry| entry.path()) - .filter(|path| { - if let Some(ext) = path.extension() { - ext == "yml" || ext == "yaml" - } else { - false - } - }) - .collect(); - workflow_files.extend(files); - } - } - - if workflow_files.is_empty() { - return Ok(()); - } - - // Parse all the found workflow files - let mut all_tasks = Vec::new(); - let mut errors = Vec::new(); - - // Create a common parent directory for all workflows - let workflows_parent = dir.join(".github").join("workflows"); - - for file_path in workflow_files { - match parse_github_actions(&file_path) { - Ok(mut tasks) => { - let source = - ComposedDefinitionSource::composed(workflows_parent.clone(), file_path); - for task in &mut tasks { - source.apply_to_task(task); - task.shadowed_by = check_shadowing(&task.name); - } - all_tasks.extend(tasks); - } - Err(e) => errors.push(format!( - "Failed to parse workflow file {:?}: {}", - file_path, e - )), - } - } - - if !errors.is_empty() { - discovered.errors.extend(errors); - } - - if !all_tasks.is_empty() { - discovered.definitions.github_actions = Some(TaskDefinitionFile { - path: workflows_parent, - definition_type: TaskDefinitionType::GitHubActions, - status: TaskFileStatus::Parsed, - }); - discovered.tasks.extend(all_tasks); - } - - Ok(()) -} - -fn discover_docker_compose_tasks( - dir: &Path, - discovered: &mut DiscoveredTasks, -) -> Result<(), String> { - // Find all possible Docker Compose files - let docker_compose_files = parse_docker_compose::find_docker_compose_files(dir); - - if docker_compose_files.is_empty() { - // No Docker Compose files found, mark as not found - let default_path = dir.join("docker-compose.yml"); - discovered.definitions.docker_compose = Some(TaskDefinitionFile { - path: default_path, - definition_type: TaskDefinitionType::DockerCompose, - status: TaskFileStatus::NotFound, - }); - return Ok(()); - } - - // Use the first found file (priority order: docker-compose.yml > docker-compose.yaml > compose.yml > compose.yaml) - let docker_compose_path = &docker_compose_files[0]; +mod cmake; +mod disambiguation; +mod docker_compose; +mod github_actions; +mod gradle; +mod justfile; +mod make; +mod maven; +mod npm; +mod python; +mod registry; +mod shell_scripts; +mod support; +mod taskfile; +mod travis_ci; +mod turbo; + +use crate::types::{Task, TaskDefinitionFile}; +use std::collections::HashMap; +use std::path::Path; - match parse_docker_compose::parse(docker_compose_path) { - Ok(tasks) => { - handle_discovery_success( - tasks, - docker_compose_path.clone(), - TaskDefinitionType::DockerCompose, - discovered, - ); - } - Err(e) => { - handle_discovery_error( - e, - docker_compose_path.clone(), - TaskDefinitionType::DockerCompose, - discovered, - ); - } - } +pub use disambiguation::{ + format_ambiguous_task_error, get_matching_tasks, is_task_ambiguous, process_task_disambiguation, +}; - Ok(()) +#[derive(Debug, Clone, Default)] +pub struct DiscoveredTaskDefinitions { + pub makefile: Option, + pub package_json: Option, + pub pyproject_toml: Option, + pub taskfile: Option, + pub turbo_json: Option, + pub maven_pom: Option, + pub gradle: Option, + pub github_actions: Option, + pub docker_compose: Option, + pub travis_ci: Option, + pub cmake: Option, + pub justfile: Option, } -fn discover_travis_ci_tasks(dir: &Path, discovered: &mut DiscoveredTasks) -> Result<(), String> { - let travis_ci_path = dir.join(".travis.yml"); - - if travis_ci_path.exists() { - match parse_travis_ci(&travis_ci_path) { - Ok(tasks) => { - handle_discovery_success( - tasks, - travis_ci_path.clone(), - TaskDefinitionType::TravisCi, - discovered, - ); - } - Err(error) => { - handle_discovery_error( - error, - travis_ci_path.clone(), - TaskDefinitionType::TravisCi, - discovered, - ); - } - } - } else { - set_definition( - discovered, - TaskDefinitionFile { - path: travis_ci_path, - definition_type: TaskDefinitionType::TravisCi, - status: TaskFileStatus::NotFound, - }, - ); - } - - Ok(()) +#[derive(Debug, Clone, Default)] +pub struct DiscoveredTasks { + pub definitions: DiscoveredTaskDefinitions, + pub tasks: Vec, + pub errors: Vec, + pub task_name_counts: HashMap, } -fn discover_cmake_tasks(dir: &Path, discovered: &mut DiscoveredTasks) -> Result<(), String> { - let cmake_path = dir.join("CMakeLists.txt"); - if !cmake_path.exists() { - return Ok(()); +impl DiscoveredTasks { + #[cfg(test)] + pub fn new() -> Self { + Self::default() } - match parse_cmake::parse(&cmake_path) { - Ok(tasks) => { - handle_discovery_success( - tasks, - cmake_path.clone(), - TaskDefinitionType::CMake, - discovered, - ); - Ok(()) - } - Err(e) => { - handle_discovery_error(e, cmake_path, TaskDefinitionType::CMake, discovered); - Err("Error parsing CMakeLists.txt".to_string()) - } + #[cfg(test)] + pub fn add_task(&mut self, task: Task) { + *self.task_name_counts.entry(task.name.clone()).or_insert(0) += 1; + self.tasks.push(task); } } -fn discover_justfile_tasks(dir: &Path, discovered: &mut DiscoveredTasks) -> Result<(), String> { - // List of possible Justfile paths in order of priority - let possible_justfiles = ["Justfile", "justfile", ".justfile"]; - - // Try to find the first existing Justfile - let mut justfile_path = None; - for filename in &possible_justfiles { - let path = dir.join(filename); - if path.exists() { - justfile_path = Some(path); - break; - } - } +pub(crate) trait TaskDiscovery { + fn discover(&self, dir: &Path, discovered: &mut DiscoveredTasks); +} - // Use a default path for reporting if no Justfile was found - let default_path = dir.join("Justfile"); +pub fn discover_tasks(dir: &Path) -> DiscoveredTasks { + let mut discovered = DiscoveredTasks::default(); - // If a Justfile was found, parse it - if let Some(justfile_path) = justfile_path { - match parse_justfile::parse(&justfile_path) { - Ok(tasks) => { - handle_discovery_success( - tasks, - justfile_path, - TaskDefinitionType::Justfile, - discovered, - ); - } - Err(e) => { - handle_discovery_error(e, justfile_path, TaskDefinitionType::Justfile, discovered); - } - } - } else { - // No Justfile found, set status as NotFound - discovered.definitions.justfile = Some(TaskDefinitionFile { - path: default_path, - definition_type: TaskDefinitionType::Justfile, - status: TaskFileStatus::NotFound, - }); + for discoverer in registry::registered_discoveries() { + discoverer.discover(dir, &mut discovered); } - Ok(()) -} - -fn discover_shell_script_tasks(dir: &Path, discovered: &mut DiscoveredTasks) { - if let Ok(entries) = fs::read_dir(dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.is_file() - && let Some(extension) = path.extension() - && extension == "sh" - { - // Use file stem (without extension) for task name and disambiguation - let name = path - .file_stem() - .unwrap_or_default() - .to_string_lossy() - .to_string(); - // Use full filename (with extension) for source_name since we execute ./script.sh - let source_name = path - .file_name() - .unwrap_or_default() - .to_string_lossy() - .to_string(); - discovered.tasks.push(Task { - name: name.clone(), - file_path: path, - definition_path: None, - definition_type: TaskDefinitionType::ShellScript, - runner: TaskRunner::ShellScript, - source_name, - description: None, - shadowed_by: check_shadowing(&name), - disambiguated_name: None, - }); - } - } - } + process_task_disambiguation(&mut discovered); + discovered } #[cfg(test)] mod tests { use super::*; use crate::environment::{TestEnvironment, reset_to_real_environment, set_test_environment}; + use crate::parsers::parse_package_json; use crate::task_shadowing::{enable_mock, mock_executable, reset_mock}; - use crate::types::ShadowType; + use crate::types::{ShadowType, TaskDefinitionType, TaskFileStatus, TaskRunner}; use serial_test::serial; use std::fs::File; use std::io::Write; + use std::path::{Path, PathBuf}; use tempfile::TempDir; type ExecuteFn = Box Result<(), String>>; @@ -1479,6 +171,11 @@ mod tests { writeln!(file, "{}", content).unwrap(); } + fn create_named_makefile(dir: &Path, name: &str, content: &str) { + let mut file = File::create(dir.join(name)).unwrap(); + writeln!(file, "{}", content).unwrap(); + } + fn create_test_turbo_json(dir: &Path, content: &str) { std::fs::create_dir_all(dir).unwrap(); std::fs::write(dir.join("turbo.json"), content).unwrap(); @@ -1551,14 +248,14 @@ test: assert!(discovered.errors.is_empty()); // Check Makefile status - assert!(matches!( - discovered.definitions.makefile.unwrap().status, - TaskFileStatus::Parsed - )); + let makefile_def = discovered.definitions.makefile.as_ref().unwrap(); + assert!(matches!(makefile_def.status, TaskFileStatus::Parsed)); + assert_eq!(makefile_def.path, temp_dir.path().join("Makefile")); // Verify tasks let build_task = discovered.tasks.iter().find(|t| t.name == "build").unwrap(); assert_eq!(build_task.runner, TaskRunner::Make); + assert_eq!(build_task.file_path, temp_dir.path().join("Makefile")); assert_eq!( build_task.description, Some("Building the project".to_string()) @@ -1569,6 +266,96 @@ test: assert_eq!(test_task.description, Some("Running tests".to_string())); } + #[test] + fn test_discover_tasks_with_lowercase_makefile() { + let temp_dir = TempDir::new().unwrap(); + create_named_makefile( + temp_dir.path(), + "makefile", + r#"build: + @echo "Building from makefile""#, + ); + + let discovered = discover_tasks(temp_dir.path()); + + assert!(matches!( + discovered.definitions.makefile.as_ref().unwrap().status, + TaskFileStatus::Parsed + )); + assert_eq!( + discovered.definitions.makefile.as_ref().unwrap().path, + temp_dir.path().join("makefile") + ); + assert_eq!(discovered.tasks.len(), 1); + assert_eq!( + discovered.tasks[0].file_path, + temp_dir.path().join("makefile") + ); + } + + #[test] + fn test_discover_tasks_with_gnumakefile() { + let temp_dir = TempDir::new().unwrap(); + create_named_makefile( + temp_dir.path(), + "GNUmakefile", + r#"build: + @echo "Building from GNUmakefile""#, + ); + + let discovered = discover_tasks(temp_dir.path()); + + assert!(matches!( + discovered.definitions.makefile.as_ref().unwrap().status, + TaskFileStatus::Parsed + )); + assert_eq!( + discovered.definitions.makefile.as_ref().unwrap().path, + temp_dir.path().join("GNUmakefile") + ); + assert_eq!(discovered.tasks.len(), 1); + assert_eq!( + discovered.tasks[0].file_path, + temp_dir.path().join("GNUmakefile") + ); + } + + #[test] + fn test_discover_tasks_prefers_gnumakefile_over_makefile() { + let temp_dir = TempDir::new().unwrap(); + create_named_makefile( + temp_dir.path(), + "Makefile", + r#"from_makefile: + @echo "From Makefile""#, + ); + create_named_makefile( + temp_dir.path(), + "GNUmakefile", + r#"from_gnumakefile: + @echo "From GNUmakefile""#, + ); + + let discovered = discover_tasks(temp_dir.path()); + + assert_eq!( + discovered.definitions.makefile.as_ref().unwrap().path, + temp_dir.path().join("GNUmakefile") + ); + assert!( + discovered + .tasks + .iter() + .any(|task| task.name == "from_gnumakefile") + ); + assert!( + !discovered + .tasks + .iter() + .any(|task| task.name == "from_makefile") + ); + } + #[test] fn test_discover_tasks_with_included_makefiles() { let temp_dir = TempDir::new().unwrap(); @@ -1768,6 +555,76 @@ build: assert!(discovered.tasks.iter().any(|t| t.name == "build")); } + #[test] + fn test_discover_tasks_with_invalid_included_makefile_keeps_root_tasks() { + let temp_dir = TempDir::new().unwrap(); + let included_dir = temp_dir.path().join("mk"); + std::fs::create_dir_all(&included_dir).unwrap(); + + create_test_makefile( + temp_dir.path(), + r#"include mk/common.mk + +build: + @echo "Build from root""#, + ); + std::fs::write( + included_dir.join("common.mk"), + "not a make file", + ) + .unwrap(); + + let discovered = discover_tasks(temp_dir.path()); + + assert!(matches!( + discovered.definitions.makefile.unwrap().status, + TaskFileStatus::Parsed + )); + assert_eq!(discovered.tasks.len(), 1); + assert!(discovered.tasks.iter().any(|t| t.name == "build")); + assert_eq!(discovered.errors.len(), 1); + assert!(discovered.errors[0].contains("mk/common.mk")); + } + + #[test] + fn test_discover_tasks_skips_broken_include_and_continues() { + let temp_dir = TempDir::new().unwrap(); + let included_dir = temp_dir.path().join("mk"); + std::fs::create_dir_all(&included_dir).unwrap(); + + create_test_makefile( + temp_dir.path(), + r#"include mk/broken.mk +include mk/valid.mk"#, + ); + std::fs::write( + included_dir.join("broken.mk"), + "not a make file", + ) + .unwrap(); + std::fs::write( + included_dir.join("valid.mk"), + r#"test: + @echo "Test from valid include""#, + ) + .unwrap(); + + let discovered = discover_tasks(temp_dir.path()); + + assert!(matches!( + discovered.definitions.makefile.unwrap().status, + TaskFileStatus::Parsed + )); + assert_eq!(discovered.tasks.len(), 1); + let task = discovered.tasks.iter().find(|t| t.name == "test").unwrap(); + assert_eq!( + task.definition_path(), + included_dir.join("valid.mk").as_path() + ); + assert_eq!(discovered.errors.len(), 1); + assert!(discovered.errors[0].contains("mk/broken.mk")); + } + #[test] fn test_discover_tasks_finds_turbo_json_at_git_repo_root() { let temp_dir = TempDir::new().unwrap(); @@ -3253,6 +2110,41 @@ jobs: ); } + #[test] + #[serial] + fn test_get_matching_tasks_treats_alias_collision_as_ambiguous() { + let mut discovered = DiscoveredTasks::default(); + + discovered.tasks.push(Task { + name: "test".to_string(), + file_path: PathBuf::from("/test/Makefile"), + definition_path: None, + definition_type: TaskDefinitionType::Makefile, + runner: TaskRunner::Make, + source_name: "test".to_string(), + description: None, + shadowed_by: Some(ShadowType::PathExecutable("/bin/test".to_string())), + disambiguated_name: Some("test-m".to_string()), + }); + discovered.tasks.push(Task { + name: "test-m".to_string(), + file_path: PathBuf::from("/test/package.json"), + definition_path: None, + definition_type: TaskDefinitionType::PackageJson, + runner: TaskRunner::NodeNpm, + source_name: "test-m".to_string(), + description: None, + shadowed_by: None, + disambiguated_name: None, + }); + + let matching_tasks = get_matching_tasks(&discovered, "test-m"); + + assert_eq!(matching_tasks.len(), 2); + assert!(matching_tasks.iter().any(|task| task.name == "test")); + assert!(matching_tasks.iter().any(|task| task.name == "test-m")); + } + #[test] fn test_execute_task_with_disambiguated_name() { let mut discovered_tasks = DiscoveredTasks::new(); @@ -3400,6 +2292,43 @@ jobs: assert!(err_msg.contains("Ambiguous")); } + #[test] + fn test_execute_task_alias_collision_is_ambiguous() { + let mut discovered_tasks = DiscoveredTasks::new(); + + discovered_tasks.add_task(Task { + name: "test".to_string(), + file_path: PathBuf::from("/path/to/Makefile"), + definition_path: None, + definition_type: TaskDefinitionType::Makefile, + runner: TaskRunner::Make, + source_name: "test".to_string(), + description: None, + shadowed_by: Some(ShadowType::PathExecutable("/bin/test".to_string())), + disambiguated_name: Some("test-m".to_string()), + }); + discovered_tasks.add_task(Task { + name: "test-m".to_string(), + file_path: PathBuf::from("/path/to/package.json"), + definition_path: None, + definition_type: TaskDefinitionType::PackageJson, + runner: TaskRunner::NodeNpm, + source_name: "test-m".to_string(), + description: None, + shadowed_by: None, + disambiguated_name: None, + }); + + let mut executor = CommandExecutor::new(MockTaskExecutor::new()); + let result = executor.execute_task_by_name(&mut discovered_tasks, "test-m", &[]); + + assert!(result.is_err()); + let err_msg = result.unwrap_err(); + assert!(err_msg.contains("Ambiguous task name: 'test-m'")); + assert!(err_msg.contains(" • test-m (make from /path/to/Makefile)")); + assert!(err_msg.contains(" • test-m (npm from /path/to/package.json)")); + } + #[test] fn test_discover_taskfile_variants() { let temp_dir = TempDir::new().unwrap(); @@ -3870,6 +2799,18 @@ add_custom_target(build-all assert!(matches!(cmake_def.status, TaskFileStatus::Parsed)); } + #[test] + fn test_discover_cmake_tasks_not_found() { + let temp_dir = TempDir::new().unwrap(); + + let discovered = discover_tasks(temp_dir.path()); + + let cmake_def = discovered.definitions.cmake.as_ref().unwrap(); + assert_eq!(cmake_def.path, temp_dir.path().join("CMakeLists.txt")); + assert_eq!(cmake_def.definition_type, TaskDefinitionType::CMake); + assert!(matches!(cmake_def.status, TaskFileStatus::NotFound)); + } + #[test] fn test_discover_justfile_variants() { let temp_dir = TempDir::new().unwrap(); diff --git a/src/task_discovery/cmake.rs b/src/task_discovery/cmake.rs new file mode 100644 index 0000000..488f79f --- /dev/null +++ b/src/task_discovery/cmake.rs @@ -0,0 +1,41 @@ +use crate::parsers::parse_cmake; +use crate::task_discovery::support::{ + handle_discovery_error, handle_discovery_success, set_definition, +}; +use crate::task_discovery::{DiscoveredTasks, TaskDiscovery}; +use crate::types::{TaskDefinitionFile, TaskDefinitionType, TaskFileStatus}; +use std::path::Path; + +pub(crate) struct CmakeDiscovery; + +impl TaskDiscovery for CmakeDiscovery { + fn discover(&self, dir: &Path, discovered: &mut DiscoveredTasks) { + let _ = discover_cmake_tasks(dir, discovered); + } +} + +fn discover_cmake_tasks(dir: &Path, discovered: &mut DiscoveredTasks) -> Result<(), String> { + let cmake_path = dir.join("CMakeLists.txt"); + if !cmake_path.exists() { + set_definition( + discovered, + TaskDefinitionFile { + path: cmake_path, + definition_type: TaskDefinitionType::CMake, + status: TaskFileStatus::NotFound, + }, + ); + return Ok(()); + } + + match parse_cmake::parse(&cmake_path) { + Ok(tasks) => { + handle_discovery_success(tasks, cmake_path, TaskDefinitionType::CMake, discovered); + Ok(()) + } + Err(error) => { + handle_discovery_error(error, cmake_path, TaskDefinitionType::CMake, discovered); + Err("Error parsing CMakeLists.txt".to_string()) + } + } +} diff --git a/src/task_discovery/disambiguation.rs b/src/task_discovery/disambiguation.rs new file mode 100644 index 0000000..0b387c5 --- /dev/null +++ b/src/task_discovery/disambiguation.rs @@ -0,0 +1,185 @@ +use crate::task_discovery::DiscoveredTasks; +use crate::types::{Task, TaskRunner}; +use std::collections::{HashMap, HashSet}; + +const MIN_PREFIX_LEN: usize = 3; + +pub fn process_task_disambiguation(discovered: &mut DiscoveredTasks) { + let mut task_name_counts: HashMap = HashMap::new(); + let mut tasks_by_name: HashMap> = HashMap::new(); + + for (index, task) in discovered.tasks.iter().enumerate() { + *task_name_counts.entry(task.name.clone()).or_insert(0) += 1; + tasks_by_name + .entry(task.name.clone()) + .or_default() + .push(index); + } + + discovered.task_name_counts = task_name_counts.clone(); + + for (name, count) in &task_name_counts { + if *count <= 1 { + continue; + } + + let task_indices = tasks_by_name + .get(name) + .expect("task collision indexes should exist"); + let mut used_prefixes = HashSet::new(); + + for &index in task_indices { + let task = &mut discovered.tasks[index]; + let runner_prefix = generate_runner_prefix(&task.runner, &used_prefixes); + used_prefixes.insert(runner_prefix.clone()); + task.disambiguated_name = Some(format!("{}-{}", task.name, runner_prefix)); + } + } + + for task in &mut discovered.tasks { + if task.disambiguated_name.is_some() { + continue; + } + + if task.shadowed_by.is_some() { + let runner_prefix = generate_runner_prefix(&task.runner, &HashSet::new()); + task.disambiguated_name = Some(format!("{}-{}", task.name, runner_prefix)); + } + } +} + +fn generate_runner_prefix(runner: &TaskRunner, used_prefixes: &HashSet) -> String { + let short_name = runner.short_name().to_lowercase(); + generate_prefix_from_short_name(&short_name, used_prefixes) +} + +fn generate_prefix_from_short_name(short_name: &str, used_prefixes: &HashSet) -> String { + let single_char = short_name + .chars() + .next() + .expect("runner short names are never empty") + .to_string(); + if !used_prefixes.contains(&single_char) { + return single_char; + } + + let short_name_len = short_name.chars().count(); + let prefix_length = std::cmp::min(MIN_PREFIX_LEN, short_name_len); + let mut prefix = short_name.chars().take(prefix_length).collect::(); + if !used_prefixes.contains(&prefix) { + return prefix; + } + + for length in (prefix_length + 1)..=short_name_len { + prefix = short_name.chars().take(length).collect::(); + if !used_prefixes.contains(&prefix) { + return prefix; + } + } + + let mut index = 1; + loop { + let numbered_prefix = format!("{}{}", short_name, index); + if !used_prefixes.contains(&numbered_prefix) { + return numbered_prefix; + } + index += 1; + } +} + +pub fn is_task_ambiguous(discovered: &DiscoveredTasks, task_name: &str) -> bool { + discovered + .task_name_counts + .get(task_name) + .is_some_and(|&count| count > 1) +} + +#[allow(dead_code)] +pub fn get_disambiguated_task_names(discovered: &DiscoveredTasks, task_name: &str) -> Vec { + discovered + .tasks + .iter() + .filter(|task| task.name == task_name) + .filter_map(|task| task.disambiguated_name.clone()) + .collect() +} + +pub fn get_matching_tasks<'a>(discovered: &'a DiscoveredTasks, task_name: &str) -> Vec<&'a Task> { + discovered + .tasks + .iter() + .filter(|task| { + task.name == task_name + || task + .disambiguated_name + .as_ref() + .is_some_and(|name| name == task_name) + }) + .collect() +} + +pub fn format_ambiguous_task_error(task_name: &str, matching_tasks: &[&Task]) -> String { + let mut message = format!("Multiple tasks named '{}' found. Use one of:\n", task_name); + + for task in matching_tasks { + let display_name = task.disambiguated_name.as_deref().unwrap_or(&task.name); + message.push_str(&format!( + " • {} ({} from {})\n", + display_name, + task.runner.short_name(), + task.definition_path().display() + )); + } + + message.push_str("Please use the specific task name with its suffix to disambiguate."); + message +} + +#[cfg(test)] +mod tests { + use super::{format_ambiguous_task_error, generate_prefix_from_short_name}; + use crate::types::{Task, TaskDefinitionType, TaskRunner}; + use std::collections::HashSet; + use std::path::PathBuf; + + #[test] + fn generate_prefix_handles_multibyte_runner_names() { + let used_prefixes = HashSet::from(["å".to_string(), "ång".to_string(), "ångs".to_string()]); + + assert_eq!( + generate_prefix_from_short_name("ångström", &used_prefixes), + "ångst".to_string() + ); + } + + #[test] + fn format_ambiguous_task_error_includes_tasks_without_disambiguated_names() { + let make_task = Task { + name: "test".to_string(), + file_path: PathBuf::from("/tmp/Makefile"), + definition_path: None, + definition_type: TaskDefinitionType::Makefile, + runner: TaskRunner::Make, + source_name: "test".to_string(), + description: None, + shadowed_by: None, + disambiguated_name: None, + }; + let npm_task = Task { + name: "test".to_string(), + file_path: PathBuf::from("/tmp/package.json"), + definition_path: None, + definition_type: TaskDefinitionType::PackageJson, + runner: TaskRunner::NodeNpm, + source_name: "test".to_string(), + description: None, + shadowed_by: None, + disambiguated_name: Some("test-npm".to_string()), + }; + + let error = format_ambiguous_task_error("test", &[&make_task, &npm_task]); + + assert!(error.contains(" • test (make from /tmp/Makefile)")); + assert!(error.contains(" • test-npm (npm from /tmp/package.json)")); + } +} diff --git a/src/task_discovery/docker_compose.rs b/src/task_discovery/docker_compose.rs new file mode 100644 index 0000000..4fbe826 --- /dev/null +++ b/src/task_discovery/docker_compose.rs @@ -0,0 +1,51 @@ +use crate::parsers::parse_docker_compose; +use crate::task_discovery::support::{handle_discovery_error, handle_discovery_success}; +use crate::task_discovery::{DiscoveredTasks, TaskDiscovery}; +use crate::types::{TaskDefinitionFile, TaskDefinitionType, TaskFileStatus}; +use std::path::Path; + +pub(crate) struct DockerComposeDiscovery; + +impl TaskDiscovery for DockerComposeDiscovery { + fn discover(&self, dir: &Path, discovered: &mut DiscoveredTasks) { + let _ = discover_docker_compose_tasks(dir, discovered); + } +} + +fn discover_docker_compose_tasks( + dir: &Path, + discovered: &mut DiscoveredTasks, +) -> Result<(), String> { + let docker_compose_files = parse_docker_compose::find_docker_compose_files(dir); + + if docker_compose_files.is_empty() { + discovered.definitions.docker_compose = Some(TaskDefinitionFile { + path: dir.join("docker-compose.yml"), + definition_type: TaskDefinitionType::DockerCompose, + status: TaskFileStatus::NotFound, + }); + return Ok(()); + } + + let docker_compose_path = docker_compose_files[0].clone(); + match parse_docker_compose::parse(&docker_compose_path) { + Ok(tasks) => { + handle_discovery_success( + tasks, + docker_compose_path, + TaskDefinitionType::DockerCompose, + discovered, + ); + } + Err(error) => { + handle_discovery_error( + error, + docker_compose_path, + TaskDefinitionType::DockerCompose, + discovered, + ); + } + } + + Ok(()) +} diff --git a/src/task_discovery/github_actions.rs b/src/task_discovery/github_actions.rs new file mode 100644 index 0000000..4805157 --- /dev/null +++ b/src/task_discovery/github_actions.rs @@ -0,0 +1,116 @@ +use crate::composed_paths::ComposedDefinitionSource; +use crate::parsers::parse_github_actions; +use crate::task_discovery::{DiscoveredTasks, TaskDiscovery}; +use crate::task_shadowing::check_shadowing; +use crate::types::{TaskDefinitionFile, TaskDefinitionType, TaskFileStatus}; +use std::fs; +use std::path::{Path, PathBuf}; + +pub(crate) struct GithubActionsDiscovery; + +impl TaskDiscovery for GithubActionsDiscovery { + fn discover(&self, dir: &Path, discovered: &mut DiscoveredTasks) { + let _ = discover_github_actions_tasks(dir, discovered); + } +} + +fn discover_github_actions_tasks( + dir: &Path, + discovered: &mut DiscoveredTasks, +) -> Result<(), String> { + let mut workflow_files = Vec::new(); + + let workflows_dir = dir.join(".github").join("workflows"); + if workflows_dir.exists() && workflows_dir.is_dir() { + match fs::read_dir(&workflows_dir) { + Ok(entries) => { + let files: Vec = entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.extension() + .is_some_and(|ext| ext == "yml" || ext == "yaml") + }) + .collect(); + workflow_files.extend(files); + } + Err(error) => { + discovered.errors.push(format!( + "Failed to read .github/workflows directory: {}", + error + )); + } + } + } + + for filename in &[ + "workflow.yml", + "workflow.yaml", + ".github/workflow.yml", + ".github/workflow.yaml", + ] { + let file_path = dir.join(filename); + if file_path.exists() && file_path.is_file() { + workflow_files.push(file_path); + } + } + + for custom_dir in &["workflows", "custom/workflows", ".gitlab/workflows"] { + let custom_path = dir.join(custom_dir); + if custom_path.exists() + && custom_path.is_dir() + && let Ok(entries) = fs::read_dir(&custom_path) + { + let files: Vec = entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.extension() + .is_some_and(|ext| ext == "yml" || ext == "yaml") + }) + .collect(); + workflow_files.extend(files); + } + } + + if workflow_files.is_empty() { + return Ok(()); + } + + let mut all_tasks = Vec::new(); + let mut errors = Vec::new(); + let workflows_parent = dir.join(".github").join("workflows"); + + for file_path in workflow_files { + match parse_github_actions(&file_path) { + Ok(mut tasks) => { + let source = + ComposedDefinitionSource::composed(workflows_parent.clone(), file_path); + for task in &mut tasks { + source.apply_to_task(task); + task.shadowed_by = check_shadowing(&task.name); + } + all_tasks.extend(tasks); + } + Err(error) => errors.push(format!( + "Failed to parse workflow file {:?}: {}", + file_path, error + )), + } + } + + if !errors.is_empty() { + discovered.errors.extend(errors); + } + + if !all_tasks.is_empty() { + discovered.definitions.github_actions = Some(TaskDefinitionFile { + path: workflows_parent, + definition_type: TaskDefinitionType::GitHubActions, + status: TaskFileStatus::Parsed, + }); + discovered.tasks.extend(all_tasks); + } + + Ok(()) +} diff --git a/src/task_discovery/gradle.rs b/src/task_discovery/gradle.rs new file mode 100644 index 0000000..7f9f986 --- /dev/null +++ b/src/task_discovery/gradle.rs @@ -0,0 +1,70 @@ +use crate::parsers::parse_gradle; +use crate::task_discovery::support::{handle_discovery_error, handle_discovery_success}; +use crate::task_discovery::{DiscoveredTasks, TaskDiscovery}; +use crate::types::{TaskDefinitionFile, TaskDefinitionType, TaskFileStatus}; +use std::path::Path; + +pub(crate) struct GradleDiscovery; + +impl TaskDiscovery for GradleDiscovery { + fn discover(&self, dir: &Path, discovered: &mut DiscoveredTasks) { + let _ = discover_gradle_tasks(dir, discovered); + } +} + +fn discover_gradle_tasks(dir: &Path, discovered: &mut DiscoveredTasks) -> Result<(), String> { + let build_gradle_path = dir.join("build.gradle"); + if build_gradle_path.exists() { + return match parse_gradle::parse(&build_gradle_path) { + Ok(tasks) => { + handle_discovery_success( + tasks, + build_gradle_path, + TaskDefinitionType::Gradle, + discovered, + ); + Ok(()) + } + Err(error) => { + handle_discovery_error( + error, + build_gradle_path, + TaskDefinitionType::Gradle, + discovered, + ); + Err("Error parsing build.gradle".to_string()) + } + }; + } + + let build_gradle_kts_path = dir.join("build.gradle.kts"); + if build_gradle_kts_path.exists() { + return match parse_gradle::parse(&build_gradle_kts_path) { + Ok(tasks) => { + handle_discovery_success( + tasks, + build_gradle_kts_path, + TaskDefinitionType::Gradle, + discovered, + ); + Ok(()) + } + Err(error) => { + handle_discovery_error( + error, + build_gradle_kts_path, + TaskDefinitionType::Gradle, + discovered, + ); + Err("Error parsing build.gradle.kts".to_string()) + } + }; + } + + discovered.definitions.gradle = Some(TaskDefinitionFile { + path: build_gradle_path, + definition_type: TaskDefinitionType::Gradle, + status: TaskFileStatus::NotFound, + }); + Ok(()) +} diff --git a/src/task_discovery/justfile.rs b/src/task_discovery/justfile.rs new file mode 100644 index 0000000..97f0207 --- /dev/null +++ b/src/task_discovery/justfile.rs @@ -0,0 +1,51 @@ +use crate::parsers::parse_justfile; +use crate::task_discovery::support::{handle_discovery_error, handle_discovery_success}; +use crate::task_discovery::{DiscoveredTasks, TaskDiscovery}; +use crate::types::{TaskDefinitionFile, TaskDefinitionType, TaskFileStatus}; +use std::path::Path; + +pub(crate) struct JustfileDiscovery; + +impl TaskDiscovery for JustfileDiscovery { + fn discover(&self, dir: &Path, discovered: &mut DiscoveredTasks) { + let _ = discover_justfile_tasks(dir, discovered); + } +} + +fn discover_justfile_tasks(dir: &Path, discovered: &mut DiscoveredTasks) -> Result<(), String> { + let possible_justfiles = ["Justfile", "justfile", ".justfile"]; + let justfile_path = possible_justfiles + .iter() + .map(|filename| dir.join(filename)) + .find(|path| path.exists()); + + let default_path = dir.join("Justfile"); + if let Some(justfile_path) = justfile_path { + match parse_justfile::parse(&justfile_path) { + Ok(tasks) => { + handle_discovery_success( + tasks, + justfile_path, + TaskDefinitionType::Justfile, + discovered, + ); + } + Err(error) => { + handle_discovery_error( + error, + justfile_path, + TaskDefinitionType::Justfile, + discovered, + ); + } + } + } else { + discovered.definitions.justfile = Some(TaskDefinitionFile { + path: default_path, + definition_type: TaskDefinitionType::Justfile, + status: TaskFileStatus::NotFound, + }); + } + + Ok(()) +} diff --git a/src/task_discovery/make.rs b/src/task_discovery/make.rs new file mode 100644 index 0000000..51b6413 --- /dev/null +++ b/src/task_discovery/make.rs @@ -0,0 +1,144 @@ +use crate::composed_paths::{ComposedDefinitionSource, RecursiveDiscoveryState, VisitState}; +use crate::parsers::parse_makefile; +use crate::task_discovery::support::{apply_shadowing, set_definition}; +use crate::task_discovery::{DiscoveredTasks, TaskDiscovery}; +use crate::types::{Task, TaskDefinitionFile, TaskDefinitionType, TaskFileStatus}; +use std::collections::HashSet; +use std::fs; +use std::path::Path; + +pub(crate) struct MakefileDiscovery; + +const MAKEFILE_NAMES: [&str; 3] = ["GNUmakefile", "makefile", "Makefile"]; + +impl TaskDiscovery for MakefileDiscovery { + fn discover(&self, dir: &Path, discovered: &mut DiscoveredTasks) { + discover_makefile_tasks(dir, discovered); + } +} + +fn discover_makefile_tasks(dir: &Path, discovered: &mut DiscoveredTasks) { + let Some(makefile_path) = find_makefile_path(dir) else { + set_definition( + discovered, + TaskDefinitionFile { + path: dir.join("Makefile"), + definition_type: TaskDefinitionType::Makefile, + status: TaskFileStatus::NotFound, + }, + ); + return; + }; + + let root_source = ComposedDefinitionSource::direct(makefile_path.clone()); + let mut traversal_state = RecursiveDiscoveryState::new(); + let mut seen_task_names = HashSet::new(); + let mut tasks = Vec::new(); + let mut include_errors = Vec::new(); + + let result = collect_makefile_tasks_recursive( + &makefile_path, + &root_source, + &mut traversal_state, + &mut seen_task_names, + &mut tasks, + &mut include_errors, + ); + + apply_shadowing(&mut tasks); + discovered.tasks.extend(tasks); + discovered.errors.extend(include_errors); + + let status = match result { + Ok(()) => TaskFileStatus::Parsed, + Err(error) => { + discovered.errors.push(format!( + "Failed to parse {}: {}", + makefile_path.display(), + error + )); + TaskFileStatus::ParseError(error) + } + }; + + set_definition( + discovered, + TaskDefinitionFile { + path: makefile_path, + definition_type: TaskDefinitionType::Makefile, + status, + }, + ); +} + +fn find_makefile_path(dir: &Path) -> Option { + let entries = fs::read_dir(dir).ok()?; + let mut paths_by_name = std::collections::HashMap::new(); + + for entry in entries.flatten() { + let path = entry.path(); + let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + paths_by_name.insert(file_name.to_string(), path); + } + + MAKEFILE_NAMES + .iter() + .find_map(|name| paths_by_name.remove(*name)) +} + +fn collect_makefile_tasks_recursive( + root_makefile_path: &Path, + current_source: &ComposedDefinitionSource, + traversal_state: &mut RecursiveDiscoveryState, + seen_task_names: &mut HashSet, + collected_tasks: &mut Vec, + include_errors: &mut Vec, +) -> Result<(), String> { + match traversal_state.mark_visited(current_source.definition_path()) { + VisitState::AlreadyVisited(_) => return Ok(()), + VisitState::New(_) => {} + } + + let mut tasks = parse_makefile::parse(current_source.definition_path())?; + for task in &mut tasks { + current_source.apply_to_task(task); + } + // We intentionally keep discovery name-oriented instead of reimplementing GNU make's + // full override semantics. Dela only needs a stable task list here; `make` remains the + // source of truth for which recipe actually executes when duplicate targets exist. + for task in tasks { + if seen_task_names.insert(task.name.clone()) { + collected_tasks.push(task); + } + } + + let includes = parse_makefile::extract_include_directives(current_source.definition_path())?; + for include in includes { + let resolved_include = current_source.resolve_child(&include.path); + if !resolved_include.is_file() { + continue; + } + + let include_source = + ComposedDefinitionSource::composed(root_makefile_path, resolved_include.clone()); + if let Err(error) = collect_makefile_tasks_recursive( + root_makefile_path, + &include_source, + traversal_state, + seen_task_names, + collected_tasks, + include_errors, + ) { + let error = format!( + "Failed to parse included makefile '{}': {}", + resolved_include.display(), + error + ); + include_errors.push(error); + } + } + + Ok(()) +} diff --git a/src/task_discovery/maven.rs b/src/task_discovery/maven.rs new file mode 100644 index 0000000..9f0a078 --- /dev/null +++ b/src/task_discovery/maven.rs @@ -0,0 +1,31 @@ +use crate::parsers::parse_pom_xml; +use crate::task_discovery::support::{handle_discovery_error, handle_discovery_success}; +use crate::task_discovery::{DiscoveredTasks, TaskDiscovery}; +use crate::types::TaskDefinitionType; +use std::path::Path; + +pub(crate) struct MavenDiscovery; + +impl TaskDiscovery for MavenDiscovery { + fn discover(&self, dir: &Path, discovered: &mut DiscoveredTasks) { + let _ = discover_maven_tasks(dir, discovered); + } +} + +fn discover_maven_tasks(dir: &Path, discovered: &mut DiscoveredTasks) -> Result<(), String> { + let pom_path = dir.join("pom.xml"); + if !pom_path.exists() { + return Ok(()); + } + + match parse_pom_xml(&pom_path) { + Ok(tasks) => { + handle_discovery_success(tasks, pom_path, TaskDefinitionType::MavenPom, discovered); + Ok(()) + } + Err(error) => { + handle_discovery_error(error, pom_path, TaskDefinitionType::MavenPom, discovered); + Err("Error parsing pom.xml".to_string()) + } + } +} diff --git a/src/task_discovery/npm.rs b/src/task_discovery/npm.rs new file mode 100644 index 0000000..8881e71 --- /dev/null +++ b/src/task_discovery/npm.rs @@ -0,0 +1,47 @@ +use crate::parsers::parse_package_json; +use crate::task_discovery::support::{handle_discovery_error, handle_discovery_success}; +use crate::task_discovery::{DiscoveredTasks, TaskDiscovery}; +use crate::types::{TaskDefinitionFile, TaskDefinitionType, TaskFileStatus}; +use std::path::Path; + +pub(crate) struct NpmDiscovery; + +impl TaskDiscovery for NpmDiscovery { + fn discover(&self, dir: &Path, discovered: &mut DiscoveredTasks) { + let _ = discover_npm_tasks(dir, discovered); + } +} + +fn discover_npm_tasks(dir: &Path, discovered: &mut DiscoveredTasks) -> Result<(), String> { + let package_json = dir.join("package.json"); + + if !package_json.exists() { + discovered.definitions.package_json = Some(TaskDefinitionFile { + path: package_json, + definition_type: TaskDefinitionType::PackageJson, + status: TaskFileStatus::NotFound, + }); + return Ok(()); + } + + match parse_package_json::parse(&package_json) { + Ok(tasks) => { + handle_discovery_success( + tasks, + package_json, + TaskDefinitionType::PackageJson, + discovered, + ); + } + Err(error) => { + handle_discovery_error( + error, + package_json, + TaskDefinitionType::PackageJson, + discovered, + ); + } + } + + Ok(()) +} diff --git a/src/task_discovery/python.rs b/src/task_discovery/python.rs new file mode 100644 index 0000000..1b297b4 --- /dev/null +++ b/src/task_discovery/python.rs @@ -0,0 +1,47 @@ +use crate::parsers::parse_pyproject_toml; +use crate::task_discovery::support::{handle_discovery_error, handle_discovery_success}; +use crate::task_discovery::{DiscoveredTasks, TaskDiscovery}; +use crate::types::{TaskDefinitionFile, TaskDefinitionType, TaskFileStatus}; +use std::path::Path; + +pub(crate) struct PythonDiscovery; + +impl TaskDiscovery for PythonDiscovery { + fn discover(&self, dir: &Path, discovered: &mut DiscoveredTasks) { + let _ = discover_python_tasks(dir, discovered); + } +} + +fn discover_python_tasks(dir: &Path, discovered: &mut DiscoveredTasks) -> Result<(), String> { + let pyproject_toml = dir.join("pyproject.toml"); + + if !pyproject_toml.exists() { + discovered.definitions.pyproject_toml = Some(TaskDefinitionFile { + path: pyproject_toml, + definition_type: TaskDefinitionType::PyprojectToml, + status: TaskFileStatus::NotFound, + }); + return Ok(()); + } + + match parse_pyproject_toml::parse(&pyproject_toml) { + Ok(tasks) => { + handle_discovery_success( + tasks, + pyproject_toml, + TaskDefinitionType::PyprojectToml, + discovered, + ); + } + Err(error) => { + handle_discovery_error( + error, + pyproject_toml, + TaskDefinitionType::PyprojectToml, + discovered, + ); + } + } + + Ok(()) +} diff --git a/src/task_discovery/registry.rs b/src/task_discovery/registry.rs new file mode 100644 index 0000000..856c341 --- /dev/null +++ b/src/task_discovery/registry.rs @@ -0,0 +1,39 @@ +use crate::task_discovery::{ + TaskDiscovery, cmake::CmakeDiscovery, docker_compose::DockerComposeDiscovery, + github_actions::GithubActionsDiscovery, gradle::GradleDiscovery, justfile::JustfileDiscovery, + make::MakefileDiscovery, maven::MavenDiscovery, npm::NpmDiscovery, python::PythonDiscovery, + shell_scripts::ShellScriptDiscovery, taskfile::TaskfileDiscovery, travis_ci::TravisCiDiscovery, + turbo::TurboDiscovery, +}; + +static MAKEFILE_DISCOVERY: MakefileDiscovery = MakefileDiscovery; +static NPM_DISCOVERY: NpmDiscovery = NpmDiscovery; +static PYTHON_DISCOVERY: PythonDiscovery = PythonDiscovery; +static TASKFILE_DISCOVERY: TaskfileDiscovery = TaskfileDiscovery; +static TURBO_DISCOVERY: TurboDiscovery = TurboDiscovery; +static MAVEN_DISCOVERY: MavenDiscovery = MavenDiscovery; +static GRADLE_DISCOVERY: GradleDiscovery = GradleDiscovery; +static GITHUB_ACTIONS_DISCOVERY: GithubActionsDiscovery = GithubActionsDiscovery; +static DOCKER_COMPOSE_DISCOVERY: DockerComposeDiscovery = DockerComposeDiscovery; +static TRAVIS_CI_DISCOVERY: TravisCiDiscovery = TravisCiDiscovery; +static CMAKE_DISCOVERY: CmakeDiscovery = CmakeDiscovery; +static JUSTFILE_DISCOVERY: JustfileDiscovery = JustfileDiscovery; +static SHELL_SCRIPT_DISCOVERY: ShellScriptDiscovery = ShellScriptDiscovery; + +pub(crate) fn registered_discoveries() -> Vec<&'static dyn TaskDiscovery> { + vec![ + &MAKEFILE_DISCOVERY, + &NPM_DISCOVERY, + &PYTHON_DISCOVERY, + &TASKFILE_DISCOVERY, + &TURBO_DISCOVERY, + &MAVEN_DISCOVERY, + &GRADLE_DISCOVERY, + &GITHUB_ACTIONS_DISCOVERY, + &DOCKER_COMPOSE_DISCOVERY, + &TRAVIS_CI_DISCOVERY, + &CMAKE_DISCOVERY, + &JUSTFILE_DISCOVERY, + &SHELL_SCRIPT_DISCOVERY, + ] +} diff --git a/src/task_discovery/shell_scripts.rs b/src/task_discovery/shell_scripts.rs new file mode 100644 index 0000000..1972708 --- /dev/null +++ b/src/task_discovery/shell_scripts.rs @@ -0,0 +1,48 @@ +use crate::task_discovery::{DiscoveredTasks, TaskDiscovery}; +use crate::task_shadowing::check_shadowing; +use crate::types::{Task, TaskDefinitionType, TaskRunner}; +use std::fs; +use std::path::Path; + +pub(crate) struct ShellScriptDiscovery; + +impl TaskDiscovery for ShellScriptDiscovery { + fn discover(&self, dir: &Path, discovered: &mut DiscoveredTasks) { + discover_shell_script_tasks(dir, discovered); + } +} + +fn discover_shell_script_tasks(dir: &Path, discovered: &mut DiscoveredTasks) { + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() + && let Some(extension) = path.extension() + && extension == "sh" + { + let name = path + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + let source_name = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + + discovered.tasks.push(Task { + name: name.clone(), + file_path: path, + definition_path: None, + definition_type: TaskDefinitionType::ShellScript, + runner: TaskRunner::ShellScript, + source_name, + description: None, + shadowed_by: check_shadowing(&name), + disambiguated_name: None, + }); + } + } + } +} diff --git a/src/task_discovery/support.rs b/src/task_discovery/support.rs new file mode 100644 index 0000000..ec61ea4 --- /dev/null +++ b/src/task_discovery/support.rs @@ -0,0 +1,73 @@ +use crate::task_discovery::{DiscoveredTasks, TaskDefinitionFile}; +use crate::task_shadowing::check_shadowing; +use crate::types::{Task, TaskDefinitionType, TaskFileStatus}; +use std::path::PathBuf; + +pub(crate) fn apply_shadowing(tasks: &mut [Task]) { + for task in tasks { + task.shadowed_by = check_shadowing(&task.name); + } +} + +pub(crate) fn set_definition(discovered: &mut DiscoveredTasks, definition: TaskDefinitionFile) { + match definition.definition_type { + TaskDefinitionType::Makefile => discovered.definitions.makefile = Some(definition), + TaskDefinitionType::PackageJson => discovered.definitions.package_json = Some(definition), + TaskDefinitionType::PyprojectToml => { + discovered.definitions.pyproject_toml = Some(definition) + } + TaskDefinitionType::Taskfile => discovered.definitions.taskfile = Some(definition), + TaskDefinitionType::TurboJson => discovered.definitions.turbo_json = Some(definition), + TaskDefinitionType::MavenPom => discovered.definitions.maven_pom = Some(definition), + TaskDefinitionType::Gradle => discovered.definitions.gradle = Some(definition), + TaskDefinitionType::GitHubActions => { + discovered.definitions.github_actions = Some(definition) + } + TaskDefinitionType::DockerCompose => { + discovered.definitions.docker_compose = Some(definition) + } + TaskDefinitionType::TravisCi => discovered.definitions.travis_ci = Some(definition), + TaskDefinitionType::CMake => discovered.definitions.cmake = Some(definition), + TaskDefinitionType::Justfile => discovered.definitions.justfile = Some(definition), + _ => {} + } +} + +pub(crate) fn handle_discovery_error( + error: String, + file_path: PathBuf, + definition_type: TaskDefinitionType, + discovered: &mut DiscoveredTasks, +) { + discovered.errors.push(format!( + "Failed to parse {}: {}", + file_path.display(), + error + )); + set_definition( + discovered, + TaskDefinitionFile { + path: file_path, + definition_type, + status: TaskFileStatus::ParseError(error), + }, + ); +} + +pub(crate) fn handle_discovery_success( + mut tasks: Vec, + file_path: PathBuf, + definition_type: TaskDefinitionType, + discovered: &mut DiscoveredTasks, +) { + apply_shadowing(&mut tasks); + set_definition( + discovered, + TaskDefinitionFile { + path: file_path, + definition_type, + status: TaskFileStatus::Parsed, + }, + ); + discovered.tasks.extend(tasks); +} diff --git a/src/task_discovery/taskfile.rs b/src/task_discovery/taskfile.rs new file mode 100644 index 0000000..ede8bf0 --- /dev/null +++ b/src/task_discovery/taskfile.rs @@ -0,0 +1,199 @@ +use crate::composed_paths::{ComposedDefinitionSource, RecursiveDiscoveryState, VisitState}; +use crate::parsers::parse_taskfile; +use crate::task_discovery::support::{apply_shadowing, set_definition}; +use crate::task_discovery::{DiscoveredTasks, TaskDiscovery}; +use crate::types::{Task, TaskDefinitionFile, TaskDefinitionType, TaskFileStatus}; +use std::collections::HashSet; +use std::path::Path; + +pub(crate) struct TaskfileDiscovery; + +impl TaskDiscovery for TaskfileDiscovery { + fn discover(&self, dir: &Path, discovered: &mut DiscoveredTasks) { + let _ = discover_taskfile_tasks(dir, discovered); + } +} + +fn discover_taskfile_tasks(dir: &Path, discovered: &mut DiscoveredTasks) -> Result<(), String> { + let default_path = dir.join(parse_taskfile::SUPPORTED_TASKFILE_NAMES[0]); + let Some(taskfile_path) = parse_taskfile::find_taskfile_in_dir(dir) else { + set_definition( + discovered, + TaskDefinitionFile { + path: default_path, + definition_type: TaskDefinitionType::Taskfile, + status: TaskFileStatus::NotFound, + }, + ); + return Ok(()); + }; + + let root_source = ComposedDefinitionSource::direct(taskfile_path.clone()); + let mut traversal_state = RecursiveDiscoveryState::new(); + let mut seen_task_names = HashSet::new(); + let mut tasks = Vec::new(); + let mut include_errors = Vec::new(); + let no_excludes = HashSet::new(); + let mut traversal = TaskfileTraversal { + root_taskfile_path: &taskfile_path, + traversal_state: &mut traversal_state, + seen_task_names: &mut seen_task_names, + collected_tasks: &mut tasks, + include_errors: &mut include_errors, + }; + + let result = collect_taskfile_tasks_recursive( + &root_source, + "", + None, + false, + &no_excludes, + &mut traversal, + ); + + apply_shadowing(&mut tasks); + discovered.tasks.extend(tasks); + discovered.errors.extend(include_errors); + + let status = match result { + Ok(()) => TaskFileStatus::Parsed, + Err(error) => { + discovered.errors.push(format!( + "Failed to parse {}: {}", + taskfile_path.display(), + error + )); + TaskFileStatus::ParseError(error) + } + }; + + set_definition( + discovered, + TaskDefinitionFile { + path: taskfile_path, + definition_type: TaskDefinitionType::Taskfile, + status, + }, + ); + + Ok(()) +} + +struct TaskfileTraversal<'a> { + root_taskfile_path: &'a Path, + traversal_state: &'a mut RecursiveDiscoveryState, + seen_task_names: &'a mut HashSet, + collected_tasks: &'a mut Vec, + include_errors: &'a mut Vec, +} + +fn collect_taskfile_tasks_recursive( + current_source: &ComposedDefinitionSource, + namespace_prefix: &str, + include_label: Option<&str>, + hide_tasks: bool, + excluded_tasks: &HashSet, + traversal: &mut TaskfileTraversal<'_>, +) -> Result<(), String> { + match traversal + .traversal_state + .mark_visited(current_source.definition_path()) + { + VisitState::AlreadyVisited(_) => return Ok(()), + VisitState::New(_) => {} + } + + let mut first_error = None; + + let mut tasks = parse_taskfile::parse(current_source.definition_path())?; + tasks.sort_by(|a, b| a.name.cmp(&b.name)); + + if !hide_tasks { + for mut task in tasks { + let original_name = task.name.clone(); + if excluded_tasks.contains(&original_name) { + continue; + } + + let effective_name = prefix_taskfile_task_name(namespace_prefix, &original_name); + task.name = effective_name.clone(); + task.source_name = effective_name; + current_source.apply_to_task(&mut task); + + if !traversal.seen_task_names.insert(task.name.clone()) { + let error = match include_label { + Some(include_label) => { + format!( + "Found multiple tasks ({}) included by \"{}\"", + task.name, include_label + ) + } + None => format!("Found multiple Taskfile tasks named '{}'", task.name), + }; + traversal.include_errors.push(error.clone()); + if first_error.is_none() { + first_error = Some(error); + } + continue; + } + + traversal.collected_tasks.push(task); + } + } + + let includes = parse_taskfile::extract_include_directives(current_source.definition_path())?; + for include in includes { + let resolved_candidate = current_source.resolve_child(&include.taskfile); + let resolved_include = parse_taskfile::resolve_taskfile_include_path(&resolved_candidate); + + if !resolved_include.is_file() { + continue; + } + + let child_source = ComposedDefinitionSource::composed( + traversal.root_taskfile_path, + resolved_include.clone(), + ); + let child_namespace = if include.flatten { + namespace_prefix.to_string() + } else { + prefix_taskfile_task_name(namespace_prefix, &include.namespace) + }; + let child_include_label = prefix_taskfile_task_name(namespace_prefix, &include.namespace); + let child_hide_tasks = hide_tasks || include.internal; + let child_excludes = include.excludes.into_iter().collect(); + + if let Err(error) = collect_taskfile_tasks_recursive( + &child_source, + &child_namespace, + Some(child_include_label.as_str()), + child_hide_tasks, + &child_excludes, + traversal, + ) { + let error = format!( + "Failed to parse included Taskfile '{}': {}", + resolved_include.display(), + error + ); + traversal.include_errors.push(error.clone()); + if first_error.is_none() { + first_error = Some(error); + } + } + } + + if let Some(error) = first_error { + Err(error) + } else { + Ok(()) + } +} + +fn prefix_taskfile_task_name(namespace_prefix: &str, task_name: &str) -> String { + if namespace_prefix.is_empty() { + task_name.to_string() + } else { + format!("{}:{}", namespace_prefix, task_name) + } +} diff --git a/src/task_discovery/travis_ci.rs b/src/task_discovery/travis_ci.rs new file mode 100644 index 0000000..4c1ca8e --- /dev/null +++ b/src/task_discovery/travis_ci.rs @@ -0,0 +1,51 @@ +use crate::parsers::parse_travis_ci; +use crate::task_discovery::support::{ + handle_discovery_error, handle_discovery_success, set_definition, +}; +use crate::task_discovery::{DiscoveredTasks, TaskDiscovery}; +use crate::types::{TaskDefinitionFile, TaskDefinitionType, TaskFileStatus}; +use std::path::Path; + +pub(crate) struct TravisCiDiscovery; + +impl TaskDiscovery for TravisCiDiscovery { + fn discover(&self, dir: &Path, discovered: &mut DiscoveredTasks) { + let _ = discover_travis_ci_tasks(dir, discovered); + } +} + +fn discover_travis_ci_tasks(dir: &Path, discovered: &mut DiscoveredTasks) -> Result<(), String> { + let travis_ci_path = dir.join(".travis.yml"); + + if travis_ci_path.exists() { + match parse_travis_ci(&travis_ci_path) { + Ok(tasks) => { + handle_discovery_success( + tasks, + travis_ci_path, + TaskDefinitionType::TravisCi, + discovered, + ); + } + Err(error) => { + handle_discovery_error( + error, + travis_ci_path, + TaskDefinitionType::TravisCi, + discovered, + ); + } + } + } else { + set_definition( + discovered, + TaskDefinitionFile { + path: travis_ci_path, + definition_type: TaskDefinitionType::TravisCi, + status: TaskFileStatus::NotFound, + }, + ); + } + + Ok(()) +} diff --git a/src/task_discovery/turbo.rs b/src/task_discovery/turbo.rs new file mode 100644 index 0000000..a11ac9e --- /dev/null +++ b/src/task_discovery/turbo.rs @@ -0,0 +1,381 @@ +use crate::composed_paths::{ComposedDefinitionSource, RecursiveDiscoveryState, VisitState}; +use crate::parsers::parse_turbo_json; +use crate::repo_root::find_git_repo_root; +use crate::task_discovery::support::{apply_shadowing, set_definition}; +use crate::task_discovery::{DiscoveredTasks, TaskDiscovery}; +use crate::types::{Task, TaskDefinitionFile, TaskDefinitionType, TaskFileStatus}; +use std::collections::{BTreeMap, HashMap}; +use std::fs; +use std::path::{Path, PathBuf}; + +pub(crate) struct TurboDiscovery; + +impl TaskDiscovery for TurboDiscovery { + fn discover(&self, dir: &Path, discovered: &mut DiscoveredTasks) { + let _ = discover_turbo_tasks(dir, discovered); + } +} + +fn discover_turbo_tasks(dir: &Path, discovered: &mut DiscoveredTasks) -> Result<(), String> { + let repo_root = find_git_repo_root(dir).unwrap_or_else(|| dir.to_path_buf()); + let turbo_json = repo_root.join("turbo.json"); + + if !turbo_json.exists() { + set_definition( + discovered, + TaskDefinitionFile { + path: turbo_json, + definition_type: TaskDefinitionType::TurboJson, + status: TaskFileStatus::NotFound, + }, + ); + return Ok(()); + } + + let mut tasks_by_name = BTreeMap::new(); + let mut config_errors = Vec::new(); + + let result = collect_turbo_tasks_for_context( + &repo_root, + dir, + &turbo_json, + &mut tasks_by_name, + &mut config_errors, + ); + + let mut tasks: Vec<_> = tasks_by_name.into_values().collect(); + apply_shadowing(&mut tasks); + discovered.tasks.extend(tasks); + discovered.errors.extend(config_errors); + + let status = match result { + Ok(()) => TaskFileStatus::Parsed, + Err(error) => { + discovered.errors.push(format!( + "Failed to parse {}: {}", + turbo_json.display(), + error + )); + TaskFileStatus::ParseError(error) + } + }; + + set_definition( + discovered, + TaskDefinitionFile { + path: turbo_json, + definition_type: TaskDefinitionType::TurboJson, + status, + }, + ); + + Ok(()) +} + +fn collect_turbo_tasks_for_context( + repo_root: &Path, + dir: &Path, + root_turbo_json: &Path, + collected_tasks: &mut BTreeMap, + config_errors: &mut Vec, +) -> Result<(), String> { + let root_source = ComposedDefinitionSource::direct(root_turbo_json.to_path_buf()); + let mut package_configs_by_name = None; + let root_tasks = resolve_effective_turbo_tasks( + &root_source, + repo_root, + root_turbo_json, + &mut package_configs_by_name, + &mut RecursiveDiscoveryState::new(), + )?; + collected_tasks.extend(root_tasks); + + let mut first_error = None; + + if dir == repo_root { + for config_path in collect_descendant_turbo_config_paths(repo_root) { + let config_source = + ComposedDefinitionSource::composed(root_turbo_json, config_path.clone()); + match resolve_effective_turbo_tasks( + &config_source, + repo_root, + root_turbo_json, + &mut package_configs_by_name, + &mut RecursiveDiscoveryState::new(), + ) { + Ok(tasks) => { + for (name, task) in tasks { + collected_tasks.entry(name).or_insert(task); + } + } + Err(error) => { + let error = format!( + "Failed to parse workspace-local turbo config '{}': {}", + config_path.display(), + error + ); + config_errors.push(error.clone()); + if first_error.is_none() { + first_error = Some(error); + } + } + } + } + } else { + for config_path in collect_turbo_ancestor_config_paths(dir, repo_root) { + let config_source = + ComposedDefinitionSource::composed(root_turbo_json, config_path.clone()); + match resolve_effective_turbo_tasks( + &config_source, + repo_root, + root_turbo_json, + &mut package_configs_by_name, + &mut RecursiveDiscoveryState::new(), + ) { + Ok(tasks) if !tasks.is_empty() => { + *collected_tasks = tasks; + break; + } + Ok(_) => {} + Err(error) => { + let error = format!( + "Failed to parse workspace-local turbo config '{}': {}", + config_path.display(), + error + ); + config_errors.push(error.clone()); + if first_error.is_none() { + first_error = Some(error); + } + break; + } + } + } + } + + if let Some(error) = first_error { + Err(error) + } else { + Ok(()) + } +} + +fn resolve_effective_turbo_tasks( + current_source: &ComposedDefinitionSource, + repo_root: &Path, + root_turbo_json: &Path, + package_configs_by_name: &mut Option>, + traversal_state: &mut RecursiveDiscoveryState, +) -> Result, String> { + match traversal_state.mark_visited(current_source.definition_path()) { + VisitState::AlreadyVisited(_) => return Ok(BTreeMap::new()), + VisitState::New(_) => {} + } + + let config = parse_turbo_json::load_config(current_source.definition_path())?; + + if current_source.definition_path() != root_turbo_json && config.extends.is_empty() { + return Ok(BTreeMap::new()); + } + + let mut tasks = BTreeMap::new(); + + if current_source.definition_path() != root_turbo_json { + for extend_entry in &config.extends { + let Some(parent_config_path) = resolve_turbo_extends_entry( + current_source, + extend_entry, + repo_root, + root_turbo_json, + package_configs_by_name, + ) else { + continue; + }; + + if !parent_config_path.is_file() { + continue; + } + + let parent_source = + ComposedDefinitionSource::composed(root_turbo_json, parent_config_path.clone()); + let inherited_tasks = resolve_effective_turbo_tasks( + &parent_source, + repo_root, + root_turbo_json, + package_configs_by_name, + traversal_state, + )?; + tasks.extend(inherited_tasks); + } + } + + for (name, task_config) in &config.tasks { + if !task_config.is_effective_task_definition() { + tasks.remove(name.as_str()); + } + } + + let mut local_tasks = parse_turbo_json::parse(current_source.definition_path())?; + for task in &mut local_tasks { + current_source.apply_to_task(task); + } + for task in local_tasks { + tasks.insert(task.name.clone(), task); + } + + Ok(tasks) +} + +fn resolve_turbo_extends_entry( + current_source: &ComposedDefinitionSource, + extend_entry: &str, + repo_root: &Path, + root_turbo_json: &Path, + package_configs_by_name: &mut Option>, +) -> Option { + if extend_entry == "//" { + return Some(root_turbo_json.to_path_buf()); + } + + if looks_like_turbo_config_path(extend_entry) { + let candidate = current_source.resolve_child(extend_entry); + return Some(resolve_turbo_config_path_candidate(&candidate)); + } + + let package_configs_by_name = + package_configs_by_name.get_or_insert_with(|| build_turbo_package_config_index(repo_root)); + package_configs_by_name.get(extend_entry).cloned() +} + +fn collect_turbo_ancestor_config_paths(dir: &Path, repo_root: &Path) -> Vec { + let mut current = dir.to_path_buf(); + let mut config_paths = Vec::new(); + + while current.starts_with(repo_root) && current != repo_root { + let candidate = current.join("turbo.json"); + if candidate.is_file() { + config_paths.push(candidate); + } + + if !current.pop() { + break; + } + } + + config_paths +} + +fn collect_descendant_turbo_config_paths(repo_root: &Path) -> Vec { + let mut config_paths = Vec::new(); + collect_descendant_turbo_config_paths_recursive(repo_root, repo_root, &mut config_paths); + config_paths.sort(); + config_paths +} + +fn collect_descendant_turbo_config_paths_recursive( + repo_root: &Path, + current_dir: &Path, + config_paths: &mut Vec, +) { + let Ok(entries) = fs::read_dir(current_dir) else { + return; + }; + + for entry in entries.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_symlink() || !file_type.is_dir() { + continue; + } + + let path = entry.path(); + let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if should_skip_turbo_config_scan(file_name) { + continue; + } + + let candidate = path.join("turbo.json"); + if candidate.is_file() && candidate != repo_root.join("turbo.json") { + config_paths.push(candidate); + } + + collect_descendant_turbo_config_paths_recursive(repo_root, &path, config_paths); + } +} + +fn should_skip_turbo_config_scan(file_name: &str) -> bool { + matches!(file_name, ".git" | "node_modules") +} + +fn looks_like_turbo_config_path(extend_entry: &str) -> bool { + let extend_path = Path::new(extend_entry); + let is_scoped_package = extend_entry.starts_with('@'); + extend_path.is_absolute() + || extend_entry.starts_with('.') + || (!is_scoped_package && extend_entry.contains(std::path::MAIN_SEPARATOR)) + || (!is_scoped_package && extend_entry.contains('/')) + || (!is_scoped_package && extend_entry.contains('\\')) +} + +fn resolve_turbo_config_path_candidate(candidate: &Path) -> PathBuf { + if candidate + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name == "turbo.json") + { + return candidate.to_path_buf(); + } + + if candidate.extension().is_none() || candidate.is_dir() { + return candidate.join("turbo.json"); + } + + candidate.to_path_buf() +} + +fn build_turbo_package_config_index(repo_root: &Path) -> HashMap { + let mut package_configs = HashMap::new(); + + let root_turbo_json = repo_root.join("turbo.json"); + if root_turbo_json.is_file() + && let Some(package_name) = read_package_name(repo_root) + { + package_configs.insert(package_name, root_turbo_json); + } + + for config_path in collect_descendant_turbo_config_paths(repo_root) { + let Some(config_dir) = config_path.parent() else { + continue; + }; + let Some(package_name) = read_package_name(config_dir) else { + continue; + }; + package_configs.entry(package_name).or_insert(config_path); + } + + package_configs +} + +fn read_package_name(dir: &Path) -> Option { + let package_json_path = dir.join("package.json"); + let contents = fs::read_to_string(package_json_path).ok()?; + let json: serde_json::Value = serde_json::from_str(&contents).ok()?; + json.get("name") + .and_then(serde_json::Value::as_str) + .map(str::to_string) +} + +#[cfg(test)] +mod tests { + use super::looks_like_turbo_config_path; + + #[test] + fn looks_like_turbo_config_path_treats_scoped_packages_as_packages() { + assert!(!looks_like_turbo_config_path("@scope/pkg")); + assert!(looks_like_turbo_config_path("packages/shared")); + assert!(looks_like_turbo_config_path(".turbo/shared")); + } +}