fix(launch): reject option-like and missing paths, resolve them absolute and drop the folder-missing page

This commit is contained in:
yyc12345 committed 2026-10-04 16:17:05 +08:00
1 parent 157f597eed
commit e3265e3c89
9 files changed
+91 -94

No files matched your search

+11 -6
View File
@@ -14,9 +14,8 @@ YKanBan is a local/offline kanban board. It works like Git in one respect: all d
ykanban <path>
```
- `<path>` is the workspace folder to open.
- Starting without arguments, or with invalid arguments, shows the argument error page.
- If the path does not exist, the folder-missing page is shown.
- `<path>` is the workspace folder to open. A relative path is resolved against the current working directory.
- Exactly one argument is required. Starting without arguments, with more than one argument, with a blank argument, with an argument starting with `-` (any option form, `--help` included), or with a path that does not exist or is not a folder shows the argument error page.
- If the folder exists but has no `.ykanban`, the not-initialized page is shown with an **Initialize Workspace** button. Initializing creates the database and seeds the preset columns and tags in the current interface language.
## 2. Do not commit `.ykanban` to Git
@@ -67,8 +66,8 @@ phrase := '"' any text '"' // escapes: \" to literal quote and
- `tag:NAME` matches a tag by its **exact** name.
- `column:TITLE` matches a column by its **exact** title.
- `title:TEXT` matches a substring of the card title, and `content:TEXT` matches a substring of the card content.
- `id:N` matches a card by its **exact** numeric id.
- All matching is case-insensitive (SQLite ASCII semantics).
- `id:N` matches a card by its **exact** positive integer id; an optional leading `#` is allowed (`id:#61`).
- All matching is Unicode case-insensitive (both sides are case-folded before comparing).
- Quotes allow values with spaces, for example `tag:"release notes"` or `column:"In Progress"`.
- Inside a phrase, `\"` produces a literal quote and `\\` a literal backslash.
@@ -82,9 +81,15 @@ The following are invalid:
- a dangling operator, for example `bug AND`;
- consecutive operators, for example `a AND OR b`;
- a qualifier without a value, for example `tag:`;
- a qualifier phrase separated from its key by whitespace, for example `tag: "x"` (write `tag:"x"` instead);
- an unterminated phrase, for example `"abc`;
- an empty phrase, for example `""`;
- a phrase glued to other text, for example `a"b"`;
- an invalid escape inside a phrase (a backslash not followed by `"` or `\`), for example `tag:"a\x"`;
- an unknown qualifier key. Only `tag`, `title`, `content`, `id` and `column` are valid, and the key is case-sensitive, so `Tag:x` is an error. A word whose colon is not preceded by an identifier (for example `:foo`) is treated as plain text.
- a bare backslash in a word, for example `a\b`;
- an unquoted qualifier value containing `:` or equal to `AND`/`OR`, for example `tag:a:b` (quote the value instead);
- an id value that is not an unquoted positive integer with an optional `#`, for example `id:0`, `id:-1` or `id:"1"`;
- an unknown qualifier key. Only `tag`, `title`, `content`, `id` and `column` are valid, and the key is case-sensitive, so `Tag:x` is an error. A colon not preceded by a valid key (for example `:foo` or `http://x`) is an error; quote such text to search for it literally.
## 5. Export
@@ -1,10 +1,11 @@
using YKanBan.Launching;
using YKanBan.Tests.TestUtilities;
namespace YKanBan.Tests.Launching;
/// <summary>
/// Tests for the <c>ykanban &lt;path&gt;</c> command-line contract across its
/// three branches: no argument, one valid path, and invalid input.
/// Tests for the <c>ykanban &lt;path&gt;</c> command-line contract: exactly one
/// existing folder, resolved to an absolute path; everything else is rejected.
/// </summary>
[TestClass]
public class LaunchArgumentsTests {
@@ -27,9 +28,58 @@ public class LaunchArgumentsTests {
}
[TestMethod]
public void SinglePathIsAccepted() {
bool parsed = LaunchArguments.TryParse([@"C:\Repo\MyProject"], out string? path);
[DataRow("--help")]
[DataRow("-x")]
public void OptionLikeArgumentIsRejected(string argument) {
Assert.IsFalse(LaunchArguments.TryParse([argument], out string? path));
Assert.IsNull(path);
}
[TestMethod]
public void NonExistentFolderIsRejected() {
using var directory = new TempDirectory();
string missing = Path.Combine(directory.FullPath, "nope");
Assert.IsFalse(LaunchArguments.TryParse([missing], out string? path));
Assert.IsNull(path);
}
[TestMethod]
public void FileInsteadOfFolderIsRejected() {
using var directory = new TempDirectory();
string file = Path.Combine(directory.FullPath, "file.txt");
File.WriteAllText(file, "x");
Assert.IsFalse(LaunchArguments.TryParse([file], out string? path));
Assert.IsNull(path);
}
[TestMethod]
public void ExistingFolderIsAcceptedAsAnAbsolutePath() {
using var directory = new TempDirectory();
bool parsed = LaunchArguments.TryParse([directory.FullPath], out string? path);
Assert.IsTrue(parsed);
Assert.AreEqual(@"C:\Repo\MyProject", path);
Assert.AreEqual(Path.GetFullPath(directory.FullPath), path);
Assert.IsTrue(Path.IsPathRooted(path));
}
[TestMethod]
public void RelativePathIsResolvedToAbsolute() {
using var directory = new TempDirectory();
string relative = Path.GetRelativePath(Environment.CurrentDirectory, directory.FullPath);
bool parsed = LaunchArguments.TryParse([relative], out string? path);
Assert.IsTrue(parsed);
Assert.IsTrue(Path.IsPathRooted(path));
Assert.AreEqual(Path.GetFullPath(relative), path);
}
[TestMethod]
public void MalformedPathIsRejected() {
Assert.IsFalse(LaunchArguments.TryParse(["bad\0path"], out string? path));
Assert.IsNull(path);
}
}
-6
View File
@@ -265,12 +265,6 @@
<data name="Error_Argument_Usage" xml:space="preserve">
<value>Usage: ykanban &lt;path&gt;</value>
</data>
<data name="Error_DirectoryMissing_Title" xml:space="preserve">
<value>Folder not found</value>
</data>
<data name="Error_DirectoryMissing_Message" xml:space="preserve">
<value>The folder does not exist: {0}</value>
</data>
<data name="Error_NotInitialized_Title" xml:space="preserve">
<value>No workspace here</value>
</data>
@@ -265,12 +265,6 @@
<data name="Error_Argument_Usage" xml:space="preserve">
<value>用法:ykanban &lt;路径&gt;</value>
</data>
<data name="Error_DirectoryMissing_Title" xml:space="preserve">
<value>目录不存在</value>
</data>
<data name="Error_DirectoryMissing_Message" xml:space="preserve">
<value>目录不存在:{0}</value>
</data>
<data name="Error_NotInitialized_Title" xml:space="preserve">
<value>尚未初始化</value>
</data>
+25 -10
View File
@@ -4,9 +4,11 @@ namespace YKanBan.Launching;
/// <summary>
/// Parses raw command-line arguments. The only accepted form is a single,
/// non-blank workspace path (<c>ykanban &lt;path&gt;</c>); zero arguments,
/// more than one argument, or a blank argument is rejected as an argument
/// error. The path itself is not checked for existence here.
/// non-blank, existing workspace folder (<c>ykanban &lt;path&gt;</c>); zero
/// arguments, more than one argument, a blank argument, an argument starting
/// with <c>-</c> (any option form, <c>--help</c> included), a malformed path or
/// a path that is not an existing folder is rejected as an argument error. A
/// relative path is resolved against the process's current directory.
/// </summary>
public static class LaunchArguments {
/// <summary>
@@ -14,15 +16,28 @@ public static class LaunchArguments {
/// <c>ykanban &lt;path&gt;</c> contract.
/// </summary>
/// <param name="arguments">The raw arguments, excluding the executable name.</param>
/// <param name="folderPath">The parsed workspace path on success, otherwise <see langword="null"/>.</param>
/// <returns><see langword="true"/> when exactly one non-blank path was supplied.</returns>
/// <param name="folderPath">The absolute, existing workspace folder on success, otherwise <see langword="null"/>.</param>
/// <returns><see langword="true"/> when exactly one usable path was supplied.</returns>
public static bool TryParse(IReadOnlyList<string> arguments, [NotNullWhen(true)] out string? folderPath) {
if (arguments.Count == 1 && !string.IsNullOrWhiteSpace(arguments[0])) {
folderPath = arguments[0];
return true;
folderPath = null;
if (arguments.Count != 1 || string.IsNullOrWhiteSpace(arguments[0]) || arguments[0].StartsWith('-')) {
return false;
}
folderPath = null;
return false;
string fullPath;
try {
fullPath = Path.GetFullPath(arguments[0]);
} catch (Exception exception) when (exception is ArgumentException or NotSupportedException or PathTooLongException) {
// A path the OS cannot even represent is a bad argument, not a missing folder.
return false;
}
// Existence is checked here so every caller gets an already validated folder.
if (!Directory.Exists(fullPath)) {
return false;
}
folderPath = fullPath;
return true;
}
}
@@ -1,29 +0,0 @@
using System.Globalization;
namespace YKanBan.ViewModels.ErrorPages;
/// <summary>
/// The full-window page shown when the folder passed on the command line does
/// not exist. It has no operations.
/// </summary>
public sealed class DirectoryMissingPageViewModel : ViewModelBase {
/// <summary>
/// Initializes the page for a missing folder.
/// </summary>
/// <param name="folderPath">The folder path that does not exist.</param>
public DirectoryMissingPageViewModel(string folderPath) {
FolderPath = folderPath;
Message = string.Format(
Resources.Culture ?? CultureInfo.CurrentCulture, Resources.Error_DirectoryMissing_Message, folderPath);
}
/// <summary>
/// Gets the folder path that does not exist.
/// </summary>
public string FolderPath { get; }
/// <summary>
/// Gets the localized explanatory message.
/// </summary>
public string Message { get; }
}
@@ -69,11 +69,6 @@ public sealed partial class MainWindowViewModel : ViewModelBase {
return;
}
if (!Directory.Exists(folderPath)) {
Content = new DirectoryMissingPageViewModel(folderPath);
return;
}
try {
OpenWorkspace(folderPath);
} catch (WorkspaceNotInitializedException) {
@@ -1,18 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:YKanBan.ViewModels.ErrorPages"
xmlns:lang="using:YKanBan"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="640"
x:Class="YKanBan.Views.ErrorPages.DirectoryMissingPageView"
x:DataType="vm:DirectoryMissingPageViewModel">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="12"
MaxWidth="560" Margin="32">
<TextBlock Text="{x:Static lang:Resources.Error_DirectoryMissing_Title}"
FontSize="20" FontWeight="SemiBold" HorizontalAlignment="Center"/>
<TextBlock Text="{Binding Message}"
TextWrapping="Wrap" HorizontalAlignment="Center" TextAlignment="Center"/>
</StackPanel>
</UserControl>
@@ -1,9 +0,0 @@
using Avalonia.Controls;
namespace YKanBan.Views.ErrorPages;
public partial class DirectoryMissingPageView : UserControl {
public DirectoryMissingPageView() {
InitializeComponent();
}
}