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);
+ }
+}