diff --git a/Rot.Tests/LoggingTests.cs b/Rot.Tests/LoggingTests.cs new file mode 100644 index 0000000..968a349 --- /dev/null +++ b/Rot.Tests/LoggingTests.cs @@ -0,0 +1,111 @@ +using Rot.Logging; + +namespace Rot.Tests; + +public class LoggingTests +{ + [Fact] + public void ConsoleLogger_WithDebugLevel_LogsDebugMessages() + { + var logger = new ConsoleLogger(LogLevel.Debug, useColors: false); + + // Should not throw + logger.Debug("Debug message"); + logger.Info("Info message"); + logger.Warning("Warning message"); + logger.Error("Error message"); + } + + [Fact] + public void ConsoleLogger_WithErrorLevel_OnlyLogsErrors() + { + var logger = new ConsoleLogger(LogLevel.Error, useColors: false); + + // Should not throw + logger.Debug("Debug message"); + logger.Info("Info message"); + logger.Warning("Warning message"); + logger.Error("Error message"); + } + + [Fact] + public void NullLogger_DoesNothing() + { + var logger = NullLogger.Instance; + + // Should not throw + logger.Debug("Debug message"); + logger.Info("Info message"); + logger.Warning("Warning message"); + logger.Error("Error message"); + } + + [Fact] + public void FileLogger_WritesToFile() + { + var tempFile = Path.GetTempFileName(); + try + { + using (var logger = new FileLogger(tempFile, LogLevel.Debug)) + { + logger.Info("Test message"); + } + + var content = File.ReadAllText(tempFile); + Assert.Contains("Test message", content); + Assert.Contains("[Info]", content); + } + finally + { + if (File.Exists(tempFile)) + File.Delete(tempFile); + } + } + + [Fact] + public void FileLogger_WithJsonFormat_WritesJson() + { + var tempFile = Path.GetTempFileName(); + try + { + using (var logger = new FileLogger(tempFile, LogLevel.Debug, asJson: true)) + { + logger.Info("Test message"); + } + + var content = File.ReadAllText(tempFile); + Assert.Contains("\"message\":\"Test message\"", content); + Assert.Contains("\"level\":\"info\"", content); + } + finally + { + if (File.Exists(tempFile)) + File.Delete(tempFile); + } + } + + [Theory] + [InlineData(LogLevel.Debug, "debug")] + [InlineData(LogLevel.Info, "info")] + [InlineData(LogLevel.Warning, "warning")] + [InlineData(LogLevel.Error, "error")] + public void FileLogger_JsonFormat_CorrectLevelString(LogLevel level, string expectedLevel) + { + var tempFile = Path.GetTempFileName(); + try + { + using (var logger = new FileLogger(tempFile, LogLevel.Debug, asJson: true)) + { + logger.Log(level, "Test"); + } + + var content = File.ReadAllText(tempFile); + Assert.Contains($"\"level\":\"{expectedLevel}\"", content); + } + finally + { + if (File.Exists(tempFile)) + File.Delete(tempFile); + } + } +} diff --git a/Rot.Tests/Rot.Tests.csproj b/Rot.Tests/Rot.Tests.csproj new file mode 100644 index 0000000..562aee5 --- /dev/null +++ b/Rot.Tests/Rot.Tests.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + + + + + + + + diff --git a/Rot.Tests/TaskExecutorTests.cs b/Rot.Tests/TaskExecutorTests.cs new file mode 100644 index 0000000..b194d8e --- /dev/null +++ b/Rot.Tests/TaskExecutorTests.cs @@ -0,0 +1,204 @@ +using Rot.Models; +using Rot.Services; + +namespace Rot.Tests; + +public class TaskExecutorTests +{ + [Fact] + public void HasTask_ExistingTask_ReturnsTrue() + { + var tasks = new Dictionary + { + ["build"] = new TaskDefinition { Command = "echo build" } + }; + var executor = new TaskExecutor(tasks); + + Assert.True(executor.HasTask("build")); + } + + [Fact] + public void HasTask_NonExistingTask_ReturnsFalse() + { + var tasks = new Dictionary + { + ["build"] = new TaskDefinition { Command = "echo build" } + }; + var executor = new TaskExecutor(tasks); + + Assert.False(executor.HasTask("test")); + } + + [Fact] + public void GetTaskNames_ReturnsAllTaskNames() + { + var tasks = new Dictionary + { + ["build"] = new TaskDefinition { Command = "echo build" }, + ["test"] = new TaskDefinition { Command = "echo test" }, + ["deploy"] = new TaskDefinition { Command = "echo deploy" } + }; + var executor = new TaskExecutor(tasks); + + var taskNames = executor.GetTaskNames().ToList(); + + Assert.Equal(3, taskNames.Count); + Assert.Contains("build", taskNames); + Assert.Contains("test", taskNames); + Assert.Contains("deploy", taskNames); + } + + [Fact] + public async Task ExecuteTaskAsync_NonExistingTask_ReturnsOne() + { + var tasks = new Dictionary + { + ["build"] = new TaskDefinition { Command = "echo build" } + }; + var executor = new TaskExecutor(tasks); + + var result = await executor.ExecuteTaskAsync("nonexistent"); + + Assert.Equal(1, result); + } + + [Fact] + public async Task ExecuteTaskAsync_SimpleCommand_ReturnsZero() + { + var tasks = new Dictionary + { + ["echo"] = new TaskDefinition + { + Command = "echo hello", + Type = "shell" + } + }; + var executor = new TaskExecutor(tasks); + + var result = await executor.ExecuteTaskAsync("echo"); + + Assert.Equal(0, result); + } + + [Fact] + public async Task ExecuteTaskAsync_FailingCommand_ReturnsNonZero() + { + var tasks = new Dictionary + { + ["fail"] = new TaskDefinition + { + Command = "exit 1", + Type = "shell" + } + }; + var executor = new TaskExecutor(tasks); + + var result = await executor.ExecuteTaskAsync("fail"); + + Assert.NotEqual(0, result); + } + + [Fact] + public async Task ExecuteTaskAsync_DryRun_DoesNotExecute() + { + var tasks = new Dictionary + { + ["write-file"] = new TaskDefinition + { + Command = "touch /tmp/rot-test-file-should-not-exist", + Type = "shell" + } + }; + var executor = new TaskExecutor(tasks, dryRun: true); + + var result = await executor.ExecuteTaskAsync("write-file"); + + Assert.Equal(0, result); + Assert.False(File.Exists("/tmp/rot-test-file-should-not-exist")); + } + + [Fact] + public async Task ExecuteTaskAsync_WithDependency_ExecutesDependencyFirst() + { + var executionOrder = new List(); + var tasks = new Dictionary + { + ["first"] = new TaskDefinition + { + Command = "echo first", + Type = "shell" + }, + ["second"] = new TaskDefinition + { + Command = "echo second", + Type = "shell", + DependsOn = new[] { "first" } + } + }; + var executor = new TaskExecutor(tasks); + + var result = await executor.ExecuteTaskAsync("second"); + + Assert.Equal(0, result); + } + + [Fact] + public async Task ExecuteTaskAsync_WithTimeout_TimeoutKillsProcess() + { + var tasks = new Dictionary + { + ["slow"] = new TaskDefinition + { + Command = "sleep 10", + Type = "shell", + Timeout = 1 // 1 second timeout + } + }; + var executor = new TaskExecutor(tasks); + + var result = await executor.ExecuteTaskAsync("slow"); + + Assert.Equal(-1, result); // -1 indicates timeout + } + + [Fact] + public async Task ExecuteTaskAsync_ProcessType_ExecutesDirectly() + { + var tasks = new Dictionary + { + ["echo-process"] = new TaskDefinition + { + Command = "/bin/echo", + Args = new[] { "hello", "world" }, + Type = "process" + } + }; + var executor = new TaskExecutor(tasks); + + var result = await executor.ExecuteTaskAsync("echo-process"); + + Assert.Equal(0, result); + } + + [Fact] + public async Task ExecuteTaskAsync_WithEnvironmentVariables_SetsEnv() + { + var tasks = new Dictionary + { + ["env-test"] = new TaskDefinition + { + Command = "printenv MY_TEST_VAR", + Type = "shell", + Env = new Dictionary + { + ["MY_TEST_VAR"] = "test-value" + } + } + }; + var executor = new TaskExecutor(tasks); + + var result = await executor.ExecuteTaskAsync("env-test"); + + Assert.Equal(0, result); + } +} diff --git a/Rot.Tests/TaskValidatorTests.cs b/Rot.Tests/TaskValidatorTests.cs new file mode 100644 index 0000000..b1d2eff --- /dev/null +++ b/Rot.Tests/TaskValidatorTests.cs @@ -0,0 +1,174 @@ +using Rot.Models; +using Rot.Services; + +namespace Rot.Tests; + +public class TaskValidatorTests +{ + private readonly TaskValidator _validator = new(); + + [Fact] + public void Validate_ValidTask_ReturnsNoErrors() + { + var task = new TaskDefinition + { + Command = "echo hello", + Type = "shell" + }; + + var result = _validator.Validate("test", task); + + Assert.True(result.IsValid); + Assert.Empty(result.Errors); + } + + [Fact] + public void Validate_EmptyCommand_ReturnsError() + { + var task = new TaskDefinition + { + Command = "", + Type = "shell" + }; + + var result = _validator.Validate("test", task); + + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.Contains("'command' is required")); + } + + [Fact] + public void Validate_InvalidTaskType_ReturnsError() + { + var task = new TaskDefinition + { + Command = "echo hello", + Type = "invalid" + }; + + var result = _validator.Validate("test", task); + + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.Contains("Invalid task type")); + } + + [Fact] + public void Validate_NegativeTimeout_ReturnsError() + { + var task = new TaskDefinition + { + Command = "echo hello", + Timeout = -5 + }; + + var result = _validator.Validate("test", task); + + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.Contains("Timeout must be a positive number")); + } + + [Fact] + public void Validate_ZeroTimeout_ReturnsError() + { + var task = new TaskDefinition + { + Command = "echo hello", + Timeout = 0 + }; + + var result = _validator.Validate("test", task); + + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.Contains("Timeout must be a positive number")); + } + + [Fact] + public void Validate_PositiveTimeout_ReturnsNoErrors() + { + var task = new TaskDefinition + { + Command = "echo hello", + Timeout = 60 + }; + + var result = _validator.Validate("test", task); + + Assert.True(result.IsValid); + } + + [Fact] + public void ValidateAll_MissingDependency_ReturnsError() + { + var tasks = new Dictionary + { + ["build"] = new TaskDefinition + { + Command = "echo build", + DependsOn = new[] { "missing-task" } + } + }; + + var result = _validator.ValidateAll(tasks); + + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.Contains("Dependency 'missing-task' not found")); + } + + [Fact] + public void ValidateAll_CircularDependency_ReturnsError() + { + var tasks = new Dictionary + { + ["task-a"] = new TaskDefinition + { + Command = "echo a", + DependsOn = new[] { "task-b" } + }, + ["task-b"] = new TaskDefinition + { + Command = "echo b", + DependsOn = new[] { "task-a" } + } + }; + + var result = _validator.ValidateAll(tasks); + + Assert.False(result.IsValid); + Assert.Contains(result.Errors, e => e.Contains("Circular dependency")); + } + + [Fact] + public void ValidateAll_EmptyTasks_ReturnsWarning() + { + var tasks = new Dictionary(); + + var result = _validator.ValidateAll(tasks); + + Assert.True(result.IsValid); + Assert.Contains(result.Warnings, w => w.Contains("No tasks defined")); + } + + [Fact] + public void ValidateAll_ValidTaskGraph_ReturnsNoErrors() + { + var tasks = new Dictionary + { + ["clean"] = new TaskDefinition { Command = "echo clean" }, + ["build"] = new TaskDefinition + { + Command = "echo build", + DependsOn = new[] { "clean" } + }, + ["test"] = new TaskDefinition + { + Command = "echo test", + DependsOn = new[] { "build" } + } + }; + + var result = _validator.ValidateAll(tasks); + + Assert.True(result.IsValid); + Assert.Empty(result.Errors); + } +} diff --git a/Rot.sln b/Rot.sln index fb364f2..36e7955 100644 --- a/Rot.sln +++ b/Rot.sln @@ -4,6 +4,8 @@ VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Rot", "Rot\Rot.csproj", "{CC78AB89-0AE6-4297-A659-33EDAAD38CCC}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Rot.Tests", "Rot.Tests\Rot.Tests.csproj", "{D1E7D8A0-5F3E-4C2B-9A1F-2B8C4D5E6F7A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -26,6 +28,18 @@ Global {CC78AB89-0AE6-4297-A659-33EDAAD38CCC}.Release|x64.Build.0 = Release|Any CPU {CC78AB89-0AE6-4297-A659-33EDAAD38CCC}.Release|x86.ActiveCfg = Release|Any CPU {CC78AB89-0AE6-4297-A659-33EDAAD38CCC}.Release|x86.Build.0 = Release|Any CPU + {D1E7D8A0-5F3E-4C2B-9A1F-2B8C4D5E6F7A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D1E7D8A0-5F3E-4C2B-9A1F-2B8C4D5E6F7A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D1E7D8A0-5F3E-4C2B-9A1F-2B8C4D5E6F7A}.Debug|x64.ActiveCfg = Debug|Any CPU + {D1E7D8A0-5F3E-4C2B-9A1F-2B8C4D5E6F7A}.Debug|x64.Build.0 = Debug|Any CPU + {D1E7D8A0-5F3E-4C2B-9A1F-2B8C4D5E6F7A}.Debug|x86.ActiveCfg = Debug|Any CPU + {D1E7D8A0-5F3E-4C2B-9A1F-2B8C4D5E6F7A}.Debug|x86.Build.0 = Debug|Any CPU + {D1E7D8A0-5F3E-4C2B-9A1F-2B8C4D5E6F7A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D1E7D8A0-5F3E-4C2B-9A1F-2B8C4D5E6F7A}.Release|Any CPU.Build.0 = Release|Any CPU + {D1E7D8A0-5F3E-4C2B-9A1F-2B8C4D5E6F7A}.Release|x64.ActiveCfg = Release|Any CPU + {D1E7D8A0-5F3E-4C2B-9A1F-2B8C4D5E6F7A}.Release|x64.Build.0 = Release|Any CPU + {D1E7D8A0-5F3E-4C2B-9A1F-2B8C4D5E6F7A}.Release|x86.ActiveCfg = Release|Any CPU + {D1E7D8A0-5F3E-4C2B-9A1F-2B8C4D5E6F7A}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Rot/Logging/ConsoleLogger.cs b/Rot/Logging/ConsoleLogger.cs new file mode 100644 index 0000000..62ab112 --- /dev/null +++ b/Rot/Logging/ConsoleLogger.cs @@ -0,0 +1,97 @@ +namespace Rot.Logging; + +public class ConsoleLogger : ITaskLogger +{ + private readonly LogLevel _minimumLevel; + private readonly bool _useColors; + + public ConsoleLogger(LogLevel minimumLevel = LogLevel.Info, bool useColors = true) + { + _minimumLevel = minimumLevel; + _useColors = useColors; + } + + public void Log(LogLevel level, string message, params object[] args) + { + if (level < _minimumLevel) return; + + var formattedMessage = args.Length > 0 ? FormatMessage(message, args) : message; + var timestamp = DateTime.Now.ToString("HH:mm:ss"); + var levelStr = GetLevelString(level); + + if (_useColors) + { + var color = GetLevelColor(level); + Console.ForegroundColor = color; + Console.WriteLine($"[{timestamp}] [{levelStr}] {formattedMessage}"); + Console.ResetColor(); + } + else + { + Console.WriteLine($"[{timestamp}] [{levelStr}] {formattedMessage}"); + } + } + + public void Debug(string message, params object[] args) => Log(LogLevel.Debug, message, args); + public void Info(string message, params object[] args) => Log(LogLevel.Info, message, args); + public void Warning(string message, params object[] args) => Log(LogLevel.Warning, message, args); + public void Error(string message, params object[] args) => Log(LogLevel.Error, message, args); + + private static string FormatMessage(string message, object[] args) + { + // Simple named parameter replacement: {ParamName} -> value + var result = message; + for (int i = 0; i < args.Length; i++) + { + var placeholder = $"{{{i}}}"; + if (result.Contains(placeholder)) + { + result = result.Replace(placeholder, args[i]?.ToString() ?? "null"); + } + } + + // Also handle named placeholders like {TaskName} + var index = 0; + while (result.Contains('{') && result.Contains('}') && index < args.Length) + { + var start = result.IndexOf('{'); + var end = result.IndexOf('}', start); + if (start >= 0 && end > start) + { + var placeholder = result.Substring(start, end - start + 1); + result = result.Replace(placeholder, args[index]?.ToString() ?? "null"); + index++; + } + else + { + break; + } + } + + return result; + } + + private static string GetLevelString(LogLevel level) + { + return level switch + { + LogLevel.Debug => "DBG", + LogLevel.Info => "INF", + LogLevel.Warning => "WRN", + LogLevel.Error => "ERR", + _ => "???" + }; + } + + private static ConsoleColor GetLevelColor(LogLevel level) + { + return level switch + { + LogLevel.Debug => ConsoleColor.Gray, + LogLevel.Info => ConsoleColor.White, + LogLevel.Warning => ConsoleColor.Yellow, + LogLevel.Error => ConsoleColor.Red, + _ => ConsoleColor.White + }; + } +} diff --git a/Rot/Logging/FileLogger.cs b/Rot/Logging/FileLogger.cs new file mode 100644 index 0000000..8a78eaf --- /dev/null +++ b/Rot/Logging/FileLogger.cs @@ -0,0 +1,97 @@ +using System.Text.Json; + +namespace Rot.Logging; + +public class FileLogger : ITaskLogger, IDisposable +{ + private readonly string _filePath; + private readonly LogLevel _minimumLevel; + private readonly bool _asJson; + private readonly StreamWriter _writer; + private readonly object _lock = new(); + private bool _disposed; + + public FileLogger(string filePath, LogLevel minimumLevel = LogLevel.Info, bool asJson = false) + { + _filePath = filePath; + _minimumLevel = minimumLevel; + _asJson = asJson; + _writer = new StreamWriter(filePath, append: true) { AutoFlush = true }; + } + + public void Log(LogLevel level, string message, params object[] args) + { + if (level < _minimumLevel) return; + + var formattedMessage = args.Length > 0 ? FormatMessage(message, args) : message; + var timestamp = DateTime.UtcNow; + + string line; + if (_asJson) + { + var logEntry = new + { + timestamp = timestamp.ToString("o"), + level = level.ToString().ToLower(), + message = formattedMessage + }; + line = JsonSerializer.Serialize(logEntry); + } + else + { + line = $"[{timestamp:yyyy-MM-dd HH:mm:ss.fff}] [{level}] {formattedMessage}"; + } + + lock (_lock) + { + _writer.WriteLine(line); + } + } + + public void Debug(string message, params object[] args) => Log(LogLevel.Debug, message, args); + public void Info(string message, params object[] args) => Log(LogLevel.Info, message, args); + public void Warning(string message, params object[] args) => Log(LogLevel.Warning, message, args); + public void Error(string message, params object[] args) => Log(LogLevel.Error, message, args); + + private static string FormatMessage(string message, object[] args) + { + var result = message; + var index = 0; + while (result.Contains('{') && result.Contains('}') && index < args.Length) + { + var start = result.IndexOf('{'); + var end = result.IndexOf('}', start); + if (start >= 0 && end > start) + { + var placeholder = result.Substring(start, end - start + 1); + result = result.Replace(placeholder, args[index]?.ToString() ?? "null"); + index++; + } + else + { + break; + } + } + return result; + } + + public void Dispose() + { + if (!_disposed) + { + _writer.Dispose(); + _disposed = true; + } + } +} + +public class NullLogger : ITaskLogger +{ + public static readonly NullLogger Instance = new(); + + public void Log(LogLevel level, string message, params object[] args) { } + public void Debug(string message, params object[] args) { } + public void Info(string message, params object[] args) { } + public void Warning(string message, params object[] args) { } + public void Error(string message, params object[] args) { } +} diff --git a/Rot/Logging/ITaskLogger.cs b/Rot/Logging/ITaskLogger.cs new file mode 100644 index 0000000..1a528f7 --- /dev/null +++ b/Rot/Logging/ITaskLogger.cs @@ -0,0 +1,18 @@ +namespace Rot.Logging; + +public enum LogLevel +{ + Debug, + Info, + Warning, + Error +} + +public interface ITaskLogger +{ + void Log(LogLevel level, string message, params object[] args); + void Debug(string message, params object[] args); + void Info(string message, params object[] args); + void Warning(string message, params object[] args); + void Error(string message, params object[] args); +} diff --git a/Rot/Program.cs b/Rot/Program.cs index efee71b..3e792cc 100644 --- a/Rot/Program.cs +++ b/Rot/Program.cs @@ -1,4 +1,5 @@ using System.CommandLine; +using Rot.Logging; using Rot.Services; var fileOption = new Option( @@ -11,6 +12,26 @@ description: "Enable concurrent execution of tasks", getDefaultValue: () => true); +var dryRunOption = new Option( + aliases: ["--dry-run", "-n"], + description: "Preview what would be executed without running commands", + getDefaultValue: () => false); + +var verboseOption = new Option( + aliases: ["--verbose", "-v"], + description: "Show detailed execution information", + getDefaultValue: () => false); + +var quietOption = new Option( + aliases: ["--quiet", "-q"], + description: "Only show errors", + getDefaultValue: () => false); + +var logFileOption = new Option( + aliases: ["--log-file"], + description: "Write logs to a file", + getDefaultValue: () => null); + var listCommand = new Command("list", "List all available tasks") { fileOption @@ -19,7 +40,11 @@ var runCommand = new Command("run", "Run a specific task") { fileOption, - concurrentOption + concurrentOption, + dryRunOption, + verboseOption, + quietOption, + logFileOption }; var initCommand = new Command("init", "Initialize a new tasks file"); @@ -38,7 +63,8 @@ runCommand, initCommand, fileOption, - concurrentOption + concurrentOption, + dryRunOption }; listCommand.SetHandler((string file) => @@ -56,12 +82,14 @@ } }, fileOption); -runCommand.SetHandler(async (string file, string task, bool concurrent) => +runCommand.SetHandler(async (string file, string task, bool concurrent, bool dryRun, bool verbose, bool quiet, string? logFile) => { try { - var executor = TaskExecutor.LoadFromFile(file, concurrent); + ITaskLogger logger = CreateLogger(verbose, quiet, logFile); + var executor = TaskExecutor.LoadFromFile(file, concurrent, dryRun, logger); var result = await executor.ExecuteTaskAsync(task); + (logger as IDisposable)?.Dispose(); Environment.Exit(result); } catch (Exception ex) @@ -69,7 +97,7 @@ Console.Error.WriteLine($"Error: {ex.Message}"); Environment.Exit(1); } -}, fileOption, taskArgument, concurrentOption); +}, fileOption, taskArgument, concurrentOption, dryRunOption, verboseOption, quietOption, logFileOption); initCommand.SetHandler((string format) => { @@ -182,41 +210,54 @@ } }, formatOption); -rootCommand.SetHandler((string file, bool concurrent) => +rootCommand.SetHandler((string file, bool concurrent, bool dryRun) => { // Show help when no arguments provided Console.WriteLine("Use 'rot --help' for usage information."); -}, fileOption, concurrentOption); +}, fileOption, concurrentOption, dryRunOption); + +ITaskLogger CreateLogger(bool verbose, bool quiet, string? logFile) +{ + LogLevel level; + if (quiet) + level = LogLevel.Error; + else if (verbose) + level = LogLevel.Debug; + else + level = LogLevel.Info; + + if (!string.IsNullOrEmpty(logFile)) + { + return new FileLogger(logFile, level); + } + + return new ConsoleLogger(level); +} // Pre-process arguments to handle direct task execution if (args.Length > 0) { var firstArg = args[0]; - + // Skip if it's a known command or starts with -- (option) - if (firstArg != "list" && firstArg != "run" && firstArg != "init" && !firstArg.StartsWith("--")) + if (firstArg != "list" && firstArg != "run" && firstArg != "init" && !firstArg.StartsWith("--") && !firstArg.StartsWith("-")) { try { // Try to load tasks and see if first argument matches a task name var file = File.Exists("tasks.yaml") ? "tasks.yaml" : "tasks.json"; - var concurrent = true; - - // Parse file and concurrent options from remaining args + + // Parse file option from remaining args for (int i = 1; i < args.Length; i++) { if ((args[i] == "--file" || args[i] == "-f") && i + 1 < args.Length) { file = args[i + 1]; - i++; // Skip the value - } - else if (args[i] == "--concurrent" || args[i] == "-c") - { - concurrent = true; + break; } } - - var executor = TaskExecutor.LoadFromFile(file, concurrent); + + var executor = TaskExecutor.LoadFromFile(file); if (executor.HasTask(firstArg)) { // Insert "run" command to handle task execution through normal flow diff --git a/Rot/TaskDefinition.cs b/Rot/TaskDefinition.cs index cc0d4c8..87dfecc 100644 --- a/Rot/TaskDefinition.cs +++ b/Rot/TaskDefinition.cs @@ -12,5 +12,6 @@ public class TaskDefinition public bool Echo { get; set; } = true; public string[] DependsOn { get; set; } = Array.Empty(); public bool AllowConcurrent { get; set; } = false; + public int? Timeout { get; set; } } diff --git a/Rot/TaskExecutor.cs b/Rot/TaskExecutor.cs index f6b07d4..f70202a 100644 --- a/Rot/TaskExecutor.cs +++ b/Rot/TaskExecutor.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Text.Json; +using Rot.Logging; using Rot.Models; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; @@ -12,14 +13,26 @@ public class TaskExecutor private readonly HashSet _executingTasks = new(); private readonly SemaphoreSlim _executionSemaphore = new(1, 1); private readonly bool _allowConcurrency; + private readonly bool _dryRun; + private readonly ITaskLogger _logger; - public TaskExecutor(Dictionary tasks, bool allowConcurrency = false) + public TaskExecutor( + Dictionary tasks, + bool allowConcurrency = false, + bool dryRun = false, + ITaskLogger? logger = null) { _tasks = tasks; _allowConcurrency = allowConcurrency; + _dryRun = dryRun; + _logger = logger ?? NullLogger.Instance; } - public static TaskExecutor LoadFromFile(string filePath, bool allowConcurrency = false) + public static TaskExecutor LoadFromFile( + string filePath, + bool allowConcurrency = false, + bool dryRun = false, + ITaskLogger? logger = null) { if (!File.Exists(filePath)) throw new FileNotFoundException($"Tasks file not found: {filePath}"); @@ -50,14 +63,42 @@ public static TaskExecutor LoadFromFile(string filePath, bool allowConcurrency = throw new NotSupportedException($"File extension '{extension}' is not supported. Use .json, .yaml, or .yml files."); } - return new TaskExecutor(config?.Tasks ?? new Dictionary(), allowConcurrency); + var tasks = config?.Tasks ?? new Dictionary(); + + // Validate configuration + var validator = new TaskValidator(); + var validationResult = validator.ValidateAll(tasks); + + foreach (var warning in validationResult.Warnings) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"Warning: {warning}"); + Console.ResetColor(); + } + + if (!validationResult.IsValid) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine("Configuration validation failed:"); + foreach (var error in validationResult.Errors) + { + Console.WriteLine($" - {error}"); + } + Console.ResetColor(); + throw new InvalidOperationException("Task configuration is invalid."); + } + + return new TaskExecutor(tasks, allowConcurrency, dryRun, logger); } public async Task ExecuteTaskAsync(string taskName) { + _logger.Debug("Starting execution of task '{TaskName}'", taskName); + if (!_tasks.ContainsKey(taskName)) { - Console.WriteLine($"Task '{taskName}' not found."); + _logger.Error("Task '{TaskName}' not found", taskName); + PrintTaskNotFoundError(taskName); return 1; } @@ -66,6 +107,7 @@ public async Task ExecuteTaskAsync(string taskName) { if (_executingTasks.Contains(taskName)) { + _logger.Error("Circular dependency detected for task '{TaskName}'", taskName); Console.WriteLine($"Circular dependency detected for task '{taskName}'."); return 1; } @@ -77,17 +119,19 @@ public async Task ExecuteTaskAsync(string taskName) } var task = _tasks[taskName]; - + 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 _executionSemaphore.WaitAsync(); try { _executingTasks.Remove(taskName); } finally { _executionSemaphore.Release(); } + _logger.Error("One or more dependencies failed for task '{TaskName}'", taskName); Console.WriteLine($"One or more dependencies failed for task '{taskName}'."); return failedDependency; } @@ -96,11 +140,13 @@ public async Task ExecuteTaskAsync(string taskName) { 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 _executionSemaphore.WaitAsync(); try { _executingTasks.Remove(taskName); } finally { _executionSemaphore.Release(); } + _logger.Error("Dependency '{Dependency}' failed for task '{TaskName}'", dependency, taskName); Console.WriteLine($"Dependency '{dependency}' failed for task '{taskName}'."); return dependencyResult; } @@ -110,16 +156,33 @@ public async Task ExecuteTaskAsync(string taskName) try { var taskLabel = GetColoredTaskLabel(taskName); + + if (_dryRun) + { + PrintDryRunInfo(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."); } + 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}."); } @@ -231,15 +294,126 @@ private async Task RunCommandAsync(TaskDefinition task, string taskName) process.BeginErrorReadLine(); } + if (task.Timeout.HasValue && task.Timeout.Value > 0) + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(task.Timeout.Value)); + try + { + await process.WaitForExitAsync(cts.Token); + return process.ExitCode; + } + catch (OperationCanceledException) + { + try + { + process.Kill(entireProcessTree: true); + } + catch + { + // Process may have already exited + } + return -1; + } + } + await process.WaitForExitAsync(); return process.ExitCode; } + private void PrintDryRunInfo(TaskDefinition task, string taskName, string taskLabel) + { + Console.WriteLine($"{taskLabel} [DRY RUN] Would execute:"); + Console.WriteLine($" Command: {task.Command}"); + if (task.Args.Length > 0) + { + Console.WriteLine($" Arguments: {string.Join(" ", task.Args)}"); + } + if (!string.IsNullOrEmpty(task.Cwd)) + { + Console.WriteLine($" Working directory: {task.Cwd}"); + } + if (task.Env.Count > 0) + { + Console.WriteLine($" Environment:"); + foreach (var env in task.Env) + { + Console.WriteLine($" {env.Key}={env.Value}"); + } + } + if (task.Timeout.HasValue) + { + Console.WriteLine($" Timeout: {task.Timeout.Value} seconds"); + } + if (task.DependsOn.Length > 0) + { + Console.WriteLine($" Dependencies: {string.Join(", ", task.DependsOn)}"); + } + } + public bool HasTask(string taskName) { return _tasks.ContainsKey(taskName); } + public IEnumerable GetTaskNames() + { + return _tasks.Keys; + } + + private void PrintTaskNotFoundError(string taskName) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"Task '{taskName}' not found."); + Console.ResetColor(); + + var availableTasks = _tasks.Keys.ToList(); + if (availableTasks.Count > 0) + { + Console.WriteLine($"Available tasks: {string.Join(", ", availableTasks)}"); + + // Suggest similar task names + var similar = FindSimilarTasks(taskName, availableTasks); + if (similar.Count > 0) + { + Console.WriteLine($"Did you mean: {string.Join(", ", similar)}?"); + } + } + } + + private List FindSimilarTasks(string input, List taskNames) + { + return taskNames + .Select(name => (name, distance: LevenshteinDistance(input.ToLower(), name.ToLower()))) + .Where(x => x.distance <= 3) + .OrderBy(x => x.distance) + .Take(3) + .Select(x => x.name) + .ToList(); + } + + private static int LevenshteinDistance(string s1, string s2) + { + var m = s1.Length; + var n = s2.Length; + var dp = new int[m + 1, n + 1]; + + for (int i = 0; i <= m; i++) dp[i, 0] = i; + for (int j = 0; j <= n; j++) dp[0, j] = j; + + for (int i = 1; i <= m; i++) + { + for (int j = 1; j <= n; j++) + { + var cost = s1[i - 1] == s2[j - 1] ? 0 : 1; + dp[i, j] = Math.Min( + Math.Min(dp[i - 1, j] + 1, dp[i, j - 1] + 1), + dp[i - 1, j - 1] + cost); + } + } + + return dp[m, n]; + } + public void ListTasks() { Console.WriteLine("Available tasks:"); diff --git a/Rot/TaskValidator.cs b/Rot/TaskValidator.cs new file mode 100644 index 0000000..0d89eb0 --- /dev/null +++ b/Rot/TaskValidator.cs @@ -0,0 +1,150 @@ +using Rot.Models; + +namespace Rot.Services; + +public class ValidationResult +{ + public bool IsValid => Errors.Count == 0; + public List Errors { get; } = new(); + public List Warnings { get; } = new(); + + public void AddError(string error) => Errors.Add(error); + public void AddWarning(string warning) => Warnings.Add(warning); +} + +public class TaskValidator +{ + private static readonly HashSet ValidTaskTypes = new(StringComparer.OrdinalIgnoreCase) + { + "shell", + "process" + }; + + public ValidationResult Validate(string taskName, TaskDefinition task) + { + var result = new ValidationResult(); + + if (string.IsNullOrWhiteSpace(taskName)) + { + result.AddError("Task name cannot be empty."); + } + + if (string.IsNullOrWhiteSpace(task.Command)) + { + result.AddError($"Task '{taskName}': 'command' is required."); + } + + if (!string.IsNullOrEmpty(task.Type) && !ValidTaskTypes.Contains(task.Type)) + { + result.AddError($"Task '{taskName}': Invalid task type '{task.Type}'. Valid types are: {string.Join(", ", ValidTaskTypes)}."); + } + + if (task.Timeout.HasValue && task.Timeout.Value <= 0) + { + result.AddError($"Task '{taskName}': Timeout must be a positive number (got {task.Timeout.Value})."); + } + + if (!string.IsNullOrEmpty(task.Cwd) && !Directory.Exists(task.Cwd)) + { + result.AddWarning($"Task '{taskName}': Working directory '{task.Cwd}' does not exist."); + } + + return result; + } + + public ValidationResult ValidateAll(Dictionary tasks) + { + var result = new ValidationResult(); + + if (tasks.Count == 0) + { + result.AddWarning("No tasks defined in configuration."); + return result; + } + + foreach (var (taskName, task) in tasks) + { + var taskResult = Validate(taskName, task); + foreach (var error in taskResult.Errors) + { + result.AddError(error); + } + foreach (var warning in taskResult.Warnings) + { + result.AddWarning(warning); + } + } + + // Validate dependencies + foreach (var (taskName, task) in tasks) + { + foreach (var dependency in task.DependsOn) + { + if (!tasks.ContainsKey(dependency)) + { + result.AddError($"Task '{taskName}': Dependency '{dependency}' not found."); + } + } + } + + // Detect circular dependencies + var circularDeps = DetectCircularDependencies(tasks); + foreach (var cycle in circularDeps) + { + result.AddError($"Circular dependency detected: {cycle}"); + } + + return result; + } + + private List DetectCircularDependencies(Dictionary tasks) + { + var cycles = new List(); + var visited = new HashSet(); + var recursionStack = new HashSet(); + var path = new List(); + + foreach (var taskName in tasks.Keys) + { + if (!visited.Contains(taskName)) + { + DetectCycleDfs(taskName, tasks, visited, recursionStack, path, cycles); + } + } + + return cycles; + } + + private void DetectCycleDfs( + string taskName, + Dictionary tasks, + HashSet visited, + HashSet recursionStack, + List path, + List cycles) + { + visited.Add(taskName); + recursionStack.Add(taskName); + path.Add(taskName); + + if (tasks.TryGetValue(taskName, out var task)) + { + foreach (var dependency in task.DependsOn) + { + if (!visited.Contains(dependency)) + { + DetectCycleDfs(dependency, tasks, visited, recursionStack, path, cycles); + } + else if (recursionStack.Contains(dependency)) + { + var cycleStart = path.IndexOf(dependency); + var cycle = string.Join(" -> ", path.Skip(cycleStart)) + " -> " + dependency; + cycles.Add(cycle); + } + } + } + + path.RemoveAt(path.Count - 1); + recursionStack.Remove(taskName); + } +}