diff --git a/Directory.Packages.props b/Directory.Packages.props index e3a994d..d719be5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,7 +3,8 @@ true + - + \ No newline at end of file diff --git a/README.md b/README.md index 3c705a2..a161808 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,8 @@ All functions support switching between Docker image variants using flags: - **`-h` or `--help`** - Show usage help and examples - **`--no-cleanup`** - Skip cleanup of unused Docker images - **`--no-pull`** - Skip pulling the latest image -- **`--mount `** - Mount a directory as read-only -- **`--mount-rw `** - Mount a directory as read-write +- **`--mount `** - Mount a directory as read-only (supports `path` or `host:container` format) +- **`--mount-rw `** - Mount a directory as read-write (supports `path` or `host:container` format) - **`--save-mount `** - Save a mount to local config - **`--save-mount-global `** - Save a mount to global config - **`--remove-mount `** - Remove a saved mount @@ -78,8 +78,10 @@ These options are passed directly to the GitHub Copilot CLI: By default, `copilot_here` only mounts the current working directory. You can mount additional directories using flags or configuration files. **CLI Flags:** -- `--mount ./path/to/dir` (Read-only) -- `--mount-rw ./path/to/dir` (Read-write) +- `--mount ./path/to/dir` (Read-only, auto-computed container path) +- `--mount-rw ./path/to/dir` (Read-write, auto-computed container path) +- `--mount /host/path:/container/path` (Read-only, custom container path) +- `--mount-rw /host/path:/container/path` (Read-write, custom container path) **Configuration Files:** - Global: `~/.config/copilot_here/mounts.conf` diff --git a/app/Commands/Mounts/ListMounts.cs b/app/Commands/Mounts/ListMounts.cs index 704e4d7..89d95aa 100644 --- a/app/Commands/Mounts/ListMounts.cs +++ b/app/Commands/Mounts/ListMounts.cs @@ -28,13 +28,13 @@ private static Command SetListMountsCommand() foreach (var mount in config.GlobalMounts) { var mode = mount.IsReadWrite ? "rw" : "ro"; - Console.WriteLine($" 🌍 {mount.Path} ({mode})"); + Console.WriteLine($" 🌍 {mount.HostPath} ({mode})"); } foreach (var mount in config.LocalMounts) { var mode = mount.IsReadWrite ? "rw" : "ro"; - Console.WriteLine($" 📍 {mount.Path} ({mode})"); + Console.WriteLine($" 📍 {mount.HostPath} ({mode})"); } Console.WriteLine(); diff --git a/app/Commands/Mounts/_MountsConfig.cs b/app/Commands/Mounts/_MountsConfig.cs index 1091256..1d5512a 100644 --- a/app/Commands/Mounts/_MountsConfig.cs +++ b/app/Commands/Mounts/_MountsConfig.cs @@ -36,19 +36,32 @@ private static List LoadFromFile(string path, MountSource source) foreach (var line in ConfigFile.ReadLines(path)) { var isReadWrite = false; - var mountPath = line; + var workingLine = line; - if (line.EndsWith(":rw", StringComparison.OrdinalIgnoreCase)) + // Check for :rw or :ro suffix + if (workingLine.EndsWith(":rw", StringComparison.OrdinalIgnoreCase)) { isReadWrite = true; - mountPath = line[..^3]; + workingLine = workingLine[..^3]; } - else if (line.EndsWith(":ro", StringComparison.OrdinalIgnoreCase)) + else if (workingLine.EndsWith(":ro", StringComparison.OrdinalIgnoreCase)) { - mountPath = line[..^3]; + workingLine = workingLine[..^3]; } - mounts.Add(new MountEntry(mountPath, isReadWrite, source)); + // Parse hostPath:containerPath format + var colonIndex = workingLine.IndexOf(':'); + if (colonIndex > 0) + { + var hostPath = workingLine[..colonIndex]; + var containerPath = workingLine[(colonIndex + 1)..]; + mounts.Add(new MountEntry(hostPath, containerPath, isReadWrite, source)); + } + else + { + var hostPath = workingLine; + mounts.Add(new MountEntry(hostPath, null, isReadWrite, source)); + } } return mounts; @@ -133,14 +146,35 @@ private static bool RemoveFromFile(string configPath, string pathToRemove) /// /// A single mount entry with its path, mode, and source. /// -public readonly record struct MountEntry(string Path, bool IsReadWrite, MountSource Source) +public readonly record struct MountEntry { + /// + /// A single mount entry with its path, mode, and source. + /// + public MountEntry(string HostPath, bool IsReadWrite, MountSource Source) + { + this.HostPath = HostPath; + this.IsReadWrite = IsReadWrite; + this.Source = Source; + } + + /// + /// A single mount entry with its host/container paths, mode, and source. + /// + public MountEntry(string HostPath, string ContainerPath, bool IsReadWrite, MountSource Source) + { + this.HostPath = HostPath; + this.ContainerPath = ContainerPath; + this.IsReadWrite = IsReadWrite; + this.Source = Source; + } + /// /// Resolves ~ and relative paths to absolute paths, following symlinks. /// - public string ResolvePath(string userHome) + public string ResolveHostPath(string userHome) { - return PathValidator.ResolvePath(Path, userHome); + return PathValidator.ResolvePath(HostPath, userHome); } /// @@ -149,7 +183,7 @@ public string ResolvePath(string userHome) /// public bool Validate(string userHome) { - var resolved = ResolvePath(userHome); + var resolved = ResolveHostPath(userHome); // Warn if path doesn't exist PathValidator.WarnIfNotExists(resolved); @@ -166,7 +200,13 @@ public bool Validate(string userHome) /// Calculates the container path for this mount. public string GetContainerPath(string userHome) { - var hostPath = ResolvePath(userHome); + // If custom container path is specified, expand tilde to container user home + if (!string.IsNullOrWhiteSpace(ContainerPath)) + { + return ExpandContainerTilde(ContainerPath); + } + + var hostPath = ResolveHostPath(userHome); if (hostPath.StartsWith(userHome)) { return $"/home/appuser/{System.IO.Path.GetRelativePath(userHome, hostPath).Replace("\\", "/")}"; @@ -185,10 +225,28 @@ public string GetContainerPath(string userHome) return $"/work{hostPath}"; } + /// + /// Expands tilde in container paths to /home/appuser (the container user's home). + /// + private static string ExpandContainerTilde(string containerPath) + { + if (containerPath == "~") + { + return "/home/appuser"; + } + + if (containerPath.StartsWith("~/")) + { + return $"/home/appuser/{containerPath.Substring(2)}"; + } + + return containerPath; + } + /// Gets the Docker volume mount string. public string ToDockerVolume(string userHome) { - var hostPath = ResolvePath(userHome); + var hostPath = ResolveHostPath(userHome); var dockerHostPath = ConvertToDockerPath(hostPath); var containerPath = GetContainerPath(userHome); var mode = IsReadWrite ? "rw" : "ro"; @@ -217,6 +275,11 @@ private static string ConvertToDockerPath(string path) return normalizedPath; } + + public string HostPath { get; init; } + public bool IsReadWrite { get; init; } + public MountSource Source { get; init; } + public string? ContainerPath { get; init; } } public enum MountSource diff --git a/app/Commands/Run/RunCommand.cs b/app/Commands/Run/RunCommand.cs index 444af2a..df01b6d 100644 --- a/app/Commands/Run/RunCommand.cs +++ b/app/Commands/Run/RunCommand.cs @@ -99,9 +99,9 @@ public RunCommand(bool isYolo = false) _golangOption = new Option("--golang") { Description = "[-go] Use Golang image variant" }; - _mountOption = new Option("--mount") { Description = "Mount additional directory (read-only)" }; + _mountOption = new Option("--mount") { Description = "Mount directory (read-only). Format: path or host:container" }; - _mountRwOption = new Option("--mount-rw") { Description = "Mount additional directory (read-write)" }; + _mountRwOption = new Option("--mount-rw") { Description = "Mount directory (read-write). Format: path or host:container" }; _noCleanupOption = new Option("--no-cleanup") { Description = "Skip cleanup of unused Docker images" }; @@ -447,6 +447,7 @@ public void Configure(RootCommand root) var allMounts = new List(); // Parse CLI mounts first (highest priority) + // Supports both simple paths and host:container format foreach (var path in cliMountsRo) allMounts.Add(ParseCliMount(path, defaultReadWrite: false)); foreach (var path in cliMountsRw) @@ -469,7 +470,7 @@ public void Configure(RootCommand root) else { // User cancelled due to sensitive path - skip this mount - Console.WriteLine($"⏭️ Skipping mount: {mount.Path}"); + Console.WriteLine($"⏭️ Skipping mount: {mount.HostPath}"); } } allMounts = validatedMounts; @@ -622,26 +623,60 @@ private static List BuildDockerArgs( } /// - /// Parses a CLI mount path, handling :rw/:ro suffix. - /// Format: "path" or "path:rw" or "path:ro" + /// Parses a CLI mount path, handling both simple paths and host:container format. + /// Format: "path", "path:rw", "path:ro", "host:container", "host:container:rw", "host:container:ro" /// - private static MountEntry ParseCliMount(string input, bool defaultReadWrite) + internal static MountEntry ParseCliMount(string input, bool defaultReadWrite) { var isReadWrite = defaultReadWrite; - var mountPath = input; + var spec = input.Trim('\'', '"'); // Remove any surrounding quotes - if (input.EndsWith(":rw", StringComparison.OrdinalIgnoreCase)) + // Check for trailing :rw or :ro + if (spec.EndsWith(":rw", StringComparison.OrdinalIgnoreCase)) { isReadWrite = true; - mountPath = input[..^3]; + spec = spec[..^3]; } - else if (input.EndsWith(":ro", StringComparison.OrdinalIgnoreCase)) + else if (spec.EndsWith(":ro", StringComparison.OrdinalIgnoreCase)) { isReadWrite = false; - mountPath = input[..^3]; + spec = spec[..^3]; } - return new MountEntry(mountPath, isReadWrite, MountSource.CommandLine); + // Check if this is a host:container format + var separatorIndex = FindBindSeparator(spec); + + if (separatorIndex == -1) + { + // Simple path format - auto-compute container path + return new MountEntry(spec, isReadWrite, MountSource.CommandLine); + } + + // host:container format + var hostPath = spec[..separatorIndex]; + var containerPath = spec[(separatorIndex + 1)..]; + + return new MountEntry(hostPath, containerPath, isReadWrite, MountSource.CommandLine); + } + + /// + /// Finds the separator between host and container paths in a bind spec. + /// On Windows, skips the colon after drive letter (e.g., C:\path). + /// + private static int FindBindSeparator(string bindSpec) + { + var startIndex = 0; + + // On Windows, skip past drive letter colon (e.g., C:) + if (OperatingSystem.IsWindows() && + bindSpec.Length >= 2 && + char.IsLetter(bindSpec[0]) && + bindSpec[1] == ':') + { + startIndex = 2; + } + + return bindSpec.IndexOf(':', startIndex); } /// diff --git a/app/Infrastructure/AirlockRunner.cs b/app/Infrastructure/AirlockRunner.cs index 20af041..c44a26d 100644 --- a/app/Infrastructure/AirlockRunner.cs +++ b/app/Infrastructure/AirlockRunner.cs @@ -244,7 +244,7 @@ private static void SetupLogsDirectory(AppPaths paths, string rulesPath) { var mode = mount.IsReadWrite ? "rw" : "ro"; var containerPath = mount.GetContainerPath(ctx.Paths.UserHome); - var resolvedPath = mount.ResolvePath(ctx.Paths.UserHome); + var resolvedPath = mount.ResolveHostPath(ctx.Paths.UserHome); var dockerPath = ConvertToDockerPath(resolvedPath); extraMounts.AppendLine($" - {dockerPath}:{containerPath}:{mode}"); } diff --git a/app/Infrastructure/SessionInfo.cs b/app/Infrastructure/SessionInfo.cs index fb33e82..b5b6f55 100644 --- a/app/Infrastructure/SessionInfo.cs +++ b/app/Infrastructure/SessionInfo.cs @@ -34,7 +34,7 @@ public static string Generate( var m = mounts[i]; if (i > 0) json.Append(','); json.Append('{'); - json.Append($"\"host_path\":\"{JsonEncode(m.ResolvePath(ctx.Paths.UserHome))}\","); + json.Append($"\"host_path\":\"{JsonEncode(m.ResolveHostPath(ctx.Paths.UserHome))}\","); json.Append($"\"container_path\":\"{JsonEncode(m.GetContainerPath(ctx.Paths.UserHome))}\","); json.Append($"\"mode\":\"{(m.IsReadWrite ? "rw" : "ro")}\","); json.Append($"\"source\":\"{m.Source.ToString().ToLowerInvariant()}\""); diff --git a/tests/CopilotHere.UnitTests/CopilotHere.UnitTests.csproj b/tests/CopilotHere.UnitTests/CopilotHere.UnitTests.csproj index 88f79e6..ded2a9a 100644 --- a/tests/CopilotHere.UnitTests/CopilotHere.UnitTests.csproj +++ b/tests/CopilotHere.UnitTests/CopilotHere.UnitTests.csproj @@ -17,6 +17,7 @@ + diff --git a/tests/CopilotHere.UnitTests/MountEntryTests.cs b/tests/CopilotHere.UnitTests/MountEntryTests.cs index 45f3ea7..d13926d 100644 --- a/tests/CopilotHere.UnitTests/MountEntryTests.cs +++ b/tests/CopilotHere.UnitTests/MountEntryTests.cs @@ -15,7 +15,7 @@ public async Task MountEntry_ReadOnly_SetsCorrectly() var mount = new MountEntry(path, IsReadWrite: false, MountSource.Local); // Assert - await Assert.That(mount.Path).IsEqualTo(path); + await Assert.That(mount.HostPath).IsEqualTo(path); await Assert.That(mount.IsReadWrite).IsFalse(); await Assert.That(mount.Source).IsEqualTo(MountSource.Local); } @@ -27,7 +27,7 @@ public async Task MountEntry_ReadWrite_SetsCorrectly() var mount = new MountEntry("~/data", IsReadWrite: true, MountSource.Global); // Assert - await Assert.That(mount.Path).IsEqualTo("~/data"); + await Assert.That(mount.HostPath).IsEqualTo("~/data"); await Assert.That(mount.IsReadWrite).IsTrue(); await Assert.That(mount.Source).IsEqualTo(MountSource.Global); } @@ -54,7 +54,7 @@ public async Task ResolvePath_ExpandsTilde() var userHome = IsWindows ? @"C:\Users\testuser" : "/home/testuser"; // Act - var resolved = mount.ResolvePath(userHome); + var resolved = mount.ResolveHostPath(userHome); // Assert await Assert.That(resolved).Contains("testuser"); @@ -253,4 +253,198 @@ public async Task ToDockerVolume_WindowsPathWithDifferentDrives_ConvertsCorrectl await Assert.That(dockerVolume).Contains(":/work/d/Projects/myapp:"); await Assert.That(dockerVolume).EndsWith(":ro"); } + + [Test] + public async Task HostContainerMount_WithCustomContainerPath_UsesSpecifiedPath() + { + // Arrange + var hostPath = IsWindows ? @"C:\data\logs" : "/data/logs"; + var containerPath = "/var/log/app"; + var mount = new MountEntry(hostPath, containerPath, false, MountSource.CommandLine); + var userHome = IsWindows ? @"C:\Users\test" : "/home/test"; + + // Act + var resultContainerPath = mount.GetContainerPath(userHome); + + // Assert + await Assert.That(resultContainerPath).IsEqualTo(containerPath); + await Assert.That(mount.ContainerPath).IsEqualTo(containerPath); + } + + [Test] + public async Task HostContainerMount_ToDockerVolume_UsesCustomContainerPath() + { + // Arrange + var hostPath = IsWindows ? @"C:\data\configs" : "/data/configs"; + var containerPath = "/etc/myapp"; + var mount = new MountEntry(hostPath, containerPath, true, MountSource.CommandLine); + var userHome = IsWindows ? @"C:\Users\test" : "/home/test"; + + // Act + var dockerVolume = mount.ToDockerVolume(userHome); + + // Assert + await Assert.That(dockerVolume).Contains($":{containerPath}:"); + await Assert.That(dockerVolume).EndsWith(":rw"); + } + + [Test] + public async Task HostContainerMount_ReadOnly_FormatsCorrectly() + { + // Arrange + var mount = new MountEntry("/host/data", "/container/data", false, MountSource.Local); + var userHome = "/home/user"; + + // Act + var dockerVolume = mount.ToDockerVolume(userHome); + + // Assert + await Assert.That(dockerVolume).Contains("/host/data:/container/data:ro"); + } + + [Test] + public async Task HostContainerMount_ReadWrite_FormatsCorrectly() + { + // Arrange + var mount = new MountEntry("/host/output", "/container/output", true, MountSource.CommandLine); + var userHome = "/home/user"; + + // Act + var dockerVolume = mount.ToDockerVolume(userHome); + + // Assert + await Assert.That(dockerVolume).Contains("/host/output:/container/output:rw"); + } + + [Test] + public async Task HostContainerMount_EmptyContainerPath_FallsBackToDefaultBehavior() + { + // Arrange + var userHome = IsWindows ? @"C:\Users\test" : "/home/test"; + var hostPath = IsWindows ? @"C:\Users\test\data" : "/home/test/data"; + var mount = new MountEntry(hostPath, "", false, MountSource.Global); + + // Act + var containerPath = mount.GetContainerPath(userHome); + + // Assert - Empty string should be treated as no custom path + await Assert.That(containerPath).Contains("/home/appuser"); + await Assert.That(containerPath).Contains("data"); + } + + [Test] + public async Task HostContainerMount_WindowsHost_LinuxContainer_ConvertsCorrectly() + { + // Only run on Windows + if (!OperatingSystem.IsWindows()) + { + return; + } + + // Arrange - Windows host path with custom Linux container path + var mount = new MountEntry(@"C:\app\config", "/etc/app/config", false, MountSource.CommandLine); + var userHome = @"C:\Users\test"; + + // Act + var dockerVolume = mount.ToDockerVolume(userHome); + + // Assert - Host path should be converted to Docker format + await Assert.That(dockerVolume).StartsWith("/c/app/config:"); + await Assert.That(dockerVolume).Contains(":/etc/app/config:"); + await Assert.That(dockerVolume).EndsWith(":ro"); + } + + [Test] + public async Task HostContainerMount_Equality_ConsidersContainerPath() + { + // Arrange + var mount1 = new MountEntry("/host/path", "/container/path1", false, MountSource.Local); + var mount2 = new MountEntry("/host/path", "/container/path1", false, MountSource.Local); + var mount3 = new MountEntry("/host/path", "/container/path2", false, MountSource.Local); + + // Assert + await Assert.That(mount1).IsEqualTo(mount2); + await Assert.That(mount1).IsNotEqualTo(mount3); + } + + [Test] + public async Task HostContainerMount_AbsoluteContainerPath_PreservesPath() + { + // Arrange + var mount = new MountEntry("/host/libs", "/usr/local/lib", false, MountSource.CommandLine); + var userHome = "/home/test"; + + // Act + var containerPath = mount.GetContainerPath(userHome); + + // Assert - Absolute container paths should be preserved exactly + await Assert.That(containerPath).IsEqualTo("/usr/local/lib"); + } + + [Test] + public async Task HostContainerMount_WithTilde_ResolvesHostPathCorrectly() + { + // Arrange + var mount = new MountEntry("~/mydata", "/app/data", true, MountSource.CommandLine); + var userHome = IsWindows ? @"C:\Users\test" : "/home/test"; + + // Act + var dockerVolume = mount.ToDockerVolume(userHome); + var containerPath = mount.GetContainerPath(userHome); + + // Assert - Host path should be resolved, container path preserved + await Assert.That(dockerVolume).Contains(":/app/data:"); + await Assert.That(containerPath).IsEqualTo("/app/data"); + await Assert.That(dockerVolume).Contains("test"); + await Assert.That(dockerVolume).Contains("mydata"); + } + + [Test] + public async Task HostContainerMount_ContainerPathWithTilde_ExpandsToContainerHome() + { + // Arrange - Container path contains tilde, should expand to /home/appuser + var mount = new MountEntry("/host/data", "~/mydata", false, MountSource.CommandLine); + var userHome = "/home/user"; + + // Act + var containerPath = mount.GetContainerPath(userHome); + var dockerVolume = mount.ToDockerVolume(userHome); + + // Assert - Container path tilde should expand to /home/appuser (container user) + await Assert.That(containerPath).IsEqualTo("/home/appuser/mydata"); + await Assert.That(dockerVolume).Contains(":/home/appuser/mydata:"); + await Assert.That(dockerVolume).EndsWith(":ro"); + } + + [Test] + public async Task HostContainerMount_ContainerPathWithTildeOnly_ExpandsToContainerHome() + { + // Arrange - Container path is just tilde + var mount = new MountEntry("/host/config", "~", true, MountSource.Local); + var userHome = "/home/user"; + + // Act + var containerPath = mount.GetContainerPath(userHome); + + // Assert - Should expand to /home/appuser + await Assert.That(containerPath).IsEqualTo("/home/appuser"); + } + + [Test] + public async Task HostContainerMount_BothPathsWithTilde_ResolvesCorrectly() + { + // Arrange - Both host and container paths have tildes + var mount = new MountEntry("~/hostdata", "~/containerdata", false, MountSource.CommandLine); + var userHome = IsWindows ? @"C:\Users\test" : "/home/test"; + + // Act + var dockerVolume = mount.ToDockerVolume(userHome); + var containerPath = mount.GetContainerPath(userHome); + + // Assert - Host ~ resolves to user home, container ~ to /home/appuser + await Assert.That(containerPath).IsEqualTo("/home/appuser/containerdata"); + await Assert.That(dockerVolume).Contains(":/home/appuser/containerdata:"); + await Assert.That(dockerVolume).Contains("test"); + await Assert.That(dockerVolume).Contains("hostdata"); + } } diff --git a/tests/CopilotHere.UnitTests/MountsConfigTests.cs b/tests/CopilotHere.UnitTests/MountsConfigTests.cs index 5e99939..1ba7858 100644 --- a/tests/CopilotHere.UnitTests/MountsConfigTests.cs +++ b/tests/CopilotHere.UnitTests/MountsConfigTests.cs @@ -61,10 +61,10 @@ public async Task Load_OnlyGlobal_ReturnsGlobalMounts() // Assert await Assert.That(config.GlobalMounts).HasCount().EqualTo(2); - await Assert.That(config.GlobalMounts[0].Path).IsEqualTo("/global/path1"); + await Assert.That(config.GlobalMounts[0].HostPath).IsEqualTo("/global/path1"); await Assert.That(config.GlobalMounts[0].IsReadWrite).IsFalse(); await Assert.That(config.GlobalMounts[0].Source).IsEqualTo(MountSource.Global); - await Assert.That(config.GlobalMounts[1].Path).IsEqualTo("/global/path2"); + await Assert.That(config.GlobalMounts[1].HostPath).IsEqualTo("/global/path2"); await Assert.That(config.GlobalMounts[1].IsReadWrite).IsTrue(); } @@ -80,10 +80,10 @@ public async Task Load_OnlyLocal_ReturnsLocalMounts() // Assert await Assert.That(config.LocalMounts).HasCount().EqualTo(2); - await Assert.That(config.LocalMounts[0].Path).IsEqualTo("/local/path1"); + await Assert.That(config.LocalMounts[0].HostPath).IsEqualTo("/local/path1"); await Assert.That(config.LocalMounts[0].IsReadWrite).IsFalse(); await Assert.That(config.LocalMounts[0].Source).IsEqualTo(MountSource.Local); - await Assert.That(config.LocalMounts[1].Path).IsEqualTo("/local/path2"); + await Assert.That(config.LocalMounts[1].HostPath).IsEqualTo("/local/path2"); await Assert.That(config.LocalMounts[1].IsReadWrite).IsFalse(); } @@ -100,8 +100,8 @@ public async Task Load_BothGlobalAndLocal_LoadsBothSeparately() // Assert - Both should be loaded separately await Assert.That(config.GlobalMounts).HasCount().EqualTo(1); await Assert.That(config.LocalMounts).HasCount().EqualTo(1); - await Assert.That(config.GlobalMounts[0].Path).IsEqualTo("/global/path"); - await Assert.That(config.LocalMounts[0].Path).IsEqualTo("/local/path"); + await Assert.That(config.GlobalMounts[0].HostPath).IsEqualTo("/global/path"); + await Assert.That(config.LocalMounts[0].HostPath).IsEqualTo("/local/path"); } [Test] @@ -176,10 +176,105 @@ public async Task ParsesCommentsAndEmptyLines() // Assert await Assert.That(config.GlobalMounts).HasCount().EqualTo(3); - await Assert.That(config.GlobalMounts[0].Path).IsEqualTo("/path1"); - await Assert.That(config.GlobalMounts[1].Path).IsEqualTo("/path2"); + await Assert.That(config.GlobalMounts[0].HostPath).IsEqualTo("/path1"); + await Assert.That(config.GlobalMounts[1].HostPath).IsEqualTo("/path2"); await Assert.That(config.GlobalMounts[1].IsReadWrite).IsTrue(); - await Assert.That(config.GlobalMounts[2].Path).IsEqualTo("/path3"); + await Assert.That(config.GlobalMounts[2].HostPath).IsEqualTo("/path3"); await Assert.That(config.GlobalMounts[2].IsReadWrite).IsFalse(); } + + [Test] + public async Task Load_HostPathContainerPath_ParsesBoth() + { + // Arrange + var configPath = _paths.GetLocalPath("mounts.conf"); + File.WriteAllText(configPath, "/host/path:/container/path\n"); + + // Act + var config = MountsConfig.Load(_paths); + + // Assert + await Assert.That(config.LocalMounts).HasCount().EqualTo(1); + await Assert.That(config.LocalMounts[0].HostPath).IsEqualTo("/host/path"); + await Assert.That(config.LocalMounts[0].ContainerPath).IsEqualTo("/container/path"); + await Assert.That(config.LocalMounts[0].IsReadWrite).IsFalse(); + } + + [Test] + public async Task Load_HostPathContainerPathWithRW_ParsesCorrectly() + { + // Arrange + var configPath = _paths.GetLocalPath("mounts.conf"); + File.WriteAllText(configPath, "/host/data:/app/data:rw\n"); + + // Act + var config = MountsConfig.Load(_paths); + + // Assert + await Assert.That(config.LocalMounts).HasCount().EqualTo(1); + await Assert.That(config.LocalMounts[0].HostPath).IsEqualTo("/host/data"); + await Assert.That(config.LocalMounts[0].ContainerPath).IsEqualTo("/app/data"); + await Assert.That(config.LocalMounts[0].IsReadWrite).IsTrue(); + } + + [Test] + public async Task Load_HostPathContainerPathWithRO_ParsesCorrectly() + { + // Arrange + var configPath = _paths.GetLocalPath("mounts.conf"); + File.WriteAllText(configPath, "/host/config:/etc/config:ro\n"); + + // Act + var config = MountsConfig.Load(_paths); + + // Assert + await Assert.That(config.LocalMounts).HasCount().EqualTo(1); + await Assert.That(config.LocalMounts[0].HostPath).IsEqualTo("/host/config"); + await Assert.That(config.LocalMounts[0].ContainerPath).IsEqualTo("/etc/config"); + await Assert.That(config.LocalMounts[0].IsReadWrite).IsFalse(); + } + + [Test] + public async Task Load_MixedFormats_ParsesAllCorrectly() + { + // Arrange + var configPath = _paths.GetGlobalPath("mounts.conf"); + File.WriteAllText(configPath, @"/simple/path +/host/path:/container/path +/readonly/path:ro +/readwrite/path:rw +/custom:/app/custom:rw +"); + + // Act + var config = MountsConfig.Load(_paths); + + // Assert + await Assert.That(config.GlobalMounts).HasCount().EqualTo(5); + + // Simple path + await Assert.That(config.GlobalMounts[0].HostPath).IsEqualTo("/simple/path"); + await Assert.That(config.GlobalMounts[0].ContainerPath).IsNull(); + await Assert.That(config.GlobalMounts[0].IsReadWrite).IsFalse(); + + // Host:Container + await Assert.That(config.GlobalMounts[1].HostPath).IsEqualTo("/host/path"); + await Assert.That(config.GlobalMounts[1].ContainerPath).IsEqualTo("/container/path"); + await Assert.That(config.GlobalMounts[1].IsReadWrite).IsFalse(); + + // Read-only + await Assert.That(config.GlobalMounts[2].HostPath).IsEqualTo("/readonly/path"); + await Assert.That(config.GlobalMounts[2].ContainerPath).IsNull(); + await Assert.That(config.GlobalMounts[2].IsReadWrite).IsFalse(); + + // Read-write + await Assert.That(config.GlobalMounts[3].HostPath).IsEqualTo("/readwrite/path"); + await Assert.That(config.GlobalMounts[3].ContainerPath).IsNull(); + await Assert.That(config.GlobalMounts[3].IsReadWrite).IsTrue(); + + // Custom container path with RW + await Assert.That(config.GlobalMounts[4].HostPath).IsEqualTo("/custom"); + await Assert.That(config.GlobalMounts[4].ContainerPath).IsEqualTo("/app/custom"); + await Assert.That(config.GlobalMounts[4].IsReadWrite).IsTrue(); + } } diff --git a/tests/CopilotHere.UnitTests/ParseCliMountTests.cs b/tests/CopilotHere.UnitTests/ParseCliMountTests.cs new file mode 100644 index 0000000..f637706 --- /dev/null +++ b/tests/CopilotHere.UnitTests/ParseCliMountTests.cs @@ -0,0 +1,158 @@ +using CopilotHere.Commands.Mounts; +using CopilotHere.Commands.Run; + +namespace CopilotHere.Tests; + +public class ParseCliMountTests +{ + [Test] + public async Task ParseCliMount_SimplePath_DefaultReadOnly() + { + // Act + var mount = RunCommand.ParseCliMount("/path/to/dir", defaultReadWrite: false); + + // Assert + await Assert.That(mount.HostPath).IsEqualTo("/path/to/dir"); + await Assert.That(mount.IsReadWrite).IsFalse(); + await Assert.That(mount.Source).IsEqualTo(MountSource.CommandLine); + } + + [Test] + public async Task ParseCliMount_WithQuotes_TrimsQuotes() + { + // Act + var mount = RunCommand.ParseCliMount("'/path/to/dir'", defaultReadWrite: false); + + // Assert + await Assert.That(mount.HostPath).IsEqualTo("/path/to/dir"); + } + + [Test] + public async Task ParseCliMount_WithDoubleQuotes_TrimsQuotes() + { + // Act + var mount = RunCommand.ParseCliMount("\"/path/to/dir\"", defaultReadWrite: false); + + // Assert + await Assert.That(mount.HostPath).IsEqualTo("/path/to/dir"); + } + + [Test] + public async Task ParseCliMount_WithQuotesAndReadOnly_CorrectlyParsesReadOnly() + { + // Act + var mount = RunCommand.ParseCliMount("'/path/to/dir:ro'", defaultReadWrite: false); + + // Assert + await Assert.That(mount.HostPath).IsEqualTo("/path/to/dir"); + await Assert.That(mount.IsReadWrite).IsFalse(); + } + + [Test] + public async Task ParseCliMount_WithQuotesAndReadWrite_CorrectlyParsesReadWrite() + { + // Act + var mount = RunCommand.ParseCliMount("'/path/to/dir:rw'", defaultReadWrite: false); + + // Assert + await Assert.That(mount.HostPath).IsEqualTo("/path/to/dir"); + await Assert.That(mount.IsReadWrite).IsTrue(); + } + + [Test] + public async Task ParseCliMount_ReadOnlySuffix_OverridesDefault() + { + // Act + var mount = RunCommand.ParseCliMount("/path/to/dir:ro", defaultReadWrite: true); + + // Assert + await Assert.That(mount.HostPath).IsEqualTo("/path/to/dir"); + await Assert.That(mount.IsReadWrite).IsFalse(); + } + + [Test] + public async Task ParseCliMount_ReadWriteSuffix_OverridesDefault() + { + // Act + var mount = RunCommand.ParseCliMount("/path/to/dir:rw", defaultReadWrite: false); + + // Assert + await Assert.That(mount.HostPath).IsEqualTo("/path/to/dir"); + await Assert.That(mount.IsReadWrite).IsTrue(); + } + + [Test] + public async Task ParseCliMount_HostContainerFormat_ParsesBothPaths() + { + // Act + var mount = RunCommand.ParseCliMount("/host/path:/container/path", defaultReadWrite: false); + + // Assert + await Assert.That(mount.HostPath).IsEqualTo("/host/path"); + await Assert.That(mount.ContainerPath).IsEqualTo("/container/path"); + await Assert.That(mount.IsReadWrite).IsFalse(); + } + + [Test] + public async Task ParseCliMount_HostContainerWithReadOnly_ParsesCorrectly() + { + // Act + var mount = RunCommand.ParseCliMount("/host/path:/container/path:ro", defaultReadWrite: true); + + // Assert + await Assert.That(mount.HostPath).IsEqualTo("/host/path"); + await Assert.That(mount.ContainerPath).IsEqualTo("/container/path"); + await Assert.That(mount.IsReadWrite).IsFalse(); + } + + [Test] + public async Task ParseCliMount_HostContainerWithReadWrite_ParsesCorrectly() + { + // Act + var mount = RunCommand.ParseCliMount("/host/path:/container/path:rw", defaultReadWrite: false); + + // Assert + await Assert.That(mount.HostPath).IsEqualTo("/host/path"); + await Assert.That(mount.ContainerPath).IsEqualTo("/container/path"); + await Assert.That(mount.IsReadWrite).IsTrue(); + } + + [Test] + public async Task ParseCliMount_QuotedHostContainerWithReadWrite_ParsesCorrectly() + { + // Arrange - This is the critical test case: quotes around "path:rw" + var input = "'/host/path:/container/path:rw'"; + + // Act + var mount = RunCommand.ParseCliMount(input, defaultReadWrite: false); + + // Assert + await Assert.That(mount.HostPath).IsEqualTo("/host/path"); + await Assert.That(mount.ContainerPath).IsEqualTo("/container/path"); + await Assert.That(mount.IsReadWrite).IsTrue(); + } + + [Test] + public async Task ParseCliMount_CaseInsensitive_ReadOnly() + { + // Act + var mount1 = RunCommand.ParseCliMount("/path:RO", defaultReadWrite: true); + var mount2 = RunCommand.ParseCliMount("/path:Ro", defaultReadWrite: true); + + // Assert + await Assert.That(mount1.IsReadWrite).IsFalse(); + await Assert.That(mount2.IsReadWrite).IsFalse(); + } + + [Test] + public async Task ParseCliMount_CaseInsensitive_ReadWrite() + { + // Act + var mount1 = RunCommand.ParseCliMount("/path:RW", defaultReadWrite: false); + var mount2 = RunCommand.ParseCliMount("/path:Rw", defaultReadWrite: false); + + // Assert + await Assert.That(mount1.IsReadWrite).IsTrue(); + await Assert.That(mount2.IsReadWrite).IsTrue(); + } +}