From 4837a703c9a4d933f4ac305fd5d4a41825090756 Mon Sep 17 00:00:00 2001 From: Alex Yankov Date: Sun, 10 May 2026 23:27:01 -0400 Subject: [PATCH 01/10] break up tasks --- dev_docs/project_plan.md | 1 + src/task_discovery.rs | 1377 +------------------------- src/task_discovery/cmake.rs | 31 + src/task_discovery/disambiguation.rs | 133 +++ src/task_discovery/docker_compose.rs | 51 + src/task_discovery/github_actions.rs | 116 +++ src/task_discovery/gradle.rs | 70 ++ src/task_discovery/justfile.rs | 51 + src/task_discovery/make.rs | 134 +++ src/task_discovery/maven.rs | 31 + src/task_discovery/npm.rs | 47 + src/task_discovery/python.rs | 47 + src/task_discovery/registry.rs | 49 + src/task_discovery/shell_scripts.rs | 48 + src/task_discovery/support.rs | 73 ++ src/task_discovery/taskfile.rs | 199 ++++ src/task_discovery/travis_ci.rs | 51 + src/task_discovery/turbo.rs | 368 +++++++ 18 files changed, 1535 insertions(+), 1342 deletions(-) create mode 100644 src/task_discovery/cmake.rs create mode 100644 src/task_discovery/disambiguation.rs create mode 100644 src/task_discovery/docker_compose.rs create mode 100644 src/task_discovery/github_actions.rs create mode 100644 src/task_discovery/gradle.rs create mode 100644 src/task_discovery/justfile.rs create mode 100644 src/task_discovery/make.rs create mode 100644 src/task_discovery/maven.rs create mode 100644 src/task_discovery/npm.rs create mode 100644 src/task_discovery/python.rs create mode 100644 src/task_discovery/registry.rs create mode 100644 src/task_discovery/shell_scripts.rs create mode 100644 src/task_discovery/support.rs create mode 100644 src/task_discovery/taskfile.rs create mode 100644 src/task_discovery/travis_ci.rs create mode 100644 src/task_discovery/turbo.rs 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/task_discovery.rs b/src/task_discovery.rs index 451003f..ce4d5ee 100644 --- a/src/task_discovery.rs +++ b/src/task_discovery.rs @@ -1,18 +1,29 @@ -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; +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; -use std::path::PathBuf; -// Define the DiscoveredTaskDefinitions type directly here +pub use disambiguation::{ + format_ambiguous_task_error, get_disambiguated_task_names, get_matching_tasks, + is_task_ambiguous, process_task_disambiguation, +}; + #[derive(Debug, Clone, Default)] pub struct DiscoveredTaskDefinitions { pub makefile: Option, @@ -29,1371 +40,53 @@ pub struct DiscoveredTaskDefinitions { 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() + Self::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 +pub(crate) trait TaskDiscovery { + fn discover(&self, dir: &Path, discovered: &mut DiscoveredTasks); } -/// 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; - } - } +pub fn discover_tasks(dir: &Path) -> DiscoveredTasks { + let mut discovered = DiscoveredTasks::default(); - // 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; + for discoverer in registry::registered_discoveries() { + discoverer.discover(dir, &mut discovered); } -} -/// 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 { + process_task_disambiguation(&mut discovered); 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]; - - 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, - ); - } - } - - Ok(()) -} - -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(()) -} - -fn discover_cmake_tasks(dir: &Path, discovered: &mut DiscoveredTasks) -> Result<(), String> { - let cmake_path = dir.join("CMakeLists.txt"); - if !cmake_path.exists() { - return Ok(()); - } - - 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()) - } - } -} - -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; - } - } - - // Use a default path for reporting if no Justfile was found - let default_path = dir.join("Justfile"); - - // 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, - }); - } - - 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, - }); - } - } - } } #[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>>; diff --git a/src/task_discovery/cmake.rs b/src/task_discovery/cmake.rs new file mode 100644 index 0000000..c15ba76 --- /dev/null +++ b/src/task_discovery/cmake.rs @@ -0,0 +1,31 @@ +use crate::parsers::parse_cmake; +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 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() { + 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..03d4d3e --- /dev/null +++ b/src/task_discovery/disambiguation.rs @@ -0,0 +1,133 @@ +use crate::task_discovery::DiscoveredTasks; +use crate::types::{Task, TaskRunner}; +use std::collections::{HashMap, HashSet}; + +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(); + + 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 prefix_length = std::cmp::min(3, short_name.len()); + let mut prefix = short_name[0..prefix_length].to_string(); + if !used_prefixes.contains(&prefix) { + return prefix; + } + + for length in (prefix_length + 1)..=short_name.len() { + prefix = short_name[0..length].to_string(); + 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> { + if let Some(task) = discovered.tasks.iter().find(|task| { + task.disambiguated_name + .as_ref() + .is_some_and(|name| name == task_name) + }) { + return vec![task]; + } + + discovered + .tasks + .iter() + .filter(|task| task.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 { + if let Some(disambiguated) = &task.disambiguated_name { + message.push_str(&format!( + " • {} ({} from {})\n", + disambiguated, + task.runner.short_name(), + task.definition_path().display() + )); + } + } + + message.push_str("Please use the specific task name with its suffix to disambiguate."); + message +} 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..53eafc4 --- /dev/null +++ b/src/task_discovery/make.rs @@ -0,0 +1,134 @@ +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::path::Path; + +pub(crate) struct MakefileDiscovery; + +impl TaskDiscovery for MakefileDiscovery { + fn discover(&self, dir: &Path, discovered: &mut DiscoveredTasks) { + let _ = discover_makefile_tasks(dir, discovered); + } +} + +fn discover_makefile_tasks(dir: &Path, discovered: &mut DiscoveredTasks) -> Result<(), String> { + let makefile_path = dir.join("Makefile"); + + if !makefile_path.exists() { + set_definition( + discovered, + TaskDefinitionFile { + path: makefile_path, + 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 = 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, + }, + ); + + Ok(()) +} + +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 first_error = 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(()) + } +} 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..8b233e0 --- /dev/null +++ b/src/task_discovery/registry.rs @@ -0,0 +1,49 @@ +use crate::task_discovery::{ + DiscoveredTasks, 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, +}; +use std::path::Path; + +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, + ] +} + +#[allow(dead_code)] +pub(crate) fn run_discovery( + discovery: &dyn TaskDiscovery, + dir: &Path, + discovered: &mut DiscoveredTasks, +) { + discovery.discover(dir, discovered); +} 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..17f7f8d --- /dev/null +++ b/src/task_discovery/turbo.rs @@ -0,0 +1,368 @@ +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); + 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) +} From 45cb5e6e18c5b1efb351c31df6c36cd6d19dc657 Mon Sep 17 00:00:00 2001 From: Alex Yankov Date: Fri, 15 May 2026 23:22:16 -0400 Subject: [PATCH 02/10] handle non-ascii char counting better --- src/task_discovery/disambiguation.rs | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src/task_discovery/disambiguation.rs b/src/task_discovery/disambiguation.rs index 03d4d3e..b89c35a 100644 --- a/src/task_discovery/disambiguation.rs +++ b/src/task_discovery/disambiguation.rs @@ -48,7 +48,10 @@ pub fn process_task_disambiguation(discovered: &mut DiscoveredTasks) { 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() @@ -58,14 +61,15 @@ fn generate_runner_prefix(runner: &TaskRunner, used_prefixes: &HashSet) return single_char; } - let prefix_length = std::cmp::min(3, short_name.len()); - let mut prefix = short_name[0..prefix_length].to_string(); + let short_name_len = short_name.chars().count(); + let prefix_length = std::cmp::min(3, 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[0..length].to_string(); + for length in (prefix_length + 1)..=short_name_len { + prefix = short_name.chars().take(length).collect::(); if !used_prefixes.contains(&prefix) { return prefix; } @@ -131,3 +135,19 @@ pub fn format_ambiguous_task_error(task_name: &str, matching_tasks: &[&Task]) -> message.push_str("Please use the specific task name with its suffix to disambiguate."); message } + +#[cfg(test)] +mod tests { + use super::generate_prefix_from_short_name; + use std::collections::HashSet; + + #[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() + ); + } +} From 718f6e97746a79464afd812eea75debc506275fe Mon Sep 17 00:00:00 2001 From: Alex Yankov Date: Fri, 15 May 2026 23:44:06 -0400 Subject: [PATCH 03/10] address feedback --- src/task_discovery.rs | 12 +++++++ src/task_discovery/cmake.rs | 14 ++++++-- src/task_discovery/disambiguation.rs | 50 +++++++++++++++++++++++----- src/task_discovery/registry.rs | 12 +------ src/task_discovery/turbo.rs | 19 +++++++++-- 5 files changed, 82 insertions(+), 25 deletions(-) diff --git a/src/task_discovery.rs b/src/task_discovery.rs index ce4d5ee..46302ca 100644 --- a/src/task_discovery.rs +++ b/src/task_discovery.rs @@ -2563,6 +2563,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 index c15ba76..488f79f 100644 --- a/src/task_discovery/cmake.rs +++ b/src/task_discovery/cmake.rs @@ -1,7 +1,9 @@ use crate::parsers::parse_cmake; -use crate::task_discovery::support::{handle_discovery_error, handle_discovery_success}; +use crate::task_discovery::support::{ + handle_discovery_error, handle_discovery_success, set_definition, +}; use crate::task_discovery::{DiscoveredTasks, TaskDiscovery}; -use crate::types::TaskDefinitionType; +use crate::types::{TaskDefinitionFile, TaskDefinitionType, TaskFileStatus}; use std::path::Path; pub(crate) struct CmakeDiscovery; @@ -15,6 +17,14 @@ impl TaskDiscovery for CmakeDiscovery { 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(()); } diff --git a/src/task_discovery/disambiguation.rs b/src/task_discovery/disambiguation.rs index b89c35a..68bcd86 100644 --- a/src/task_discovery/disambiguation.rs +++ b/src/task_discovery/disambiguation.rs @@ -122,14 +122,13 @@ pub fn format_ambiguous_task_error(task_name: &str, matching_tasks: &[&Task]) -> let mut message = format!("Multiple tasks named '{}' found. Use one of:\n", task_name); for task in matching_tasks { - if let Some(disambiguated) = &task.disambiguated_name { - message.push_str(&format!( - " • {} ({} from {})\n", - disambiguated, - task.runner.short_name(), - task.definition_path().display() - )); - } + 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."); @@ -138,8 +137,10 @@ pub fn format_ambiguous_task_error(task_name: &str, matching_tasks: &[&Task]) -> #[cfg(test)] mod tests { - use super::generate_prefix_from_short_name; + 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() { @@ -150,4 +151,35 @@ mod tests { "å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/registry.rs b/src/task_discovery/registry.rs index 8b233e0..856c341 100644 --- a/src/task_discovery/registry.rs +++ b/src/task_discovery/registry.rs @@ -1,11 +1,10 @@ use crate::task_discovery::{ - DiscoveredTasks, TaskDiscovery, cmake::CmakeDiscovery, docker_compose::DockerComposeDiscovery, + 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, }; -use std::path::Path; static MAKEFILE_DISCOVERY: MakefileDiscovery = MakefileDiscovery; static NPM_DISCOVERY: NpmDiscovery = NpmDiscovery; @@ -38,12 +37,3 @@ pub(crate) fn registered_discoveries() -> Vec<&'static dyn TaskDiscovery> { &SHELL_SCRIPT_DISCOVERY, ] } - -#[allow(dead_code)] -pub(crate) fn run_discovery( - discovery: &dyn TaskDiscovery, - dir: &Path, - discovered: &mut DiscoveredTasks, -) { - discovery.discover(dir, discovered); -} diff --git a/src/task_discovery/turbo.rs b/src/task_discovery/turbo.rs index 17f7f8d..d70b8db 100644 --- a/src/task_discovery/turbo.rs +++ b/src/task_discovery/turbo.rs @@ -312,11 +312,12 @@ fn should_skip_turbo_config_scan(file_name: &str) -> bool { 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('.') - || extend_entry.contains(std::path::MAIN_SEPARATOR) - || extend_entry.contains('/') - || extend_entry.contains('\\') + || (!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 { @@ -358,6 +359,18 @@ fn build_turbo_package_config_index(repo_root: &Path) -> HashMap Option { let package_json_path = dir.join("package.json"); let contents = fs::read_to_string(package_json_path).ok()?; From 26323cca2023c74d698bdd1cc7a6839bdc656086 Mon Sep 17 00:00:00 2001 From: Alex Yankov Date: Fri, 15 May 2026 23:52:51 -0400 Subject: [PATCH 04/10] fixes --- src/commands/list.rs | 4 ++-- src/task_discovery.rs | 3 +-- src/task_discovery/disambiguation.rs | 4 +++- src/task_discovery/make.rs | 8 +++----- src/task_discovery/turbo.rs | 18 +++++++++--------- 5 files changed, 18 insertions(+), 19 deletions(-) diff --git a/src/commands/list.rs b/src/commands/list.rs index 0f188e3..661f219 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -290,8 +290,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); } diff --git a/src/task_discovery.rs b/src/task_discovery.rs index 46302ca..1430b1b 100644 --- a/src/task_discovery.rs +++ b/src/task_discovery.rs @@ -20,8 +20,7 @@ use std::collections::HashMap; use std::path::Path; pub use disambiguation::{ - format_ambiguous_task_error, get_disambiguated_task_names, get_matching_tasks, - is_task_ambiguous, process_task_disambiguation, + format_ambiguous_task_error, get_matching_tasks, is_task_ambiguous, process_task_disambiguation, }; #[derive(Debug, Clone, Default)] diff --git a/src/task_discovery/disambiguation.rs b/src/task_discovery/disambiguation.rs index 68bcd86..edfdc4f 100644 --- a/src/task_discovery/disambiguation.rs +++ b/src/task_discovery/disambiguation.rs @@ -2,6 +2,8 @@ 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(); @@ -62,7 +64,7 @@ fn generate_prefix_from_short_name(short_name: &str, used_prefixes: &HashSet(); if !used_prefixes.contains(&prefix) { return prefix; diff --git a/src/task_discovery/make.rs b/src/task_discovery/make.rs index 53eafc4..e602a32 100644 --- a/src/task_discovery/make.rs +++ b/src/task_discovery/make.rs @@ -10,11 +10,11 @@ pub(crate) struct MakefileDiscovery; impl TaskDiscovery for MakefileDiscovery { fn discover(&self, dir: &Path, discovered: &mut DiscoveredTasks) { - let _ = discover_makefile_tasks(dir, discovered); + discover_makefile_tasks(dir, discovered); } } -fn discover_makefile_tasks(dir: &Path, discovered: &mut DiscoveredTasks) -> Result<(), String> { +fn discover_makefile_tasks(dir: &Path, discovered: &mut DiscoveredTasks) { let makefile_path = dir.join("Makefile"); if !makefile_path.exists() { @@ -26,7 +26,7 @@ fn discover_makefile_tasks(dir: &Path, discovered: &mut DiscoveredTasks) -> Resu status: TaskFileStatus::NotFound, }, ); - return Ok(()); + return; } let root_source = ComposedDefinitionSource::direct(makefile_path.clone()); @@ -68,8 +68,6 @@ fn discover_makefile_tasks(dir: &Path, discovered: &mut DiscoveredTasks) -> Resu status, }, ); - - Ok(()) } fn collect_makefile_tasks_recursive( diff --git a/src/task_discovery/turbo.rs b/src/task_discovery/turbo.rs index d70b8db..a11ac9e 100644 --- a/src/task_discovery/turbo.rs +++ b/src/task_discovery/turbo.rs @@ -359,6 +359,15 @@ fn build_turbo_package_config_index(repo_root: &Path) -> HashMap 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; @@ -370,12 +379,3 @@ mod tests { assert!(looks_like_turbo_config_path(".turbo/shared")); } } - -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) -} From 39703c3c943c9b3cc50df8dd5e86e39280f6e840 Mon Sep 17 00:00:00 2001 From: Alex Yankov Date: Sat, 16 May 2026 00:04:54 -0400 Subject: [PATCH 05/10] disamb --- src/task_discovery.rs | 72 ++++++++++++++++++++++++++++ src/task_discovery/disambiguation.rs | 16 +++---- 2 files changed, 79 insertions(+), 9 deletions(-) diff --git a/src/task_discovery.rs b/src/task_discovery.rs index 1430b1b..4a6058a 100644 --- a/src/task_discovery.rs +++ b/src/task_discovery.rs @@ -1945,6 +1945,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(); @@ -2092,6 +2127,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(); diff --git a/src/task_discovery/disambiguation.rs b/src/task_discovery/disambiguation.rs index edfdc4f..0b387c5 100644 --- a/src/task_discovery/disambiguation.rs +++ b/src/task_discovery/disambiguation.rs @@ -105,18 +105,16 @@ pub fn get_disambiguated_task_names(discovered: &DiscoveredTasks, task_name: &st } pub fn get_matching_tasks<'a>(discovered: &'a DiscoveredTasks, task_name: &str) -> Vec<&'a Task> { - if let Some(task) = discovered.tasks.iter().find(|task| { - task.disambiguated_name - .as_ref() - .is_some_and(|name| name == task_name) - }) { - return vec![task]; - } - discovered .tasks .iter() - .filter(|task| task.name == task_name) + .filter(|task| { + task.name == task_name + || task + .disambiguated_name + .as_ref() + .is_some_and(|name| name == task_name) + }) .collect() } From bb797cd9889e7ee32a13740f227ad0f110165638 Mon Sep 17 00:00:00 2001 From: Alex Yankov Date: Sat, 16 May 2026 00:06:50 -0400 Subject: [PATCH 06/10] squeeze --- src/task_discovery.rs | 31 +++++++++++++++++++++++++++++++ src/task_discovery/make.rs | 2 +- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/task_discovery.rs b/src/task_discovery.rs index 4a6058a..95b3f33 100644 --- a/src/task_discovery.rs +++ b/src/task_discovery.rs @@ -460,6 +460,37 @@ 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_finds_turbo_json_at_git_repo_root() { let temp_dir = TempDir::new().unwrap(); diff --git a/src/task_discovery/make.rs b/src/task_discovery/make.rs index e602a32..fd568d8 100644 --- a/src/task_discovery/make.rs +++ b/src/task_discovery/make.rs @@ -118,7 +118,7 @@ fn collect_makefile_tasks_recursive( error ); include_errors.push(error.clone()); - if first_error.is_none() { + if first_error.is_none() && collected_tasks.is_empty() { first_error = Some(error); } } From 8368fb0f672de162564725221bda591e6b15d3f1 Mon Sep 17 00:00:00 2001 From: Alex Yankov Date: Sat, 16 May 2026 09:03:45 -0400 Subject: [PATCH 07/10] make make robust --- src/task_discovery.rs | 39 ++++++++++++++++++++++++++++++++++++++ src/task_discovery/make.rs | 13 ++----------- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/src/task_discovery.rs b/src/task_discovery.rs index 95b3f33..6be8619 100644 --- a/src/task_discovery.rs +++ b/src/task_discovery.rs @@ -491,6 +491,45 @@ build: 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(); diff --git a/src/task_discovery/make.rs b/src/task_discovery/make.rs index fd568d8..93c1981 100644 --- a/src/task_discovery/make.rs +++ b/src/task_discovery/make.rs @@ -83,8 +83,6 @@ fn collect_makefile_tasks_recursive( VisitState::New(_) => {} } - let mut first_error = None; - let mut tasks = parse_makefile::parse(current_source.definition_path())?; for task in &mut tasks { current_source.apply_to_task(task); @@ -117,16 +115,9 @@ fn collect_makefile_tasks_recursive( resolved_include.display(), error ); - include_errors.push(error.clone()); - if first_error.is_none() && collected_tasks.is_empty() { - first_error = Some(error); - } + include_errors.push(error); } } - if let Some(error) = first_error { - Err(error) - } else { - Ok(()) - } + Ok(()) } From 663fce60551326f7f66bdd5a8a585d4dac01ee27 Mon Sep 17 00:00:00 2001 From: Alex Yankov Date: Sat, 16 May 2026 11:39:46 -0400 Subject: [PATCH 08/10] more makefile detectors --- src/task_discovery.rs | 80 ++++++++++++++++++++++++++++++++++++++ src/task_discovery/make.rs | 14 ++++--- 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/src/task_discovery.rs b/src/task_discovery.rs index 6be8619..0930a2a 100644 --- a/src/task_discovery.rs +++ b/src/task_discovery.rs @@ -171,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(); @@ -261,6 +266,81 @@ test: assert_eq!(test_task.description, Some("Running tests".to_string())); } + #[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_other_makefile_names() { + 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(), + "makefile", + r#"from_lowercase: + @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_lowercase") + ); + assert!( + !discovered + .tasks + .iter() + .any(|task| task.name == "from_makefile") + ); + } + #[test] fn test_discover_tasks_with_included_makefiles() { let temp_dir = TempDir::new().unwrap(); diff --git a/src/task_discovery/make.rs b/src/task_discovery/make.rs index 93c1981..a5a0c97 100644 --- a/src/task_discovery/make.rs +++ b/src/task_discovery/make.rs @@ -8,6 +8,8 @@ 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); @@ -15,19 +17,21 @@ impl TaskDiscovery for MakefileDiscovery { } fn discover_makefile_tasks(dir: &Path, discovered: &mut DiscoveredTasks) { - let makefile_path = dir.join("Makefile"); - - if !makefile_path.exists() { + let Some(makefile_path) = MAKEFILE_NAMES + .iter() + .map(|name| dir.join(name)) + .find(|path| path.exists()) + else { set_definition( discovered, TaskDefinitionFile { - path: makefile_path, + 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(); From 32ff2629b61b3307931a2ee4d0fa83f004e145b0 Mon Sep 17 00:00:00 2001 From: Alex Yankov Date: Sat, 16 May 2026 12:18:43 -0400 Subject: [PATCH 09/10] handle multiple file names for makefile --- src/commands/list.rs | 68 ++++++++++++++++++++++++++++++++------ src/task_discovery.rs | 49 +++++++++++++++++---------- src/task_discovery/make.rs | 24 +++++++++++--- 3 files changed, 108 insertions(+), 33 deletions(-) diff --git a/src/commands/list.rs b/src/commands/list.rs index 661f219..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 { @@ -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 0930a2a..2c3dc88 100644 --- a/src/task_discovery.rs +++ b/src/task_discovery.rs @@ -248,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()) @@ -266,6 +266,33 @@ 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(); @@ -294,7 +321,7 @@ test: } #[test] - fn test_discover_tasks_prefers_gnumakefile_over_other_makefile_names() { + fn test_discover_tasks_prefers_gnumakefile_over_makefile() { let temp_dir = TempDir::new().unwrap(); create_named_makefile( temp_dir.path(), @@ -302,12 +329,6 @@ test: r#"from_makefile: @echo "From Makefile""#, ); - create_named_makefile( - temp_dir.path(), - "makefile", - r#"from_lowercase: - @echo "From makefile""#, - ); create_named_makefile( temp_dir.path(), "GNUmakefile", @@ -327,12 +348,6 @@ test: .iter() .any(|task| task.name == "from_gnumakefile") ); - assert!( - !discovered - .tasks - .iter() - .any(|task| task.name == "from_lowercase") - ); assert!( !discovered .tasks diff --git a/src/task_discovery/make.rs b/src/task_discovery/make.rs index a5a0c97..0585ef1 100644 --- a/src/task_discovery/make.rs +++ b/src/task_discovery/make.rs @@ -4,6 +4,7 @@ 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; @@ -17,11 +18,7 @@ impl TaskDiscovery for MakefileDiscovery { } fn discover_makefile_tasks(dir: &Path, discovered: &mut DiscoveredTasks) { - let Some(makefile_path) = MAKEFILE_NAMES - .iter() - .map(|name| dir.join(name)) - .find(|path| path.exists()) - else { + let Some(makefile_path) = find_makefile_path(dir) else { set_definition( discovered, TaskDefinitionFile { @@ -74,6 +71,23 @@ fn discover_makefile_tasks(dir: &Path, discovered: &mut DiscoveredTasks) { ); } +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, From 0967ae8df52e0e51979b20c990bdfcbd762d6638 Mon Sep 17 00:00:00 2001 From: Alex Yankov Date: Sat, 16 May 2026 12:56:37 -0400 Subject: [PATCH 10/10] comment --- src/task_discovery/make.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/task_discovery/make.rs b/src/task_discovery/make.rs index 0585ef1..51b6413 100644 --- a/src/task_discovery/make.rs +++ b/src/task_discovery/make.rs @@ -105,6 +105,9 @@ fn collect_makefile_tasks_recursive( 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);