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