diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md index 0d58cf5..60c6954 100644 --- a/IMPROVEMENTS.md +++ b/IMPROVEMENTS.md @@ -679,6 +679,13 @@ public class AuditLogger 4. ✅ Task caching (based on input file hashes with `--no-cache` bypass) 5. ✅ Plugin system (ITaskTypeProvider interface and PluginLoader) +### Phase 4 (Developer Experience & Code Quality) ✅ COMPLETED +1. ✅ Task Aliases - Create shortcuts for common task combinations (`aliases` in config) +2. ✅ Shell Completions - Generate shell completions (`rot completion bash|zsh|fish|powershell`) +3. ✅ Config File Auto-Discovery - Search up directory tree for tasks file +4. ✅ Task Graph Visualization - Show task dependency graph (`rot graph [task]`) +5. ✅ Result Pattern for Error Handling - `TaskResult` and `TasksResult` types with `ExecuteTaskWithResultAsync` + --- ## Related Resources diff --git a/Rot.Tests/Phase3Tests.cs b/Rot.Tests/Phase3Tests.cs index 7f48559..99c9081 100644 --- a/Rot.Tests/Phase3Tests.cs +++ b/Rot.Tests/Phase3Tests.cs @@ -318,7 +318,7 @@ public async Task ExecuteTaskAsync_WithProfileEnv_AppliesEnvironment() { ["PROFILE_VAR"] = "profile-value" }; - var executor = new TaskExecutor(tasks, profileEnv: profileEnv); + var executor = new TaskExecutor(tasks, aliases: null, profileEnv: profileEnv); var result = await executor.ExecuteTaskAsync("check-env"); @@ -344,7 +344,7 @@ public async Task ExecuteTaskAsync_TaskEnvOverridesProfileEnv() { ["OVERRIDE_VAR"] = "profile-value" }; - var executor = new TaskExecutor(tasks, profileEnv: profileEnv); + var executor = new TaskExecutor(tasks, aliases: null, profileEnv: profileEnv); var result = await executor.ExecuteTaskAsync("check-env"); @@ -530,7 +530,7 @@ public async Task ExecuteTaskAsync_NoCacheFlag_BypassesCache() }; // First run with cache - var executor1 = new TaskExecutor(tasks, noCache: false); + var executor1 = new TaskExecutor(tasks, aliases: null, noCache: false); await executor1.ExecuteTaskAsync("cached-task"); // Delete output file @@ -538,7 +538,7 @@ public async Task ExecuteTaskAsync_NoCacheFlag_BypassesCache() File.Delete(outputFile); // Second run with noCache - should execute - var executor2 = new TaskExecutor(tasks, noCache: true); + var executor2 = new TaskExecutor(tasks, aliases: null, noCache: true); await executor2.ExecuteTaskAsync("cached-task"); Assert.True(File.Exists(outputFile)); diff --git a/Rot.Tests/Phase4Tests.cs b/Rot.Tests/Phase4Tests.cs new file mode 100644 index 0000000..e9d5429 --- /dev/null +++ b/Rot.Tests/Phase4Tests.cs @@ -0,0 +1,575 @@ +using Rot.Models; +using Rot.Services; + +namespace Rot.Tests; + +public class Phase4Tests +{ + #region Task Aliases Tests + + [Fact] + public void HasAlias_ExistingAlias_ReturnsTrue() + { + var tasks = new Dictionary + { + ["build"] = new TaskDefinition { Command = "echo build" }, + ["test"] = new TaskDefinition { Command = "echo test" } + }; + var aliases = new Dictionary + { + ["ci"] = new[] { "build", "test" } + }; + var executor = new TaskExecutor(tasks, aliases); + + Assert.True(executor.HasAlias("ci")); + } + + [Fact] + public void HasAlias_NonExistingAlias_ReturnsFalse() + { + var tasks = new Dictionary + { + ["build"] = new TaskDefinition { Command = "echo build" } + }; + var aliases = new Dictionary + { + ["ci"] = new[] { "build" } + }; + var executor = new TaskExecutor(tasks, aliases); + + Assert.False(executor.HasAlias("nonexistent")); + } + + [Fact] + public void HasTaskOrAlias_ExistingTask_ReturnsTrue() + { + var tasks = new Dictionary + { + ["build"] = new TaskDefinition { Command = "echo build" } + }; + var executor = new TaskExecutor(tasks); + + Assert.True(executor.HasTaskOrAlias("build")); + } + + [Fact] + public void HasTaskOrAlias_ExistingAlias_ReturnsTrue() + { + var tasks = new Dictionary + { + ["build"] = new TaskDefinition { Command = "echo build" } + }; + var aliases = new Dictionary + { + ["ci"] = new[] { "build" } + }; + var executor = new TaskExecutor(tasks, aliases); + + Assert.True(executor.HasTaskOrAlias("ci")); + } + + [Fact] + public void HasTaskOrAlias_NeitherExists_ReturnsFalse() + { + var tasks = new Dictionary + { + ["build"] = new TaskDefinition { Command = "echo build" } + }; + var executor = new TaskExecutor(tasks); + + Assert.False(executor.HasTaskOrAlias("nonexistent")); + } + + [Fact] + public void GetAliasTasks_ExistingAlias_ReturnsTaskList() + { + var tasks = new Dictionary + { + ["build"] = new TaskDefinition { Command = "echo build" }, + ["test"] = new TaskDefinition { Command = "echo test" } + }; + var aliases = new Dictionary + { + ["ci"] = new[] { "build", "test" } + }; + var executor = new TaskExecutor(tasks, aliases); + + var aliasTasks = executor.GetAliasTasks("ci"); + + Assert.Equal(2, aliasTasks.Length); + Assert.Equal("build", aliasTasks[0]); + Assert.Equal("test", aliasTasks[1]); + } + + [Fact] + public void GetAliasTasks_NonExistingAlias_ReturnsEmptyArray() + { + var tasks = new Dictionary + { + ["build"] = new TaskDefinition { Command = "echo build" } + }; + var executor = new TaskExecutor(tasks); + + var aliasTasks = executor.GetAliasTasks("nonexistent"); + + Assert.Empty(aliasTasks); + } + + [Fact] + public void GetAliasNames_ReturnsAllAliasNames() + { + var tasks = new Dictionary + { + ["build"] = new TaskDefinition { Command = "echo build" }, + ["test"] = new TaskDefinition { Command = "echo test" }, + ["clean"] = new TaskDefinition { Command = "echo clean" } + }; + var aliases = new Dictionary + { + ["ci"] = new[] { "build", "test" }, + ["rebuild"] = new[] { "clean", "build" } + }; + var executor = new TaskExecutor(tasks, aliases); + + var aliasNames = executor.GetAliasNames().ToList(); + + Assert.Equal(2, aliasNames.Count); + Assert.Contains("ci", aliasNames); + Assert.Contains("rebuild", aliasNames); + } + + [Fact] + public async Task ExecuteAliasAsync_ValidAlias_ExecutesAllTasks() + { + var tasks = new Dictionary + { + ["task1"] = new TaskDefinition { Command = "echo task1", Type = "shell" }, + ["task2"] = new TaskDefinition { Command = "echo task2", Type = "shell" } + }; + var aliases = new Dictionary + { + ["both"] = new[] { "task1", "task2" } + }; + var executor = new TaskExecutor(tasks, aliases); + + var result = await executor.ExecuteAliasAsync("both"); + + Assert.Equal(0, result); + } + + [Fact] + public async Task ExecuteAliasAsync_NonExistingAlias_ReturnsOne() + { + var tasks = new Dictionary + { + ["build"] = new TaskDefinition { Command = "echo build" } + }; + var executor = new TaskExecutor(tasks); + + var result = await executor.ExecuteAliasAsync("nonexistent"); + + Assert.Equal(1, result); + } + + #endregion + + #region Shell Completions Tests + + [Fact] + public void GenerateBashCompletion_ReturnsValidScript() + { + var completion = ShellCompletionGenerator.GenerateBashCompletion(); + + Assert.NotEmpty(completion); + Assert.Contains("_rot_completions", completion); + Assert.Contains("complete -F _rot_completions rot", completion); + } + + [Fact] + public void GenerateZshCompletion_ReturnsValidScript() + { + var completion = ShellCompletionGenerator.GenerateZshCompletion(); + + Assert.NotEmpty(completion); + Assert.Contains("#compdef rot", completion); + Assert.Contains("_rot()", completion); + } + + [Fact] + public void GenerateFishCompletion_ReturnsValidScript() + { + var completion = ShellCompletionGenerator.GenerateFishCompletion(); + + Assert.NotEmpty(completion); + Assert.Contains("complete -c rot", completion); + } + + [Fact] + public void GeneratePowerShellCompletion_ReturnsValidScript() + { + var completion = ShellCompletionGenerator.GeneratePowerShellCompletion(); + + Assert.NotEmpty(completion); + Assert.Contains("Register-ArgumentCompleter", completion); + Assert.Contains("CommandName rot", completion); + } + + [Fact] + public void Generate_ValidShell_ReturnsCompletion() + { + Assert.NotEmpty(ShellCompletionGenerator.Generate("bash")); + Assert.NotEmpty(ShellCompletionGenerator.Generate("zsh")); + Assert.NotEmpty(ShellCompletionGenerator.Generate("fish")); + Assert.NotEmpty(ShellCompletionGenerator.Generate("powershell")); + } + + [Fact] + public void Generate_InvalidShell_ThrowsException() + { + Assert.Throws(() => ShellCompletionGenerator.Generate("invalid")); + } + + #endregion + + #region Config File Auto-Discovery Tests + + [Fact] + public void FindTasksFileOrDefault_NoSpecifiedPath_UsesDefaultOrDiscovery() + { + var result = TasksFileDiscovery.FindTasksFileOrDefault(null); + + // Should return a default path (either discovered or fallback) + Assert.NotEmpty(result); + } + + [Fact] + public void FindTasksFileOrDefault_SpecifiedPath_UsesSpecifiedPath() + { + var specifiedPath = "/custom/path/tasks.json"; + var result = TasksFileDiscovery.FindTasksFileOrDefault(specifiedPath); + + Assert.Equal(specifiedPath, result); + } + + #endregion + + #region Task Graph Tests + + [Fact] + public void PrintGraph_AllTasks_NoException() + { + var tasks = new Dictionary + { + ["build"] = new TaskDefinition { Command = "echo build", DependsOn = new[] { "restore" } }, + ["test"] = new TaskDefinition { Command = "echo test", DependsOn = new[] { "build" } }, + ["restore"] = new TaskDefinition { Command = "echo restore" } + }; + var executor = new TaskExecutor(tasks); + + var exception = Record.Exception(() => executor.PrintGraph()); + + Assert.Null(exception); + } + + [Fact] + public void PrintGraph_SpecificTask_NoException() + { + var tasks = new Dictionary + { + ["build"] = new TaskDefinition { Command = "echo build", DependsOn = new[] { "restore" } }, + ["restore"] = new TaskDefinition { Command = "echo restore" } + }; + var executor = new TaskExecutor(tasks); + + var exception = Record.Exception(() => executor.PrintGraph("build")); + + Assert.Null(exception); + } + + [Fact] + public void PrintGraph_NonExistentTask_NoException() + { + var tasks = new Dictionary + { + ["build"] = new TaskDefinition { Command = "echo build" } + }; + var executor = new TaskExecutor(tasks); + + // Should handle gracefully + var exception = Record.Exception(() => executor.PrintGraph("nonexistent")); + + Assert.Null(exception); + } + + [Fact] + public void PrintGraph_WithAliases_NoException() + { + var tasks = new Dictionary + { + ["build"] = new TaskDefinition { Command = "echo build" }, + ["test"] = new TaskDefinition { Command = "echo test" } + }; + var aliases = new Dictionary + { + ["ci"] = new[] { "build", "test" } + }; + var executor = new TaskExecutor(tasks, aliases); + + var exception = Record.Exception(() => executor.PrintGraph()); + + Assert.Null(exception); + } + + [Fact] + public void PrintGraph_WithPrePostTasks_NoException() + { + var tasks = new Dictionary + { + ["build"] = new TaskDefinition + { + Command = "echo build", + PreTasks = new[] { "clean" }, + PostTasks = new[] { "notify" } + }, + ["clean"] = new TaskDefinition { Command = "echo clean" }, + ["notify"] = new TaskDefinition { Command = "echo notify" } + }; + var executor = new TaskExecutor(tasks); + + var exception = Record.Exception(() => executor.PrintGraph("build")); + + Assert.Null(exception); + } + + #endregion + + #region TaskResult Tests + + [Fact] + public void TaskResult_Succeeded_HasCorrectProperties() + { + var duration = TimeSpan.FromSeconds(5); + var result = TaskResult.Succeeded("build", duration); + + Assert.True(result.Success); + Assert.Equal(0, result.ExitCode); + Assert.Equal("build", result.TaskName); + Assert.Equal(duration, result.Duration); + Assert.Null(result.ErrorMessage); + Assert.False(result.Skipped); + } + + [Fact] + public void TaskResult_Failed_HasCorrectProperties() + { + var result = TaskResult.Failed("build", 1, "Build failed"); + + Assert.False(result.Success); + Assert.Equal(1, result.ExitCode); + Assert.Equal("build", result.TaskName); + Assert.Equal("Build failed", result.ErrorMessage); + } + + [Fact] + public void TaskResult_Skipped_HasCorrectProperties() + { + var result = TaskResult.SkippedResult("build", "Cached"); + + Assert.True(result.Success); + Assert.True(result.Skipped); + Assert.Equal("Cached", result.SkipReason); + } + + [Fact] + public void TaskResult_TimedOut_HasCorrectProperties() + { + var result = TaskResult.TimedOut("build", 30); + + Assert.False(result.Success); + Assert.Equal(-1, result.ExitCode); + Assert.Contains("30 seconds", result.ErrorMessage); + } + + [Fact] + public void TaskResult_DryRun_HasCorrectProperties() + { + var result = TaskResult.DryRunResult("build"); + + Assert.True(result.Success); + Assert.True(result.DryRun); + } + + [Fact] + public void TaskResult_NotFound_HasCorrectProperties() + { + var result = TaskResult.NotFound("build"); + + Assert.False(result.Success); + Assert.Equal(1, result.ExitCode); + Assert.Contains("not found", result.ErrorMessage); + } + + [Fact] + public void TaskResult_CircularDependency_HasCorrectProperties() + { + var result = TaskResult.CircularDependency("build"); + + Assert.False(result.Success); + Assert.Contains("Circular dependency", result.ErrorMessage); + } + + [Fact] + public void TasksResult_AllSucceeded_SuccessIsTrue() + { + var results = new TasksResult + { + Results = new[] + { + TaskResult.Succeeded("build", TimeSpan.FromSeconds(1)), + TaskResult.Succeeded("test", TimeSpan.FromSeconds(2)) + } + }; + + Assert.True(results.Success); + Assert.Equal(0, results.ExitCode); + Assert.Equal(2, results.SucceededCount); + Assert.Equal(0, results.FailedCount); + } + + [Fact] + public void TasksResult_OneFailed_SuccessIsFalse() + { + var results = new TasksResult + { + Results = new[] + { + TaskResult.Succeeded("build", TimeSpan.FromSeconds(1)), + TaskResult.Failed("test", 1, "Test failed") + } + }; + + Assert.False(results.Success); + Assert.Equal(1, results.ExitCode); + Assert.Equal(1, results.SucceededCount); + Assert.Equal(1, results.FailedCount); + } + + [Fact] + public void TasksResult_TotalDuration_SumsAllDurations() + { + var results = new TasksResult + { + Results = new[] + { + TaskResult.Succeeded("build", TimeSpan.FromSeconds(1)), + TaskResult.Succeeded("test", TimeSpan.FromSeconds(2)), + TaskResult.Succeeded("deploy", TimeSpan.FromSeconds(3)) + } + }; + + Assert.Equal(TimeSpan.FromSeconds(6), results.TotalDuration); + } + + [Fact] + public void TasksResult_SkippedCount_CountsSkippedTasks() + { + var results = new TasksResult + { + Results = new[] + { + TaskResult.Succeeded("build", TimeSpan.FromSeconds(1)), + TaskResult.SkippedResult("test", "Cached"), + TaskResult.SkippedResult("deploy", "Condition not met") + } + }; + + Assert.Equal(1, results.SucceededCount); + Assert.Equal(2, results.SkippedCount); + Assert.Equal(0, results.FailedCount); + } + + #endregion + + #region ExecuteTaskWithResultAsync Tests + + [Fact] + public async Task ExecuteTaskWithResultAsync_Success_ReturnsSuccessResult() + { + var tasks = new Dictionary + { + ["echo"] = new TaskDefinition { Command = "echo hello", Type = "shell" } + }; + var executor = new TaskExecutor(tasks); + + var result = await executor.ExecuteTaskWithResultAsync("echo"); + + Assert.True(result.Success); + Assert.Equal(0, result.ExitCode); + Assert.Equal("echo", result.TaskName); + } + + [Fact] + public async Task ExecuteTaskWithResultAsync_NotFound_ReturnsNotFoundResult() + { + var tasks = new Dictionary + { + ["build"] = new TaskDefinition { Command = "echo build" } + }; + var executor = new TaskExecutor(tasks); + + var result = await executor.ExecuteTaskWithResultAsync("nonexistent"); + + Assert.False(result.Success); + Assert.Equal(1, result.ExitCode); + Assert.Contains("not found", result.ErrorMessage); + } + + [Fact] + public async Task ExecuteTaskWithResultAsync_Failure_ReturnsFailedResult() + { + var tasks = new Dictionary + { + ["fail"] = new TaskDefinition { Command = "exit 1", Type = "shell" } + }; + var executor = new TaskExecutor(tasks); + + var result = await executor.ExecuteTaskWithResultAsync("fail"); + + Assert.False(result.Success); + Assert.NotEqual(0, result.ExitCode); + } + + [Fact] + public async Task ExecuteTaskWithResultAsync_DryRun_ReturnsDryRunResult() + { + var tasks = new Dictionary + { + ["build"] = new TaskDefinition { Command = "echo build", Type = "shell" } + }; + var executor = new TaskExecutor(tasks, aliases: null, dryRun: true); + + var result = await executor.ExecuteTaskWithResultAsync("build"); + + Assert.True(result.Success); + Assert.True(result.DryRun); + } + + [Fact] + public async Task ExecuteTasksWithResultAsync_Success_ReturnsAggregatedResult() + { + var tasks = new Dictionary + { + ["task1"] = new TaskDefinition { Command = "echo task1", Type = "shell" }, + ["task2"] = new TaskDefinition { Command = "echo task2", Type = "shell" } + }; + var executor = new TaskExecutor(tasks); + + var result = await executor.ExecuteTasksWithResultAsync(new[] { "task1", "task2" }); + + Assert.True(result.Success); + Assert.Equal(0, result.ExitCode); + Assert.Equal(2, result.Results.Count); + } + + #endregion +} diff --git a/Rot.Tests/TaskExecutorTests.cs b/Rot.Tests/TaskExecutorTests.cs index be77e98..3db131b 100644 --- a/Rot.Tests/TaskExecutorTests.cs +++ b/Rot.Tests/TaskExecutorTests.cs @@ -109,7 +109,7 @@ public async Task ExecuteTaskAsync_DryRun_DoesNotExecute() Type = "shell" } }; - var executor = new TaskExecutor(tasks, dryRun: true); + var executor = new TaskExecutor(tasks, dryRun: true, aliases: null); var result = await executor.ExecuteTaskAsync("write-file"); @@ -354,7 +354,7 @@ public async Task ExecuteTaskAsync_WithVariableSubstitution_SubstitutesVariable( ["greeting"] = "Hello", ["name"] = "World" }; - var executor = new TaskExecutor(tasks, variables); + var executor = new TaskExecutor(tasks, variables: variables); var result = await executor.ExecuteTaskAsync("greet"); diff --git a/Rot/Models/TaskResult.cs b/Rot/Models/TaskResult.cs new file mode 100644 index 0000000..c0d6784 --- /dev/null +++ b/Rot/Models/TaskResult.cs @@ -0,0 +1,167 @@ +namespace Rot.Models; + +/// +/// Represents the result of a task execution. +/// +public record TaskResult +{ + /// + /// Whether the task completed successfully. + /// + public bool Success { get; init; } + + /// + /// The exit code from the process (0 for success, non-zero for failure, -1 for timeout). + /// + public int ExitCode { get; init; } + + /// + /// The name of the task that was executed. + /// + public string TaskName { get; init; } = string.Empty; + + /// + /// How long the task took to execute. + /// + public TimeSpan Duration { get; init; } + + /// + /// Error message if the task failed. + /// + public string? ErrorMessage { get; init; } + + /// + /// Whether the task was skipped (due to cache or condition). + /// + public bool Skipped { get; init; } + + /// + /// Reason for skipping, if applicable. + /// + public string? SkipReason { get; init; } + + /// + /// Whether this was a dry run. + /// + public bool DryRun { get; init; } + + /// + /// Creates a successful result. + /// + public static TaskResult Succeeded(string taskName, TimeSpan duration) => new() + { + Success = true, + ExitCode = 0, + TaskName = taskName, + Duration = duration + }; + + /// + /// Creates a failed result. + /// + public static TaskResult Failed(string taskName, int exitCode, string? errorMessage = null, TimeSpan? duration = null) => new() + { + Success = false, + ExitCode = exitCode, + TaskName = taskName, + ErrorMessage = errorMessage, + Duration = duration ?? TimeSpan.Zero + }; + + /// + /// Creates a skipped result. + /// + public static TaskResult SkippedResult(string taskName, string reason) => new() + { + Success = true, + ExitCode = 0, + TaskName = taskName, + Skipped = true, + SkipReason = reason + }; + + /// + /// Creates a timeout result. + /// + public static TaskResult TimedOut(string taskName, int timeoutSeconds) => new() + { + Success = false, + ExitCode = -1, + TaskName = taskName, + ErrorMessage = $"Task timed out after {timeoutSeconds} seconds" + }; + + /// + /// Creates a dry run result. + /// + public static TaskResult DryRunResult(string taskName) => new() + { + Success = true, + ExitCode = 0, + TaskName = taskName, + DryRun = true + }; + + /// + /// Creates a not found result. + /// + public static TaskResult NotFound(string taskName) => new() + { + Success = false, + ExitCode = 1, + TaskName = taskName, + ErrorMessage = $"Task '{taskName}' not found" + }; + + /// + /// Creates a circular dependency result. + /// + public static TaskResult CircularDependency(string taskName) => new() + { + Success = false, + ExitCode = 1, + TaskName = taskName, + ErrorMessage = $"Circular dependency detected for task '{taskName}'" + }; +} + +/// +/// Represents the aggregated result of multiple task executions. +/// +public record TasksResult +{ + /// + /// Whether all tasks completed successfully. + /// + public bool Success => Results.All(r => r.Success); + + /// + /// The overall exit code (0 if all succeeded, otherwise first failure). + /// + public int ExitCode => Success ? 0 : Results.FirstOrDefault(r => !r.Success)?.ExitCode ?? 1; + + /// + /// Individual task results. + /// + public IReadOnlyList Results { get; init; } = Array.Empty(); + + /// + /// Total duration of all tasks. + /// + public TimeSpan TotalDuration => TimeSpan.FromTicks(Results.Sum(r => r.Duration.Ticks)); + + /// + /// Number of tasks that succeeded. + /// + public int SucceededCount => Results.Count(r => r.Success && !r.Skipped); + + /// + /// Number of tasks that failed. + /// + public int FailedCount => Results.Count(r => !r.Success); + + /// + /// Number of tasks that were skipped. + /// + public int SkippedCount => Results.Count(r => r.Skipped); +} diff --git a/Rot/Program.cs b/Rot/Program.cs index f5c62ba..84845ea 100644 --- a/Rot/Program.cs +++ b/Rot/Program.cs @@ -4,8 +4,8 @@ var fileOption = new Option( aliases: ["--file", "-f"], - description: "Path to the tasks file (tasks.json or tasks.yaml)", - getDefaultValue: () => File.Exists("tasks.yaml") ? "tasks.yaml" : "tasks.json"); + description: "Path to the tasks file (tasks.json or tasks.yaml). Auto-discovers up the directory tree if not specified.", + getDefaultValue: () => TasksFileDiscovery.FindTasksFileOrDefault(null)); var concurrentOption = new Option( aliases: ["--concurrent", "-c"], @@ -99,6 +99,17 @@ quietOption, logFileOption }; + +var completionCommand = new Command("completion", "Generate shell completion scripts"); +var shellArgument = new Argument("shell", "Shell type: bash, zsh, fish, or powershell"); +completionCommand.Add(shellArgument); + +var graphCommand = new Command("graph", "Display task dependency graph") +{ + fileOption +}; +var graphTaskArgument = new Argument("task", () => null, "Show graph for a specific task (optional)"); +graphCommand.Add(graphTaskArgument); var watchTaskArgument = new Argument("task", "Name of the task to run on changes"); var globOption = new Option( aliases: ["--glob"], @@ -122,6 +133,8 @@ initCommand, describeCommand, watchCommand, + graphCommand, + completionCommand, fileOption, concurrentOption, dryRunOption @@ -168,7 +181,15 @@ } else if (!string.IsNullOrEmpty(task)) { - result = await executor.ExecuteTaskAsync(task); + // Check if it's an alias first + if (executor.HasAlias(task)) + { + result = await executor.ExecuteAliasAsync(task); + } + else + { + result = await executor.ExecuteTaskAsync(task); + } } else { @@ -193,7 +214,7 @@ { var defaultTasksJson = """ { - "version": "3.0.0", + "version": "4.0.0", "variables": { "config": "Debug", "outputDir": "./bin" @@ -208,6 +229,10 @@ "env": { "DOTNET_ENVIRONMENT": "Production" } } }, + "aliases": { + "ci": ["restore", "build", "test"], + "rebuild": ["clean", "build"] + }, "tasks": { "build": { "label": "Build the project", @@ -255,7 +280,7 @@ """; var defaultTasksYaml = """ - version: "3.0.0" + version: "4.0.0" variables: config: Debug outputDir: ./bin @@ -270,6 +295,14 @@ config: Release env: DOTNET_ENVIRONMENT: Production + aliases: + ci: + - restore + - build + - test + rebuild: + - clean + - build tasks: build: label: Build the project @@ -367,6 +400,36 @@ } }, fileOption, describeTaskArgument); +completionCommand.SetHandler((string shell) => +{ + try + { + var completion = ShellCompletionGenerator.Generate(shell); + Console.WriteLine(completion); + Environment.Exit(0); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: {ex.Message}"); + Environment.Exit(1); + } +}, shellArgument); + +graphCommand.SetHandler((string file, string? task) => +{ + try + { + var executor = TaskExecutor.LoadFromFile(file); + executor.PrintGraph(task); + Environment.Exit(0); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Error: {ex.Message}"); + Environment.Exit(1); + } +}, fileOption, graphTaskArgument); + watchCommand.SetHandler(async (string file, string task, string glob, int debounce, bool concurrent, bool verbose, bool quiet, string? logFile) => { try @@ -504,7 +567,7 @@ ITaskLogger CreateLogger(bool verbose, bool quiet, string? logFile) if (args.Length > 0) { var firstArg = args[0]; - var knownCommands = new[] { "list", "run", "init", "describe", "watch" }; + var knownCommands = new[] { "list", "run", "init", "describe", "watch", "graph", "completion" }; // Skip if it's a known command or starts with -- (option) if (!knownCommands.Contains(firstArg) && !firstArg.StartsWith("--") && !firstArg.StartsWith("-")) @@ -512,7 +575,7 @@ ITaskLogger CreateLogger(bool verbose, bool quiet, string? logFile) try { // Try to load tasks and see if first argument matches a task name - var file = File.Exists("tasks.yaml") ? "tasks.yaml" : "tasks.json"; + string? file = null; // Parse file option from remaining args for (int i = 1; i < args.Length; i++) @@ -524,10 +587,13 @@ ITaskLogger CreateLogger(bool verbose, bool quiet, string? logFile) } } + // Use auto-discovery if not specified + file = TasksFileDiscovery.FindTasksFileOrDefault(file); + var executor = TaskExecutor.LoadFromFile(file); - if (executor.HasTask(firstArg)) + if (executor.HasTaskOrAlias(firstArg)) { - // Insert "run" command to handle task execution through normal flow + // Insert "run" command to handle task/alias execution through normal flow var newArgs = new List { "run", firstArg }; newArgs.AddRange(args.Skip(1)); args = newArgs.ToArray(); diff --git a/Rot/Services/ShellCompletionGenerator.cs b/Rot/Services/ShellCompletionGenerator.cs new file mode 100644 index 0000000..76fed15 --- /dev/null +++ b/Rot/Services/ShellCompletionGenerator.cs @@ -0,0 +1,320 @@ +namespace Rot.Services; + +public static class ShellCompletionGenerator +{ + public static string GenerateBashCompletion() + { + return """ + # Bash completion for rot + # Install: rot completion bash > /etc/bash_completion.d/rot + # or: rot completion bash >> ~/.bashrc + + _rot_completions() + { + local cur prev opts commands + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + + commands="list run init describe watch graph completion" + opts="--file -f --concurrent -c --dry-run -n --verbose -v --quiet -q --log-file --help -h --version" + + case "${prev}" in + rot) + # First argument - could be command or task name + if [[ -f "tasks.yaml" ]] || [[ -f "tasks.json" ]]; then + local tasks=$(rot list 2>/dev/null | grep -E '^\s+\w' | awk '{print $1}') + COMPREPLY=( $(compgen -W "${commands} ${tasks}" -- ${cur}) ) + else + COMPREPLY=( $(compgen -W "${commands}" -- ${cur}) ) + fi + return 0 + ;; + run) + if [[ -f "tasks.yaml" ]] || [[ -f "tasks.json" ]]; then + local tasks=$(rot list 2>/dev/null | grep -E '^\s+\w' | awk '{print $1}') + COMPREPLY=( $(compgen -W "${tasks}" -- ${cur}) ) + fi + return 0 + ;; + describe) + if [[ -f "tasks.yaml" ]] || [[ -f "tasks.json" ]]; then + local tasks=$(rot list 2>/dev/null | grep -E '^\s+\w' | awk '{print $1}') + COMPREPLY=( $(compgen -W "${tasks}" -- ${cur}) ) + fi + return 0 + ;; + watch) + if [[ -f "tasks.yaml" ]] || [[ -f "tasks.json" ]]; then + local tasks=$(rot list 2>/dev/null | grep -E '^\s+\w' | awk '{print $1}') + COMPREPLY=( $(compgen -W "${tasks}" -- ${cur}) ) + fi + return 0 + ;; + graph) + if [[ -f "tasks.yaml" ]] || [[ -f "tasks.json" ]]; then + local tasks=$(rot list 2>/dev/null | grep -E '^\s+\w' | awk '{print $1}') + COMPREPLY=( $(compgen -W "${tasks}" -- ${cur}) ) + fi + return 0 + ;; + --file|-f) + COMPREPLY=( $(compgen -f -- ${cur}) ) + return 0 + ;; + --format) + COMPREPLY=( $(compgen -W "json yaml" -- ${cur}) ) + return 0 + ;; + --profile) + # Try to get profiles from config + COMPREPLY=() + return 0 + ;; + --group|-g) + COMPREPLY=() + return 0 + ;; + --pattern|-p) + COMPREPLY=() + return 0 + ;; + --tag|-t) + COMPREPLY=() + return 0 + ;; + completion) + COMPREPLY=( $(compgen -W "bash zsh fish powershell" -- ${cur}) ) + return 0 + ;; + esac + + if [[ ${cur} == -* ]]; then + COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) + return 0 + fi + } + + complete -F _rot_completions rot + """; + } + + public static string GenerateZshCompletion() + { + return """ + #compdef rot + + # Zsh completion for rot + # Install: rot completion zsh > ~/.zsh/completions/_rot + # Make sure ~/.zsh/completions is in your fpath + + _rot() { + local -a commands + local -a tasks + + commands=( + 'list:List all available tasks' + 'run:Run a specific task' + 'init:Initialize a new tasks file' + 'describe:Show detailed information about a task' + 'watch:Watch for file changes and re-run a task' + 'graph:Display task dependency graph' + 'completion:Generate shell completion scripts' + ) + + _arguments -C \ + '(-f --file)'{-f,--file}'[Path to the tasks file]:file:_files' \ + '(-c --concurrent)'{-c,--concurrent}'[Enable concurrent execution]' \ + '(-n --dry-run)'{-n,--dry-run}'[Preview without executing]' \ + '(-v --verbose)'{-v,--verbose}'[Show detailed output]' \ + '(-q --quiet)'{-q,--quiet}'[Only show errors]' \ + '--log-file[Write logs to file]:file:_files' \ + '--help[Show help]' \ + '1: :->command' \ + '*: :->args' + + case $state in + command) + _describe -t commands 'rot commands' commands + # Also suggest task names if tasks file exists + if [[ -f "tasks.yaml" ]] || [[ -f "tasks.json" ]]; then + tasks=(${(f)"$(rot list 2>/dev/null | grep -E '^\s+\w' | awk '{print $1}')"}) + _describe -t tasks 'tasks' tasks + fi + ;; + args) + case ${words[2]} in + run|describe|watch|graph) + if [[ -f "tasks.yaml" ]] || [[ -f "tasks.json" ]]; then + tasks=(${(f)"$(rot list 2>/dev/null | grep -E '^\s+\w' | awk '{print $1}')"}) + _describe -t tasks 'tasks' tasks + fi + ;; + init) + _arguments '--format[File format]:format:(json yaml)' + ;; + completion) + _describe -t shells 'shells' '(bash zsh fish powershell)' + ;; + esac + ;; + esac + } + + _rot "$@" + """; + } + + public static string GenerateFishCompletion() + { + return """ + # Fish completion for rot + # Install: rot completion fish > ~/.config/fish/completions/rot.fish + + # Disable file completion by default + complete -c rot -f + + # Commands + complete -c rot -n "__fish_use_subcommand" -a "list" -d "List all available tasks" + complete -c rot -n "__fish_use_subcommand" -a "run" -d "Run a specific task" + complete -c rot -n "__fish_use_subcommand" -a "init" -d "Initialize a new tasks file" + complete -c rot -n "__fish_use_subcommand" -a "describe" -d "Show detailed task information" + complete -c rot -n "__fish_use_subcommand" -a "watch" -d "Watch for changes and re-run task" + complete -c rot -n "__fish_use_subcommand" -a "graph" -d "Display task dependency graph" + complete -c rot -n "__fish_use_subcommand" -a "completion" -d "Generate shell completions" + + # Global options + complete -c rot -s f -l file -d "Path to tasks file" -r + complete -c rot -s c -l concurrent -d "Enable concurrent execution" + complete -c rot -s n -l dry-run -d "Preview without executing" + complete -c rot -s v -l verbose -d "Show detailed output" + complete -c rot -s q -l quiet -d "Only show errors" + complete -c rot -l log-file -d "Write logs to file" -r + complete -c rot -s h -l help -d "Show help" + + # run command options + complete -c rot -n "__fish_seen_subcommand_from run" -s g -l group -d "Run tasks by group" -r + complete -c rot -n "__fish_seen_subcommand_from run" -s p -l pattern -d "Run tasks by pattern" -r + complete -c rot -n "__fish_seen_subcommand_from run" -s t -l tag -d "Run tasks by tag" -r + complete -c rot -n "__fish_seen_subcommand_from run" -l profile -d "Apply profile" -r + complete -c rot -n "__fish_seen_subcommand_from run" -l no-cache -d "Disable caching" + + # init command options + complete -c rot -n "__fish_seen_subcommand_from init" -l format -d "File format" -a "json yaml" + + # watch command options + complete -c rot -n "__fish_seen_subcommand_from watch" -l glob -d "Glob pattern for files" -r + complete -c rot -n "__fish_seen_subcommand_from watch" -l debounce -d "Debounce time in ms" -r + + # completion command arguments + complete -c rot -n "__fish_seen_subcommand_from completion" -a "bash zsh fish powershell" + + # Task completions for run, describe, watch, graph + function __rot_tasks + if test -f "tasks.yaml" -o -f "tasks.json" + rot list 2>/dev/null | string match -r '^\s+\w+' | string trim + end + end + + complete -c rot -n "__fish_seen_subcommand_from run describe watch graph" -a "(__rot_tasks)" + complete -c rot -n "__fish_use_subcommand" -a "(__rot_tasks)" + """; + } + + public static string GeneratePowerShellCompletion() + { + return """ + # PowerShell completion for rot + # Install: rot completion powershell >> $PROFILE + + Register-ArgumentCompleter -CommandName rot -Native -ScriptBlock { + param($wordToComplete, $commandAst, $cursorPosition) + + $commands = @('list', 'run', 'init', 'describe', 'watch', 'graph', 'completion') + $shells = @('bash', 'zsh', 'fish', 'powershell') + $formats = @('json', 'yaml') + + # Get the command line tokens + $tokens = $commandAst.CommandElements + + # Determine context + $prevToken = if ($tokens.Count -gt 1) { $tokens[-2].Extent.Text } else { '' } + $currentCommand = if ($tokens.Count -gt 1) { $tokens[1].Extent.Text } else { '' } + + # Helper to get tasks + function Get-RotTasks { + $tasksFile = if (Test-Path 'tasks.yaml') { 'tasks.yaml' } elseif (Test-Path 'tasks.json') { 'tasks.json' } else { $null } + if ($tasksFile) { + try { + $output = rot list 2>$null + $output | Where-Object { $_ -match '^\s+\w' } | ForEach-Object { ($_ -split '\s+')[1] } + } catch { } + } + } + + # Complete based on context + switch -Regex ($prevToken) { + '^rot$' { + $tasks = Get-RotTasks + ($commands + $tasks) | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) + } + } + '^(run|describe|watch|graph)$' { + Get-RotTasks | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) + } + } + '^completion$' { + $shells | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) + } + } + '^--format$' { + $formats | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) + } + } + '^(-f|--file)$' { + Get-ChildItem -Path '.' -Filter '*.json' | ForEach-Object { $_.Name } + Get-ChildItem -Path '.' -Filter '*.yaml' | ForEach-Object { $_.Name } + Get-ChildItem -Path '.' -Filter '*.yml' | ForEach-Object { $_.Name } + } + default { + if ($wordToComplete -like '-*') { + @('-f', '--file', '-c', '--concurrent', '-n', '--dry-run', '-v', '--verbose', '-q', '--quiet', '--log-file', '--help') | + Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterName', $_) + } + } elseif ($commands -contains $currentCommand) { + # We're in a command context, suggest tasks + if (@('run', 'describe', 'watch', 'graph') -contains $currentCommand) { + Get-RotTasks | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) + } + } + } else { + # Root level - suggest commands and tasks + $tasks = Get-RotTasks + ($commands + $tasks) | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) + } + } + } + } + } + """; + } + + public static string Generate(string shell) + { + return shell.ToLowerInvariant() switch + { + "bash" => GenerateBashCompletion(), + "zsh" => GenerateZshCompletion(), + "fish" => GenerateFishCompletion(), + "powershell" or "pwsh" => GeneratePowerShellCompletion(), + _ => throw new ArgumentException($"Unknown shell: {shell}. Supported shells: bash, zsh, fish, powershell") + }; + } +} diff --git a/Rot/Services/TasksFileDiscovery.cs b/Rot/Services/TasksFileDiscovery.cs new file mode 100644 index 0000000..7cbac98 --- /dev/null +++ b/Rot/Services/TasksFileDiscovery.cs @@ -0,0 +1,81 @@ +namespace Rot.Services; + +public static class TasksFileDiscovery +{ + private static readonly string[] TasksFileNames = { "tasks.yaml", "tasks.yml", "tasks.json" }; + + /// + /// Searches up the directory tree for a tasks file starting from the specified directory. + /// + /// The directory to start searching from. If null, uses current directory. + /// The full path to the tasks file, or null if not found. + public static string? FindTasksFile(string? startDir = null) + { + var dir = startDir ?? Directory.GetCurrentDirectory(); + + while (!string.IsNullOrEmpty(dir)) + { + foreach (var fileName in TasksFileNames) + { + var filePath = Path.Combine(dir, fileName); + if (File.Exists(filePath)) + { + return filePath; + } + } + + // Move up to parent directory + var parentDir = Path.GetDirectoryName(dir); + if (parentDir == dir) + { + // We've reached the root + break; + } + dir = parentDir; + } + + return null; + } + + /// + /// Finds a tasks file, with fallback to a specified path. + /// + /// Explicitly specified path, if any. + /// The path to use (discovered or specified). + public static string FindTasksFileOrDefault(string? specifiedPath) + { + // If a path was explicitly specified, use it + if (!string.IsNullOrEmpty(specifiedPath)) + { + return specifiedPath; + } + + // Try auto-discovery + var discovered = FindTasksFile(); + if (discovered != null) + { + return discovered; + } + + // Fall back to default names in current directory + if (File.Exists("tasks.yaml")) + { + return "tasks.yaml"; + } + + return "tasks.json"; + } + + /// + /// Gets the directory containing the tasks file. + /// + public static string? GetTasksFileDirectory(string? specifiedPath = null) + { + var filePath = FindTasksFileOrDefault(specifiedPath); + if (File.Exists(filePath)) + { + return Path.GetDirectoryName(Path.GetFullPath(filePath)); + } + return null; + } +} diff --git a/Rot/TaskExecutor.cs b/Rot/TaskExecutor.cs index 405f7d4..d310b57 100644 --- a/Rot/TaskExecutor.cs +++ b/Rot/TaskExecutor.cs @@ -11,6 +11,7 @@ namespace Rot.Services; public class TaskExecutor { private readonly Dictionary _tasks; + private readonly Dictionary _aliases; private readonly Dictionary _variables; private readonly Dictionary _profileEnv; private readonly HashSet _executingTasks = new(); @@ -26,6 +27,7 @@ public class TaskExecutor public TaskExecutor( Dictionary tasks, + Dictionary? aliases = null, Dictionary? variables = null, Dictionary? profileEnv = null, bool allowConcurrency = false, @@ -35,6 +37,7 @@ public TaskExecutor( PluginLoader? pluginLoader = null) { _tasks = tasks; + _aliases = aliases ?? new Dictionary(); _variables = variables ?? new Dictionary(); _profileEnv = profileEnv ?? new Dictionary(); _allowConcurrency = allowConcurrency; @@ -84,6 +87,7 @@ public static TaskExecutor LoadFromFile( } var tasks = config?.Tasks ?? new Dictionary(); + var aliases = config?.Aliases ?? new Dictionary(); var variables = new Dictionary(config?.Variables ?? new Dictionary()); var profileEnv = new Dictionary(); @@ -140,189 +144,16 @@ public static TaskExecutor LoadFromFile( var pluginLoader = new PluginLoader(logger ?? NullLogger.Instance); // Note: Plugin names would come from config if we add plugins array to TasksConfig - return new TaskExecutor(tasks, variables, profileEnv, allowConcurrency, dryRun, noCache, logger, pluginLoader); + return new TaskExecutor(tasks, aliases, variables, profileEnv, allowConcurrency, dryRun, noCache, logger, pluginLoader); } + /// + /// Executes a task and returns its exit code. + /// public async Task ExecuteTaskAsync(string taskName) { - _logger.Debug("Starting execution of task '{TaskName}'", taskName); - - if (!_tasks.ContainsKey(taskName)) - { - _logger.Error("Task '{TaskName}' not found", taskName); - PrintTaskNotFoundError(taskName); - return 1; - } - - // Check if already completed in this session (for caching within a run) - await _executionSemaphore.WaitAsync(); - try - { - if (_completedTasks.Contains(taskName)) - { - _logger.Debug("Task '{TaskName}' already completed in this session", taskName); - return 0; - } - - if (_executingTasks.Contains(taskName)) - { - _logger.Error("Circular dependency detected for task '{TaskName}'", taskName); - Console.WriteLine($"Circular dependency detected for task '{taskName}'."); - return 1; - } - _executingTasks.Add(taskName); - } - finally - { - _executionSemaphore.Release(); - } - - var task = _tasks[taskName]; - - // Check conditions before executing - if (!_conditionEvaluator.Evaluate(task.Condition, taskName)) - { - var taskLabel = GetColoredTaskLabel(taskName); - Console.WriteLine($"{taskLabel} Skipped (condition not met)"); - await MarkTaskComplete(taskName); - return 0; - } - - // Execute dependencies - if (_allowConcurrency && task.AllowConcurrent && task.DependsOn.Length > 0) - { - _logger.Debug("Executing {Count} dependencies concurrently for task '{TaskName}'", task.DependsOn.Length, taskName); - var dependencyTasks = task.DependsOn.Select(ExecuteTaskAsync); - var dependencyResults = await Task.WhenAll(dependencyTasks); - - var failedDependency = dependencyResults.FirstOrDefault(r => r != 0); - if (failedDependency != 0) - { - await RemoveFromExecuting(taskName); - _logger.Error("One or more dependencies failed for task '{TaskName}'", taskName); - Console.WriteLine($"One or more dependencies failed for task '{taskName}'."); - return failedDependency; - } - } - else - { - foreach (var dependency in task.DependsOn) - { - _logger.Debug("Executing dependency '{Dependency}' for task '{TaskName}'", dependency, taskName); - var dependencyResult = await ExecuteTaskAsync(dependency); - if (dependencyResult != 0) - { - await RemoveFromExecuting(taskName); - _logger.Error("Dependency '{Dependency}' failed for task '{TaskName}'", dependency, taskName); - Console.WriteLine($"Dependency '{dependency}' failed for task '{taskName}'."); - return dependencyResult; - } - } - } - - try - { - var taskLabel = GetColoredTaskLabel(taskName); - - // Execute pre-tasks (hooks) - if (task.PreTasks.Length > 0) - { - _logger.Debug("Executing {Count} pre-tasks for '{TaskName}'", task.PreTasks.Length, taskName); - foreach (var preTask in task.PreTasks) - { - var preResult = await ExecuteTaskAsync(preTask); - if (preResult != 0) - { - _logger.Error("Pre-task '{PreTask}' failed for task '{TaskName}'", preTask, taskName); - Console.WriteLine($"{taskLabel} Pre-task '{preTask}' failed."); - return preResult; - } - } - } - - if (_dryRun) - { - PrintDryRunInfo(task, taskName, taskLabel); - return 0; - } - - // Check cache (unless disabled) - if (!_noCache && task.Cache != null && _cacheManager.IsCacheValid(taskName, task.Cache)) - { - Console.WriteLine($"{taskLabel} Skipped (cached)"); - await MarkTaskComplete(taskName); - - // Still run post-tasks even when cached - if (task.PostTasks.Length > 0) - { - return await ExecutePostTasks(task, taskName, taskLabel); - } - return 0; - } - - _logger.Info("Executing task '{TaskName}': {Command}", taskName, task.Command); - Console.WriteLine($"{taskLabel} Executing task..."); - - var stopwatch = Stopwatch.StartNew(); - var result = await RunCommandAsync(task, taskName); - stopwatch.Stop(); - - if (result == 0) - { - _logger.Info("Task '{TaskName}' completed successfully in {Duration}ms", taskName, stopwatch.ElapsedMilliseconds); - Console.WriteLine($"{taskLabel} Task completed successfully."); - - // Save cache on success - if (task.Cache != null) - { - _cacheManager.SaveCache(taskName, task.Cache); - } - - // Execute post-tasks (hooks) on success - if (task.PostTasks.Length > 0) - { - var postResult = await ExecutePostTasks(task, taskName, taskLabel); - if (postResult != 0) - { - return postResult; - } - } - - await MarkTaskComplete(taskName); - } - else if (result == -1) - { - _logger.Error("Task '{TaskName}' timed out after {Timeout} seconds", taskName, task.Timeout); - Console.WriteLine($"{taskLabel} Task timed out after {task.Timeout} seconds."); - } - else - { - _logger.Error("Task '{TaskName}' failed with exit code {ExitCode}", taskName, result); - Console.WriteLine($"{taskLabel} Task failed with exit code {result}."); - } - - return result; - } - finally - { - await RemoveFromExecuting(taskName); - } - } - - private async Task ExecutePostTasks(TaskDefinition task, string taskName, string taskLabel) - { - _logger.Debug("Executing {Count} post-tasks for '{TaskName}'", task.PostTasks.Length, taskName); - foreach (var postTask in task.PostTasks) - { - var postResult = await ExecuteTaskAsync(postTask); - if (postResult != 0) - { - _logger.Error("Post-task '{PostTask}' failed for task '{TaskName}'", postTask, taskName); - Console.WriteLine($"{taskLabel} Post-task '{postTask}' failed."); - return postResult; - } - } - return 0; + var result = await ExecuteTaskWithResultAsync(taskName); + return result.ExitCode; } private async Task MarkTaskComplete(string taskName) @@ -551,11 +382,50 @@ public bool HasTask(string taskName) return _tasks.ContainsKey(taskName); } + public bool HasAlias(string aliasName) + { + return _aliases.ContainsKey(aliasName); + } + + public bool HasTaskOrAlias(string name) + { + return _tasks.ContainsKey(name) || _aliases.ContainsKey(name); + } + + public string[] GetAliasTasks(string aliasName) + { + return _aliases.TryGetValue(aliasName, out var tasks) ? tasks : Array.Empty(); + } + + public async Task ExecuteAliasAsync(string aliasName) + { + if (!_aliases.TryGetValue(aliasName, out var tasks)) + { + _logger.Error("Alias '{AliasName}' not found", aliasName); + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"Alias '{aliasName}' not found."); + Console.ResetColor(); + return 1; + } + + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine($"Running alias '{aliasName}': {string.Join(" → ", tasks)}"); + Console.ResetColor(); + Console.WriteLine(); + + return await ExecuteTasksAsync(tasks); + } + public IEnumerable GetTaskNames() { return _tasks.Keys; } + public IEnumerable GetAliasNames() + { + return _aliases.Keys; + } + private void PrintTaskNotFoundError(string taskName) { Console.ForegroundColor = ConsoleColor.Red; @@ -667,6 +537,20 @@ public void ListTasks(bool detailed = false) } } + // Show aliases + if (_aliases.Count > 0) + { + Console.WriteLine(); + Console.WriteLine("Aliases:"); + foreach (var alias in _aliases.OrderBy(a => a.Key)) + { + Console.ForegroundColor = ConsoleColor.Green; + Console.Write($" {alias.Key,-20}"); + Console.ResetColor(); + Console.WriteLine($"→ {string.Join(", ", alias.Value)}"); + } + } + Console.WriteLine(); } @@ -802,13 +686,211 @@ public IEnumerable GetTasksByTag(string tag) .Select(t => t.Key); } + public void PrintGraph(string? taskName = null) + { + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine("Task Dependency Graph"); + Console.ResetColor(); + Console.WriteLine(new string('═', 40)); + Console.WriteLine(); + + if (!string.IsNullOrEmpty(taskName)) + { + // Show graph for specific task + if (!_tasks.ContainsKey(taskName)) + { + PrintTaskNotFoundError(taskName); + return; + } + + PrintTaskTree(taskName, "", true, new HashSet()); + } + else + { + // Show graph for all root tasks (tasks with no dependents) + var allDependencies = _tasks.Values + .SelectMany(t => t.DependsOn.Concat(t.PreTasks).Concat(t.PostTasks)) + .ToHashSet(); + + var rootTasks = _tasks.Keys + .Where(t => !allDependencies.Contains(t)) + .OrderBy(t => t) + .ToList(); + + if (rootTasks.Count == 0) + { + // If everything has dependents, just list all tasks + rootTasks = _tasks.Keys.OrderBy(t => t).ToList(); + } + + foreach (var root in rootTasks) + { + PrintTaskTree(root, "", true, new HashSet()); + Console.WriteLine(); + } + } + + // Print aliases graph if any + if (_aliases.Count > 0) + { + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine("Aliases"); + Console.ResetColor(); + Console.WriteLine(new string('─', 40)); + Console.WriteLine(); + + foreach (var alias in _aliases.OrderBy(a => a.Key)) + { + Console.ForegroundColor = ConsoleColor.Green; + Console.Write($" {alias.Key}"); + Console.ResetColor(); + Console.WriteLine(); + + for (int i = 0; i < alias.Value.Length; i++) + { + var isLast = i == alias.Value.Length - 1; + var prefix = isLast ? " └── " : " ├── "; + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.Write(prefix); + Console.ResetColor(); + Console.WriteLine(alias.Value[i]); + } + Console.WriteLine(); + } + } + } + + private void PrintTaskTree(string taskName, string indent, bool isLast, HashSet visited) + { + var branch = isLast ? "└── " : "├── "; + var nextIndent = indent + (isLast ? " " : "│ "); + + // Print the current task + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.Write(indent); + Console.Write(branch); + Console.ResetColor(); + + // Color based on task state + if (visited.Contains(taskName)) + { + Console.ForegroundColor = ConsoleColor.DarkYellow; + Console.WriteLine($"{taskName} (circular ref)"); + Console.ResetColor(); + return; + } + + if (!_tasks.ContainsKey(taskName)) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"{taskName} (not found)"); + Console.ResetColor(); + return; + } + + var task = _tasks[taskName]; + Console.ForegroundColor = ConsoleColor.Cyan; + Console.Write(taskName); + Console.ResetColor(); + + // Show task type and other info + Console.ForegroundColor = ConsoleColor.DarkGray; + if (!string.IsNullOrEmpty(task.Label)) + { + Console.Write($" - {task.Label}"); + } + Console.WriteLine(); + Console.ResetColor(); + + visited.Add(taskName); + + // Get all dependencies (dependsOn + preTasks) + var allDeps = new List(); + + if (task.PreTasks.Length > 0) + { + foreach (var pre in task.PreTasks) + { + allDeps.Add($"[pre] {pre}"); + } + } + + allDeps.AddRange(task.DependsOn); + + if (task.PostTasks.Length > 0) + { + foreach (var post in task.PostTasks) + { + allDeps.Add($"[post] {post}"); + } + } + + // Print dependencies + for (int i = 0; i < allDeps.Count; i++) + { + var dep = allDeps[i]; + var depIsLast = i == allDeps.Count - 1; + + if (dep.StartsWith("[pre] ") || dep.StartsWith("[post] ")) + { + var hookType = dep.StartsWith("[pre] ") ? "pre" : "post"; + var actualTask = dep.Substring(hookType.Length + 3); + + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.Write(nextIndent); + Console.Write(depIsLast ? "└── " : "├── "); + Console.ForegroundColor = ConsoleColor.Magenta; + Console.Write($"[{hookType}] "); + Console.ResetColor(); + + if (!visited.Contains(actualTask) && _tasks.ContainsKey(actualTask)) + { + var childVisited = new HashSet(visited); + Console.WriteLine(actualTask); + // Don't expand hooks further to keep output cleaner + } + else if (visited.Contains(actualTask)) + { + Console.ForegroundColor = ConsoleColor.DarkYellow; + Console.WriteLine($"{actualTask} (circular ref)"); + Console.ResetColor(); + } + else + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"{actualTask} (not found)"); + Console.ResetColor(); + } + } + else + { + PrintTaskTree(dep, nextIndent, depIsLast, new HashSet(visited)); + } + } + } + public async Task ExecuteTasksAsync(IEnumerable taskNames) + { + var result = await ExecuteTasksWithResultAsync(taskNames); + return result.ExitCode; + } + + /// + /// Executes multiple tasks and returns detailed results. + /// + public async Task ExecuteTasksWithResultAsync(IEnumerable taskNames) { var names = taskNames.ToList(); + var results = new List(); + if (names.Count == 0) { Console.WriteLine("No tasks matched the criteria."); - return 1; + return new TasksResult + { + Results = new[] { TaskResult.Failed("", 1, "No tasks matched the criteria") } + }; } Console.WriteLine($"Running {names.Count} task(s): {string.Join(", ", names)}"); @@ -816,14 +898,199 @@ public async Task ExecuteTasksAsync(IEnumerable taskNames) foreach (var taskName in names) { - var result = await ExecuteTaskAsync(taskName); - if (result != 0) + var result = await ExecuteTaskWithResultAsync(taskName); + results.Add(result); + + if (!result.Success) + { + return new TasksResult { Results = results }; + } + } + + return new TasksResult { Results = results }; + } + + /// + /// Executes a task and returns detailed result information. + /// + public async Task ExecuteTaskWithResultAsync(string taskName) + { + var stopwatch = Stopwatch.StartNew(); + _logger.Debug("Starting execution of task '{TaskName}'", taskName); + + if (!_tasks.ContainsKey(taskName)) + { + _logger.Error("Task '{TaskName}' not found", taskName); + PrintTaskNotFoundError(taskName); + return TaskResult.NotFound(taskName); + } + + // Check if already completed in this session + await _executionSemaphore.WaitAsync(); + try + { + if (_completedTasks.Contains(taskName)) + { + _logger.Debug("Task '{TaskName}' already completed in this session", taskName); + return TaskResult.SkippedResult(taskName, "Already completed in this session"); + } + + if (_executingTasks.Contains(taskName)) + { + _logger.Error("Circular dependency detected for task '{TaskName}'", taskName); + Console.WriteLine($"Circular dependency detected for task '{taskName}'."); + return TaskResult.CircularDependency(taskName); + } + _executingTasks.Add(taskName); + } + finally + { + _executionSemaphore.Release(); + } + + var task = _tasks[taskName]; + + // Check conditions before executing + if (!_conditionEvaluator.Evaluate(task.Condition, taskName)) + { + var taskLabel = GetColoredTaskLabel(taskName); + Console.WriteLine($"{taskLabel} Skipped (condition not met)"); + await MarkTaskComplete(taskName); + return TaskResult.SkippedResult(taskName, "Condition not met"); + } + + // Execute dependencies + if (_allowConcurrency && task.AllowConcurrent && task.DependsOn.Length > 0) + { + _logger.Debug("Executing {Count} dependencies concurrently for task '{TaskName}'", task.DependsOn.Length, taskName); + var dependencyTasks = task.DependsOn.Select(ExecuteTaskWithResultAsync); + var dependencyResults = await Task.WhenAll(dependencyTasks); + + var failedDependency = dependencyResults.FirstOrDefault(r => !r.Success); + if (failedDependency != null) + { + await RemoveFromExecuting(taskName); + _logger.Error("One or more dependencies failed for task '{TaskName}'", taskName); + Console.WriteLine($"One or more dependencies failed for task '{taskName}'."); + return TaskResult.Failed(taskName, failedDependency.ExitCode, $"Dependency '{failedDependency.TaskName}' failed"); + } + } + else + { + foreach (var dependency in task.DependsOn) { - return result; + _logger.Debug("Executing dependency '{Dependency}' for task '{TaskName}'", dependency, taskName); + var dependencyResult = await ExecuteTaskWithResultAsync(dependency); + if (!dependencyResult.Success) + { + await RemoveFromExecuting(taskName); + _logger.Error("Dependency '{Dependency}' failed for task '{TaskName}'", dependency, taskName); + Console.WriteLine($"Dependency '{dependency}' failed for task '{taskName}'."); + return TaskResult.Failed(taskName, dependencyResult.ExitCode, $"Dependency '{dependency}' failed"); + } } } - return 0; + try + { + var taskLabel = GetColoredTaskLabel(taskName); + + // Execute pre-tasks (hooks) + if (task.PreTasks.Length > 0) + { + _logger.Debug("Executing {Count} pre-tasks for '{TaskName}'", task.PreTasks.Length, taskName); + foreach (var preTask in task.PreTasks) + { + var preResult = await ExecuteTaskWithResultAsync(preTask); + if (!preResult.Success) + { + _logger.Error("Pre-task '{PreTask}' failed for task '{TaskName}'", preTask, taskName); + Console.WriteLine($"{taskLabel} Pre-task '{preTask}' failed."); + return TaskResult.Failed(taskName, preResult.ExitCode, $"Pre-task '{preTask}' failed"); + } + } + } + + if (_dryRun) + { + PrintDryRunInfo(task, taskName, taskLabel); + return TaskResult.DryRunResult(taskName); + } + + // Check cache (unless disabled) + if (!_noCache && task.Cache != null && _cacheManager.IsCacheValid(taskName, task.Cache)) + { + Console.WriteLine($"{taskLabel} Skipped (cached)"); + await MarkTaskComplete(taskName); + + // Still run post-tasks even when cached + if (task.PostTasks.Length > 0) + { + foreach (var postTask in task.PostTasks) + { + var postResult = await ExecuteTaskWithResultAsync(postTask); + if (!postResult.Success) + { + return TaskResult.Failed(taskName, postResult.ExitCode, $"Post-task '{postTask}' failed"); + } + } + } + return TaskResult.SkippedResult(taskName, "Cached"); + } + + _logger.Info("Executing task '{TaskName}': {Command}", taskName, task.Command); + Console.WriteLine($"{taskLabel} Executing task..."); + + var taskStopwatch = Stopwatch.StartNew(); + var exitCode = await RunCommandAsync(task, taskName); + taskStopwatch.Stop(); + + if (exitCode == 0) + { + _logger.Info("Task '{TaskName}' completed successfully in {Duration}ms", taskName, taskStopwatch.ElapsedMilliseconds); + Console.WriteLine($"{taskLabel} Task completed successfully."); + + // Save cache on success + if (task.Cache != null) + { + _cacheManager.SaveCache(taskName, task.Cache); + } + + // Execute post-tasks (hooks) on success + if (task.PostTasks.Length > 0) + { + foreach (var postTask in task.PostTasks) + { + var postResult = await ExecuteTaskWithResultAsync(postTask); + if (!postResult.Success) + { + _logger.Error("Post-task '{PostTask}' failed for task '{TaskName}'", postTask, taskName); + Console.WriteLine($"{taskLabel} Post-task '{postTask}' failed."); + return TaskResult.Failed(taskName, postResult.ExitCode, $"Post-task '{postTask}' failed"); + } + } + } + + await MarkTaskComplete(taskName); + return TaskResult.Succeeded(taskName, taskStopwatch.Elapsed); + } + else if (exitCode == -1) + { + _logger.Error("Task '{TaskName}' timed out after {Timeout} seconds", taskName, task.Timeout); + Console.WriteLine($"{taskLabel} Task timed out after {task.Timeout} seconds."); + return TaskResult.TimedOut(taskName, task.Timeout ?? 0); + } + else + { + _logger.Error("Task '{TaskName}' failed with exit code {ExitCode}", taskName, exitCode); + Console.WriteLine($"{taskLabel} Task failed with exit code {exitCode}."); + return TaskResult.Failed(taskName, exitCode, $"Task failed with exit code {exitCode}", taskStopwatch.Elapsed); + } + } + finally + { + await RemoveFromExecuting(taskName); + } } private string SubstituteVariables(string input) diff --git a/Rot/TasksConfig.cs b/Rot/TasksConfig.cs index 12d6916..a35850a 100644 --- a/Rot/TasksConfig.cs +++ b/Rot/TasksConfig.cs @@ -7,5 +7,8 @@ public class TasksConfig // Phase 3: Profile support public Dictionary Profiles { get; set; } = new(); + + // Phase 4: Task aliases + public Dictionary Aliases { get; set; } = new(); }