diff --git a/YKanBan.Tests/Platform/PathComparerTests.cs b/YKanBan.Tests/Platform/PathComparerTests.cs new file mode 100644 index 0000000..534ca9d --- /dev/null +++ b/YKanBan.Tests/Platform/PathComparerTests.cs @@ -0,0 +1,93 @@ +using YKanBan.Platform; +using YKanBan.Tests.TestUtilities; + +namespace YKanBan.Tests.Platform; + +/// Tests for path normalization and platform-parameterized comparison. +[TestClass] +public class PathComparerTests +{ + [TestMethod] + public void OrdinalComparerTreatsCaseAsSignificant() + { + // Unix-style rule, injected so it is testable on any host. + var comparer = new PathComparer(ignoreCase: false); + Assert.IsFalse(comparer.Equals(@"C:\A", @"C:\a")); + } + + [TestMethod] + public void IgnoreCaseComparerTreatsCaseAsInsignificant() + { + // Windows-style rule. + var comparer = new PathComparer(ignoreCase: true); + Assert.IsTrue(comparer.Equals(@"C:\A", @"c:\a")); + } + + [TestMethod] + public void EqualPathsProduceEqualHashCodes() + { + var comparer = new PathComparer(ignoreCase: true); + Assert.AreEqual( + comparer.GetHashCode(@"C:\Users\Data\MyRepo"), + comparer.GetHashCode(@"c:\users\data\myrepo")); + } + + [TestMethod] + public void RelativeSegmentsCollapse() + { + var comparer = new PathComparer(ignoreCase: true); + using var directory = new TempDirectory(); + + string viaParent = Path.Combine(directory.FullPath, "x", "..", "a"); + string direct = Path.Combine(directory.FullPath, "a"); + + Assert.IsTrue(comparer.Equals(viaParent, direct)); + } + + [TestMethod] + public void SeparatorStylesUnify() + { + var comparer = new PathComparer(ignoreCase: true); + + Assert.IsTrue(comparer.Equals(@"C:\a/b\c", @"C:\a\b\c")); + } + + [TestMethod] + public void TrailingSeparatorIsTrimmedForNonRoots() + { + var comparer = new PathComparer(ignoreCase: true); + using var directory = new TempDirectory(); + + string withTrailing = directory.FullPath + Path.DirectorySeparatorChar; + Assert.IsTrue(comparer.Equals(withTrailing, directory.FullPath)); + } + + [TestMethod] + public void NormalizeReturnsCanonicalAbsoluteForm() + { + var comparer = new PathComparer(ignoreCase: false); + using var directory = new TempDirectory(); + + string withRelativeSegment = Path.Combine(directory.FullPath, "sub", ".."); + Assert.AreEqual(directory.FullPath, comparer.Normalize(withRelativeSegment)); + } + + [TestMethod] + public void RootPathKeepsItsTrailingSeparator() + { + var comparer = new PathComparer(ignoreCase: true); + + Assert.AreEqual(@"C:\", comparer.Normalize(@"C:\")); + Assert.AreEqual(@"C:\x", comparer.Normalize(@"C:\x\")); + } + + [TestMethod] + public void ForCurrentPlatformMatchesHostRule() + { + PathComparer comparer = PathComparer.ForCurrentPlatform(); + + Assert.AreEqual( + OperatingSystem.IsWindows(), + comparer.Equals(@"C:\Same", @"c:\same")); + } +} diff --git a/YKanBan.Tests/Storage/Workspace/WorkspaceConnectionPoolTests.cs b/YKanBan.Tests/Storage/Workspace/WorkspaceConnectionPoolTests.cs new file mode 100644 index 0000000..538f51e --- /dev/null +++ b/YKanBan.Tests/Storage/Workspace/WorkspaceConnectionPoolTests.cs @@ -0,0 +1,191 @@ +using YKanBan.Platform; +using YKanBan.Storage; +using YKanBan.Storage.AppData; +using YKanBan.Storage.Workspace; +using YKanBan.Tests.TestUtilities; + +namespace YKanBan.Tests.Storage.Workspace; + +/// Tests for the fixed-capacity LRU workspace session pool. +[TestClass] +public class WorkspaceConnectionPoolTests +{ + /// Owns three initialized workspaces (a, b, c), a registry and the pool under test. + private sealed class PoolFixture : IDisposable + { + public TempDirectory Directory { get; } = new(); + + public AppDatabase AppDb { get; } + + public WorkspaceConnectionPool Pool { get; } + + public PoolFixture(int capacity = 2) + { + foreach (string name in new[] { "a", "b", "c" }) + { + string path = Path.Combine(Directory.FullPath, name); + System.IO.Directory.CreateDirectory(path); + WorkspaceInitializer.Initialize(path); + } + AppDb = new AppDatabase(Path.Combine(Directory.FullPath, "app.db")); + Pool = new WorkspaceConnectionPool(capacity, AppDb, new PathComparer(ignoreCase: true)); + } + + /// Workspace folder path by name. + public string Workspace(string name) => Path.Combine(Directory.FullPath, name); + + public void Dispose() + { + Pool.Dispose(); + AppDb.Dispose(); + Directory.Dispose(); + } + } + + [TestMethod] + public void OpenBeyondCapacityEvictsLeastRecentlyUsed() + { + using var fixture = new PoolFixture(capacity: 2); + + fixture.Pool.Open(fixture.Workspace("a")); + fixture.Pool.Open(fixture.Workspace("b")); + fixture.Pool.Open(fixture.Workspace("c")); + + Assert.AreEqual(2, fixture.Pool.OpenCount); + Assert.IsNull(fixture.Pool.Find(fixture.Workspace("a"))); + Assert.IsNotNull(fixture.Pool.Find(fixture.Workspace("b"))); + Assert.IsNotNull(fixture.Pool.Find(fixture.Workspace("c"))); + } + + [TestMethod] + public void OpenTwiceReturnsSameSession() + { + using var fixture = new PoolFixture(); + + WorkspaceSession first = fixture.Pool.Open(fixture.Workspace("a")); + WorkspaceSession second = fixture.Pool.Open(fixture.Workspace("a")); + + Assert.AreSame(first, second); + Assert.AreEqual(1, fixture.Pool.OpenCount); + } + + [TestMethod] + public void TouchPromotesRecency() + { + using var fixture = new PoolFixture(capacity: 2); + + fixture.Pool.Open(fixture.Workspace("a")); + fixture.Pool.Open(fixture.Workspace("b")); + fixture.Pool.Touch(fixture.Workspace("a")); + fixture.Pool.Open(fixture.Workspace("c")); + + // "a" was touched after "b" opened, so "b" is the LRU victim now. + Assert.IsNotNull(fixture.Pool.Find(fixture.Workspace("a"))); + Assert.IsNull(fixture.Pool.Find(fixture.Workspace("b"))); + } + + [TestMethod] + public void EvictedWorkspaceCanBeReopened() + { + using var fixture = new PoolFixture(capacity: 2); + + fixture.Pool.Open(fixture.Workspace("a")); + fixture.Pool.Open(fixture.Workspace("b")); + fixture.Pool.Open(fixture.Workspace("c")); + + // "a" was evicted; reopening it works and now evicts the LRU "b". + WorkspaceSession reopened = fixture.Pool.Open(fixture.Workspace("a")); + Assert.AreEqual("a", Path.GetFileName(reopened.FolderPath)); + Assert.IsNull(fixture.Pool.Find(fixture.Workspace("b"))); + } + + [TestMethod] + public void OpenRegistersWorkspaceInSidebarRegistry() + { + using var fixture = new PoolFixture(); + + fixture.Pool.Open(fixture.Workspace("a")); + fixture.Pool.Open(fixture.Workspace("b")); + + IReadOnlyList registered = fixture.AppDb.GetWorkspaces().Select(record => record.Path).ToList(); + Assert.AreEqual(2, registered.Count); + Assert.IsTrue(registered.Contains(new PathComparer(ignoreCase: true).Normalize(fixture.Workspace("a")))); + Assert.IsTrue(registered.Contains(new PathComparer(ignoreCase: true).Normalize(fixture.Workspace("b")))); + } + + [TestMethod] + public void OpenMissingDirectoryThrows() + { + using var fixture = new PoolFixture(); + string missing = Path.Combine(fixture.Directory.FullPath, "nowhere"); + + Assert.ThrowsExactly(() => fixture.Pool.Open(missing)); + } + + [TestMethod] + public void OpenFolderWithoutYkanbanThrows() + { + using var fixture = new PoolFixture(); + string plain = Path.Combine(fixture.Directory.FullPath, "plain"); + System.IO.Directory.CreateDirectory(plain); + + Assert.ThrowsExactly(() => fixture.Pool.Open(plain)); + } + + [TestMethod] + public void OpenWithLockHeldElsewhereThrowsThenSucceedsAfterRelease() + { + using var fixture = new PoolFixture(); + + using WorkspaceLock holder = WorkspaceLock.Acquire(fixture.Workspace("a")); + Assert.ThrowsExactly(() => fixture.Pool.Open(fixture.Workspace("a"))); + + holder.Dispose(); + WorkspaceSession session = fixture.Pool.Open(fixture.Workspace("a")); + Assert.AreEqual("a", Path.GetFileName(session.FolderPath)); + } + + [TestMethod] + public void RemoveReleasesResourcesAndForgetsRegistryEntry() + { + using var fixture = new PoolFixture(); + + fixture.Pool.Open(fixture.Workspace("a")); + Assert.IsTrue(fixture.Pool.Remove(fixture.Workspace("a"))); + + Assert.IsNull(fixture.Pool.Find(fixture.Workspace("a"))); + Assert.AreEqual(0, fixture.AppDb.GetWorkspaces().Count); + + // Resources are truly released: the lock can be taken again. + using WorkspaceLock reacquired = WorkspaceLock.Acquire(fixture.Workspace("a")); + } + + [TestMethod] + public void PathEquivalentsShareOneSession() + { + using var fixture = new PoolFixture(); + string path = fixture.Workspace("a"); + string differentlyCased = Path.Combine( + Path.GetDirectoryName(path)!, + Path.GetFileName(path).ToUpperInvariant()); + + WorkspaceSession session = fixture.Pool.Open(path); + WorkspaceSession same = fixture.Pool.Open(differentlyCased); + + Assert.AreSame(session, same); + Assert.AreEqual(1, fixture.Pool.OpenCount); + } + + [TestMethod] + public void DisposeReleasesAllLocks() + { + using var fixture = new PoolFixture(); + + fixture.Pool.Open(fixture.Workspace("a")); + fixture.Pool.Open(fixture.Workspace("b")); + fixture.Pool.Dispose(); + + using WorkspaceLock lockA = WorkspaceLock.Acquire(fixture.Workspace("a")); + using WorkspaceLock lockB = WorkspaceLock.Acquire(fixture.Workspace("b")); + } +} diff --git a/YKanBan.Tests/Storage/Workspace/WorkspaceRepositoryTests.cs b/YKanBan.Tests/Storage/Workspace/WorkspaceRepositoryTests.cs new file mode 100644 index 0000000..1582980 --- /dev/null +++ b/YKanBan.Tests/Storage/Workspace/WorkspaceRepositoryTests.cs @@ -0,0 +1,275 @@ +using Microsoft.Data.Sqlite; +using YKanBan.Models; +using YKanBan.Storage.AppData; +using YKanBan.Storage.Workspace; +using YKanBan.Tests.TestUtilities; + +namespace YKanBan.Tests.Storage.Workspace; + +/// +/// Tests for workspace CRUD, the three cascade chains, edit-confirm +/// transaction atomicity, timestamp refresh rules and the last_edited_at +/// write-through. +/// +[TestClass] +public class WorkspaceRepositoryTests +{ + /// + /// Owns a workspace with fixed-timestamp fixture data (one column, one + /// card, two tags linked to the card), a fake-clock app.db and the + /// repository under test. + /// + private sealed class RepositoryFixture : IDisposable + { + public TempDirectory Directory { get; } = new(); + + public long Clock { get; set; } = 1000; + + public AppDatabase AppDb { get; } + + public WorkspaceRepository Repository { get; } + + public long ColumnId { get; } = 1; + + public long CardId { get; } = 1; + + public long BugTagId { get; } = 1; + + public long UiTagId { get; } = 2; + + public RepositoryFixture() + { + WorkspaceInitializer.Initialize(Directory.FullPath); + + // Fixture rows carry literal timestamps so refresh vs. no-refresh stays observable. + using SqliteConnection connection = SqliteTestHelper.OpenWorkspace(Directory.FullPath); + SqliteTestHelper.Exec(connection, "INSERT INTO columns (title, description, created_at, updated_at) VALUES ('Col', '', 100, 100);"); + SqliteTestHelper.Exec(connection, "INSERT INTO cards (column_id, title, content, created_at, updated_at) VALUES (1, 'T', 'C', 100, 100);"); + SqliteTestHelper.Exec(connection, "INSERT INTO tags (name, color, description) VALUES ('bug', '#ff0000', '');"); + SqliteTestHelper.Exec(connection, "INSERT INTO tags (name, color, description) VALUES ('ui', '#00ff00', '');"); + SqliteTestHelper.Exec(connection, "INSERT INTO card_tags (card_id, tag_id) VALUES (1, 1);"); + SqliteTestHelper.Exec(connection, "INSERT INTO card_tags (card_id, tag_id) VALUES (1, 2);"); + + AppDb = new AppDatabase(Path.Combine(Directory.FullPath, "app.db"), () => Clock); + Repository = new WorkspaceRepository(Directory.FullPath, AppDb); + } + + /// The last_edited_at currently mirrored into the registry. + public long LastEditedAt => AppDb.GetWorkspaces().Single().LastEditedAt; + + public void Dispose() + { + Repository.Dispose(); + AppDb.Dispose(); + Directory.Dispose(); + } + } + + [TestMethod] + public void UpdateColumnWithoutChangeWritesNothing() + { + using var fixture = new RepositoryFixture(); + ColumnModel before = fixture.Repository.GetColumn(fixture.ColumnId)!; + + ColumnModel result = fixture.Repository.UpdateColumn(fixture.ColumnId, "Col", ""); + + Assert.AreEqual(before.UpdatedAt, result.UpdatedAt); + Assert.AreEqual(0, fixture.AppDb.GetWorkspaces().Count); + } + + [TestMethod] + public void UpdateColumnChangeRefreshesColumnTimestampAndLastEdited() + { + using var fixture = new RepositoryFixture(); + fixture.Clock = 2000; + + ColumnModel updated = fixture.Repository.UpdateColumn(fixture.ColumnId, "Col2", "desc"); + + Assert.IsTrue(updated.UpdatedAt > 100L); + Assert.AreEqual(2000L, fixture.LastEditedAt); + } + + [TestMethod] + public void DeleteColumnCascadesToCardsAndTagLinks() + { + using var fixture = new RepositoryFixture(); + fixture.Clock = 2000; + + Assert.IsTrue(fixture.Repository.DeleteColumn(fixture.ColumnId)); + + Assert.AreEqual(0, fixture.Repository.GetCards().Count); + // The tags themselves survive the column deletion. + Assert.AreEqual(2, fixture.Repository.GetTags().Count); + using SqliteConnection connection = SqliteTestHelper.OpenWorkspace(fixture.Directory.FullPath); + Assert.AreEqual(0L, SqliteTestHelper.ScalarLong(connection, "SELECT COUNT(*) FROM card_tags;")); + Assert.AreEqual(2000L, fixture.LastEditedAt); + } + + [TestMethod] + public void DeleteCardRemovesTagLinksButKeepsTags() + { + using var fixture = new RepositoryFixture(); + + Assert.IsTrue(fixture.Repository.DeleteCard(fixture.CardId)); + + Assert.IsNull(fixture.Repository.GetCard(fixture.CardId)); + Assert.AreEqual(2, fixture.Repository.GetTags().Count); + using SqliteConnection connection = SqliteTestHelper.OpenWorkspace(fixture.Directory.FullPath); + Assert.AreEqual(0L, SqliteTestHelper.ScalarLong(connection, "SELECT COUNT(*) FROM card_tags;")); + } + + [TestMethod] + public void DeleteTagDetachesFromCardsButKeepsCards() + { + using var fixture = new RepositoryFixture(); + + Assert.IsTrue(fixture.Repository.DeleteTag(fixture.BugTagId)); + + Assert.AreEqual(1, fixture.Repository.GetTags().Count); + CardModel card = fixture.Repository.GetCard(fixture.CardId)!; + Assert.AreEqual(1, card.Tags.Count); + Assert.AreEqual("ui", card.Tags[0].Name); + } + + [TestMethod] + public void UpdateCardWithoutEffectiveChangeWritesNothing() + { + using var fixture = new RepositoryFixture(); + fixture.Clock = 2000; + CardModel before = fixture.Repository.GetCard(fixture.CardId)!; + + CardModel result = fixture.Repository.UpdateCard(fixture.CardId, "T", "C", [fixture.BugTagId, fixture.UiTagId]); + + Assert.AreEqual(before.UpdatedAt, result.UpdatedAt); + Assert.AreEqual(0, fixture.AppDb.GetWorkspaces().Count); + } + + [TestMethod] + public void UpdateCardTitleChangeRefreshesTimestampsAndLastEdited() + { + using var fixture = new RepositoryFixture(); + fixture.Clock = 2000; + + CardModel updated = fixture.Repository.UpdateCard(fixture.CardId, "T2", "C", [fixture.BugTagId, fixture.UiTagId]); + + Assert.IsTrue(updated.UpdatedAt > 100L); + Assert.AreEqual(2000L, fixture.LastEditedAt); + } + + [TestMethod] + public void UpdateCardTagSetOnlyChangeStillRefreshesCardTimestamp() + { + using var fixture = new RepositoryFixture(); + fixture.Clock = 2000; + + CardModel updated = fixture.Repository.UpdateCard(fixture.CardId, "T", "C", [fixture.BugTagId]); + + Assert.IsTrue(updated.UpdatedAt > 100L); + Assert.AreEqual(1, updated.Tags.Count); + Assert.AreEqual(2000L, fixture.LastEditedAt); + } + + [TestMethod] + public void UpdateCardWithUnknownTagRollsBackAtomically() + { + using var fixture = new RepositoryFixture(); + fixture.Clock = 2000; + + // Tag 999 does not exist: the FK fires mid-transaction and the whole edit must roll back. + Assert.ThrowsExactly(() => + fixture.Repository.UpdateCard(fixture.CardId, "TX", "CX", [fixture.BugTagId, 999])); + + CardModel after = fixture.Repository.GetCard(fixture.CardId)!; + Assert.AreEqual("T", after.Title); + Assert.AreEqual("C", after.Content); + Assert.AreEqual(100L, after.UpdatedAt); + Assert.AreEqual(2, after.Tags.Count); + Assert.AreEqual(0, fixture.AppDb.GetWorkspaces().Count); + } + + [TestMethod] + public void MoveCardRefreshesLastEditedButNotCardTimestamp() + { + using var fixture = new RepositoryFixture(); + long target = fixture.Repository.AddColumn("Target", "").Id; + fixture.Clock = 2000; + + Assert.IsTrue(fixture.Repository.MoveCard(fixture.CardId, target)); + + CardModel moved = fixture.Repository.GetCard(fixture.CardId)!; + Assert.AreEqual(target, moved.ColumnId); + // The move is a write but deliberately leaves the card's updated_at alone. + Assert.AreEqual(100L, moved.UpdatedAt); + Assert.AreEqual(2000L, fixture.LastEditedAt); + } + + [TestMethod] + public void MoveCardToCurrentColumnWritesNothing() + { + using var fixture = new RepositoryFixture(); + fixture.Clock = 2000; + + Assert.IsFalse(fixture.Repository.MoveCard(fixture.CardId, fixture.ColumnId)); + + Assert.AreEqual(0, fixture.AppDb.GetWorkspaces().Count); + } + + [TestMethod] + public void AddCardCreatesWithEqualTimestampsAndLinkedTags() + { + using var fixture = new RepositoryFixture(); + fixture.Clock = 2000; + + CardModel card = fixture.Repository.AddCard(fixture.ColumnId, "N", "body", [fixture.UiTagId]); + + Assert.IsTrue(card.CreatedAt > 0L); + Assert.AreEqual(card.CreatedAt, card.UpdatedAt); + Assert.AreEqual(1, card.Tags.Count); + Assert.AreEqual("ui", card.Tags[0].Name); + Assert.AreEqual(2000L, fixture.LastEditedAt); + } + + [TestMethod] + public void UpdateTagWithoutChangeWritesNothing() + { + using var fixture = new RepositoryFixture(); + fixture.Clock = 2000; + + TagModel result = fixture.Repository.UpdateTag(fixture.BugTagId, "bug", "#ff0000", ""); + + Assert.AreEqual("bug", result.Name); + Assert.AreEqual(0, fixture.AppDb.GetWorkspaces().Count); + } + + [TestMethod] + public void UpdateTagChangeWritesAndTouchesLastEdited() + { + using var fixture = new RepositoryFixture(); + fixture.Clock = 3000; + + TagModel updated = fixture.Repository.UpdateTag(fixture.BugTagId, "defect", "#ee0a0a", "desc"); + + Assert.AreEqual("defect", updated.Name); + Assert.AreEqual("#ee0a0a", updated.Color); + Assert.AreEqual("desc", updated.Description); + Assert.AreEqual(3000L, fixture.LastEditedAt); + } + + [TestMethod] + public void GetTagsWithUsageCountsAssignments() + { + using var fixture = new RepositoryFixture(); + + IReadOnlyList<(TagModel Tag, long UsageCount)> before = fixture.Repository.GetTagsWithUsage(); + Assert.AreEqual(1L, before.Single(entry => entry.Tag.Name == "bug").UsageCount); + Assert.AreEqual(1L, before.Single(entry => entry.Tag.Name == "ui").UsageCount); + + // Detaching one tag via an edit confirmation drops its usage to zero but keeps the tag. + fixture.Clock = 2000; + fixture.Repository.UpdateCard(fixture.CardId, "T", "C", [fixture.UiTagId]); + + IReadOnlyList<(TagModel Tag, long UsageCount)> after = fixture.Repository.GetTagsWithUsage(); + Assert.AreEqual(0L, after.Single(entry => entry.Tag.Name == "bug").UsageCount); + Assert.AreEqual(1L, after.Single(entry => entry.Tag.Name == "ui").UsageCount); + } +} diff --git a/YKanBan/Models/CardModel.cs b/YKanBan/Models/CardModel.cs new file mode 100644 index 0000000..aa36827 --- /dev/null +++ b/YKanBan/Models/CardModel.cs @@ -0,0 +1,18 @@ +namespace YKanBan.Models; + +/// A card with its assigned tags loaded. +/// Row id. +/// Owning column. +/// Possibly empty; the empty string is the canonical "no title". +/// Multiline body, possibly empty. +/// Unix seconds of creation. +/// Unix seconds of the last confirmed edit write. +/// Tags assigned to this card, ordered by tag id. +public sealed record CardModel( + long Id, + long ColumnId, + string Title, + string Content, + long CreatedAt, + long UpdatedAt, + IReadOnlyList Tags); diff --git a/YKanBan/Models/ColumnModel.cs b/YKanBan/Models/ColumnModel.cs new file mode 100644 index 0000000..992ac99 --- /dev/null +++ b/YKanBan/Models/ColumnModel.cs @@ -0,0 +1,9 @@ +namespace YKanBan.Models; + +/// A board column as stored in the workspace database. +/// Row id. +/// Non-empty unique title. +/// Free-form description, possibly empty. +/// Unix seconds of creation. +/// Unix seconds of the last title/description change. +public sealed record ColumnModel(long Id, string Title, string Description, long CreatedAt, long UpdatedAt); diff --git a/YKanBan/Models/TagModel.cs b/YKanBan/Models/TagModel.cs new file mode 100644 index 0000000..c124ce6 --- /dev/null +++ b/YKanBan/Models/TagModel.cs @@ -0,0 +1,8 @@ +namespace YKanBan.Models; + +/// A tag assignable to cards. Tags are never garbage-collected: unreferenced tags stay until deleted explicitly. +/// Row id. +/// Arbitrary non-empty text; no charset or length rules. +/// Fixed #RRGGBB form. +/// Free-form description, possibly empty. +public sealed record TagModel(long Id, string Name, string Color, string Description); diff --git a/YKanBan/Platform/PathComparer.cs b/YKanBan/Platform/PathComparer.cs new file mode 100644 index 0000000..a1d7cd3 --- /dev/null +++ b/YKanBan/Platform/PathComparer.cs @@ -0,0 +1,64 @@ +namespace YKanBan.Platform; + +/// +/// Compares and normalizes workspace folder paths. Normalization produces +/// the canonical absolute form: separators unified by the operating system, +/// "." and ".." collapsed, trailing separator trimmed except on roots. +/// Symbolic links are deliberately NOT resolved. Case handling is an +/// injected platform rule — ignored on Windows, significant on Unix and +/// macOS — so the Unix behavior is testable on any host. +/// +public sealed class PathComparer : IEqualityComparer +{ + private readonly StringComparison _comparison; + + /// Creates a comparer with an explicit case-sensitivity rule. + /// True for Windows-style path semantics. + public PathComparer(bool ignoreCase) + { + _comparison = ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + } + + /// The comparer matching the current operating system's path semantics. + public static PathComparer ForCurrentPlatform() => new(OperatingSystem.IsWindows()); + + /// Returns the canonical absolute form used for storage and display. + public string Normalize(string path) + { + // GetFullPath makes the path absolute, collapses relative segments and + // unifies separators; it preserves a trailing separator, so trim it to + // keep the canonical form unique ("D:\repo" and "D:\repo\" are one path). + string full = Path.GetFullPath(path); + return TrimTrailingSeparator(full); + } + + /// Determines whether two paths denote the same location under the configured platform rules. + public bool Equals(string? x, string? y) + { + if (x is null || y is null) + { + return ReferenceEquals(x, y); + } + return string.Equals(Normalize(x), Normalize(y), _comparison); + } + + /// Returns a hash code consistent with . + public int GetHashCode(string path) => Normalize(path).GetHashCode(_comparison); + + /// Removes trailing separators while keeping root forms intact ("C:\", "/"). + private static string TrimTrailingSeparator(string fullPath) + { + bool isDriveRoot = fullPath.Length == 3 + && char.IsAsciiLetter(fullPath[0]) + && fullPath[1] == ':' + && IsSeparator(fullPath[2]); + if (isDriveRoot || fullPath == "/") + { + return fullPath; + } + return fullPath.TrimEnd('/', '\\'); + } + + /// Returns true for both supported separator characters. + private static bool IsSeparator(char character) => character is '/' or '\\'; +} diff --git a/YKanBan/Storage/Workspace/WorkspaceConnectionPool.cs b/YKanBan/Storage/Workspace/WorkspaceConnectionPool.cs new file mode 100644 index 0000000..f5772db --- /dev/null +++ b/YKanBan/Storage/Workspace/WorkspaceConnectionPool.cs @@ -0,0 +1,183 @@ +using YKanBan.Platform; +using YKanBan.Storage.AppData; + +namespace YKanBan.Storage.Workspace; + +/// +/// Fixed-capacity LRU pool of open workspace sessions (exclusive lock plus +/// repository per workspace). Opening a workspace also registers it in the +/// global app.db sidebar registry. Eviction — by capacity or explicitly — +/// only releases resources: the sidebar entry survives, and the workspace +/// can be reopened at any time (a lock held elsewhere then surfaces as a +/// lock conflict). +/// +public sealed class WorkspaceConnectionPool : IDisposable +{ + private readonly object _gate = new(); + private readonly int _capacity; + private readonly AppDatabase _appDatabase; + private readonly PathComparer _pathComparer; + private readonly Dictionary> _sessions; + private readonly LinkedList _recency = new(); + + /// Creates a pool. + /// Maximum number of simultaneously open workspaces. + /// Global registry for sidebar entries and last_edited_at. + /// Platform path rules used to identify workspaces. + public WorkspaceConnectionPool(int capacity, AppDatabase appDatabase, PathComparer pathComparer) + { + ArgumentOutOfRangeException.ThrowIfLessThan(capacity, 1); + _capacity = capacity; + _appDatabase = appDatabase; + _pathComparer = pathComparer; + _sessions = new Dictionary>(pathComparer); + } + + /// Number of currently open workspaces. + public int OpenCount + { + get + { + lock (_gate) + { + return _sessions.Count; + } + } + } + + /// Returns the open session and marks it most recently used, or null when the workspace is not open. + public WorkspaceSession? Find(string folderPath) + { + lock (_gate) + { + if (_sessions.TryGetValue(_pathComparer.Normalize(folderPath), out LinkedListNode? node)) + { + Promote(node); + return node.Value; + } + return null; + } + } + + /// Records user activity (click/switch) for an open workspace; no-op when it is not open. + public void Touch(string folderPath) => Find(folderPath); + + /// Opens the workspace session, or returns the existing one; evicts LRU entries beyond capacity. + /// The folder does not exist. + /// The folder has no .ykanban. + /// Another instance holds the lock. + public WorkspaceSession Open(string folderPath) + { + string canonical = _pathComparer.Normalize(folderPath); + + // Fast path: already open → promote and return. + lock (_gate) + { + if (_sessions.TryGetValue(canonical, out LinkedListNode? open)) + { + Promote(open); + return open.Value; + } + } + + // Validate the folder state up front so callers get precise state errors. + if (!Directory.Exists(canonical)) + { + throw new WorkspaceDirectoryMissingException(canonical); + } + if (!WorkspaceInitializer.IsWorkspace(canonical)) + { + throw new WorkspaceNotInitializedException(canonical); + } + + // Acquire the exclusive lock before touching the database. + WorkspaceLock workspaceLock = WorkspaceLock.Acquire(canonical); + WorkspaceSession session; + try + { + var repository = new WorkspaceRepository(canonical, _appDatabase); + session = new WorkspaceSession + { + FolderPath = canonical, + Lock = workspaceLock, + Repository = repository, + }; + } + catch + { + workspaceLock.Dispose(); + throw; + } + + lock (_gate) + { + // Re-check: another open may have won the race; the existing session wins. + if (_sessions.TryGetValue(canonical, out LinkedListNode? winner)) + { + session.Dispose(); + Promote(winner); + return winner.Value; + } + + _sessions.Add(canonical, _recency.AddFirst(session)); + + // Evict least recently used entries beyond capacity; releases resources only. + while (_sessions.Count > _capacity) + { + LinkedListNode lru = _recency.Last!; + _recency.RemoveLast(); + _sessions.Remove(lru.Value.FolderPath); + lru.Value.Dispose(); + } + + // Opening registers the workspace in the persistent sidebar registry. + _appDatabase.UpsertWorkspace(canonical); + return session; + } + } + + /// Releases a workspace's resources; the sidebar entry is kept. Returns true when it was open. + public bool Close(string folderPath) + { + lock (_gate) + { + if (!_sessions.Remove(_pathComparer.Normalize(folderPath), out LinkedListNode? node)) + { + return false; + } + _recency.Remove(node); + node.Value.Dispose(); + return true; + } + } + + /// Releases the workspace's resources and removes its sidebar entry. Returns true when it was open. + public bool Remove(string folderPath) + { + string canonical = _pathComparer.Normalize(folderPath); + bool wasOpen = Close(canonical); + _appDatabase.RemoveWorkspace(canonical); + return wasOpen; + } + + /// Releases all sessions. + public void Dispose() + { + lock (_gate) + { + foreach (WorkspaceSession session in _recency) + { + session.Dispose(); + } + _recency.Clear(); + _sessions.Clear(); + } + } + + /// Moves a session node to the most-recently-used position. + private void Promote(LinkedListNode node) + { + _recency.Remove(node); + _recency.AddFirst(node); + } +} diff --git a/YKanBan/Storage/Workspace/WorkspaceRepository.cs b/YKanBan/Storage/Workspace/WorkspaceRepository.cs new file mode 100644 index 0000000..565e53d --- /dev/null +++ b/YKanBan/Storage/Workspace/WorkspaceRepository.cs @@ -0,0 +1,446 @@ +using Microsoft.Data.Sqlite; +using YKanBan.Models; +using YKanBan.Storage.AppData; + +namespace YKanBan.Storage.Workspace; + +/// +/// Data access for one open workspace database: CRUD for columns, cards and +/// tags, card moves and tag assignment, plus the write-through that +/// refreshes the workspace's last_edited_at in the global app.db after +/// every successful data write. +/// +/// Timestamp rules: +/// - cards.updated_at refreshes only when a card edit is confirmed with +/// changes; moving a card to another column never refreshes it; +/// - columns.updated_at refreshes only when title or description changes; +/// - a confirmation with no effective change performs no write at all. +/// +public sealed class WorkspaceRepository : IDisposable +{ + private readonly string _folderPath; + private readonly AppDatabase _appDatabase; + private readonly SqliteConnection _connection; + + /// Opens the repository over an initialized workspace database. + /// Workspace folder; the exact value is used as the app.db registry key. + /// Global registry refreshed after every successful write. + public WorkspaceRepository(string folderPath, AppDatabase appDatabase) + { + _folderPath = folderPath; + _appDatabase = appDatabase; + _connection = SqliteDatabase.Open(WorkspacePaths.Database(folderPath), WorkspaceSchema.Migrations); + } + + /// Closes the underlying connection. + public void Dispose() => _connection.Dispose(); + + #region Columns + + /// Returns all columns ordered by id. + public IReadOnlyList GetColumns() + { + using var command = CreateCommand( + "SELECT id, title, description, created_at, updated_at FROM columns ORDER BY id ASC;"); + using var reader = command.ExecuteReader(); + + var columns = new List(); + while (reader.Read()) + { + columns.Add(ReadColumn(reader)); + } + return columns; + } + + /// Returns the column with the given id, or null. + public ColumnModel? GetColumn(long columnId) + { + using var command = CreateCommand( + "SELECT id, title, description, created_at, updated_at FROM columns WHERE id = $id;"); + command.Parameters.AddWithValue("$id", columnId); + using var reader = command.ExecuteReader(); + return reader.Read() ? ReadColumn(reader) : null; + } + + /// Creates a column with created_at = updated_at. + /// The title is empty or not unique. + public ColumnModel AddColumn(string title, string description) + { + long now = UnixTime.Now; + using var transaction = _connection.BeginTransaction(); + long id = ExecuteInsert(transaction, + "INSERT INTO columns (title, description, created_at, updated_at) VALUES ($title, $description, $now, $now);", + ("$title", title), ("$description", description), ("$now", now)); + transaction.Commit(); + + TouchEdited(); + return new ColumnModel(id, title, description, now, now); + } + + /// Rewrites title and description; a no-change call writes nothing. + /// The column does not exist. + /// The new title is empty or not unique. + public ColumnModel UpdateColumn(long columnId, string title, string description) + { + ColumnModel existing = GetColumn(columnId) + ?? throw new InvalidOperationException($"Column {columnId} does not exist."); + + // Unchanged title and description: no write, no timestamps. + if (existing.Title == title && existing.Description == description) + { + return existing; + } + + long now = UnixTime.Now; + using var command = CreateCommand( + "UPDATE columns SET title = $title, description = $description, updated_at = $now WHERE id = $id;"); + command.Parameters.AddWithValue("$title", title); + command.Parameters.AddWithValue("$description", description); + command.Parameters.AddWithValue("$now", now); + command.Parameters.AddWithValue("$id", columnId); + command.ExecuteNonQuery(); + + TouchEdited(); + return new ColumnModel(columnId, title, description, existing.CreatedAt, now); + } + + /// Deletes a column; the DDL cascade removes its cards and their tag links. Returns true when a row was deleted. + public bool DeleteColumn(long columnId) => ExecuteDelete("DELETE FROM columns WHERE id = $id;", columnId); + + #endregion + + #region Cards + + /// Returns all cards with their tags loaded, ordered by id. + public IReadOnlyList GetCards() + { + using var command = CreateCommand(""" + SELECT c.id, c.column_id, c.title, c.content, c.created_at, c.updated_at, + t.id, t.name, t.color, t.description + FROM cards c + LEFT JOIN card_tags ct ON ct.card_id = c.id + LEFT JOIN tags t ON t.id = ct.tag_id + ORDER BY c.id ASC; + """); + using var reader = command.ExecuteReader(); + + // Single grouped pass: one output card per distinct card id, its tags accumulated alongside. + var cards = new List(); + var tagsByCard = new Dictionary>(); + while (reader.Read()) + { + long cardId = reader.GetInt64(0); + if (!tagsByCard.TryGetValue(cardId, out List? tags)) + { + tags = new List(); + tagsByCard[cardId] = tags; + cards.Add(new CardModel( + cardId, + reader.GetInt64(1), + reader.GetString(2), + reader.GetString(3), + reader.GetInt64(4), + reader.GetInt64(5), + tags)); + } + if (!reader.IsDBNull(6)) + { + tags.Add(new TagModel(reader.GetInt64(6), reader.GetString(7), reader.GetString(8), reader.GetString(9))); + } + } + return cards; + } + + /// Returns the card with the given id (tags loaded), or null. + public CardModel? GetCard(long cardId) + { + using var command = CreateCommand(""" + SELECT c.id, c.column_id, c.title, c.content, c.created_at, c.updated_at, + t.id, t.name, t.color, t.description + FROM cards c + LEFT JOIN card_tags ct ON ct.card_id = c.id + LEFT JOIN tags t ON t.id = ct.tag_id + WHERE c.id = $id + ORDER BY c.id ASC; + """); + command.Parameters.AddWithValue("$id", cardId); + using var reader = command.ExecuteReader(); + + CardModel? card = null; + var tags = new List(); + while (reader.Read()) + { + if (card is null) + { + card = new CardModel( + reader.GetInt64(0), + reader.GetInt64(1), + reader.GetString(2), + reader.GetString(3), + reader.GetInt64(4), + reader.GetInt64(5), + tags); + } + if (!reader.IsDBNull(6)) + { + tags.Add(new TagModel(reader.GetInt64(6), reader.GetString(7), reader.GetString(8), reader.GetString(9))); + } + } + return card; + } + + /// Creates a card with its initial tag assignment (created_at = updated_at). + /// The column does not exist, or a tag id does not exist. + public CardModel AddCard(long columnId, string title, string content, IReadOnlyCollection tagIds) + { + long now = UnixTime.Now; + using var transaction = _connection.BeginTransaction(); + long id = ExecuteInsert(transaction, + "INSERT INTO cards (column_id, title, content, created_at, updated_at) VALUES ($columnId, $title, $content, $now, $now);", + ("$columnId", columnId), ("$title", title), ("$content", content), ("$now", now)); + foreach (long tagId in tagIds.Distinct()) + { + ExecuteNonQuery(transaction, + "INSERT INTO card_tags (card_id, tag_id) VALUES ($cardId, $tagId);", + ("$cardId", id), ("$tagId", tagId)); + } + transaction.Commit(); + + TouchEdited(); + return new CardModel(id, columnId, title, content, now, now, LoadTags(id)); + } + + /// + /// Applies one card edit confirmation as a single atomic unit: the card + /// row (title, content, refreshed updated_at) plus the complete tag + /// assignment. A confirmation with no effective change writes nothing; + /// a failure mid-way rolls the whole edit back. + /// + /// The card does not exist. + /// A tag id does not exist; nothing is written. + public CardModel UpdateCard(long cardId, string title, string content, IReadOnlyCollection tagIds) + { + CardModel existing = GetCard(cardId) + ?? throw new InvalidOperationException($"Card {cardId} does not exist."); + + // No change in title, content or tag set: no write, no timestamps. + bool tagsChanged = !tagIds.Distinct().ToHashSet().SetEquals(existing.Tags.Select(tag => tag.Id)); + if (existing.Title == title && existing.Content == content && !tagsChanged) + { + return existing; + } + + long now = UnixTime.Now; + using var transaction = _connection.BeginTransaction(); + // The confirm write refreshes updated_at even when only the tag set changed. + ExecuteNonQuery(transaction, + "UPDATE cards SET title = $title, content = $content, updated_at = $now WHERE id = $id;", + ("$title", title), ("$content", content), ("$now", now), ("$id", cardId)); + // Tag assignment is rewritten wholesale; card_tags carries nothing beyond the pair. + ExecuteNonQuery(transaction, "DELETE FROM card_tags WHERE card_id = $id;", ("$id", cardId)); + foreach (long tagId in tagIds.Distinct()) + { + ExecuteNonQuery(transaction, + "INSERT INTO card_tags (card_id, tag_id) VALUES ($cardId, $tagId);", + ("$cardId", cardId), ("$tagId", tagId)); + } + transaction.Commit(); + + TouchEdited(); + return new CardModel(cardId, existing.ColumnId, title, content, existing.CreatedAt, now, LoadTags(cardId)); + } + + /// + /// Moves a card to another column. This is a data write (refreshes the + /// workspace last_edited_at) but deliberately does NOT refresh the + /// card's updated_at; moving to the current column is a no-write no-op. + /// Returns true when the card moved. + /// + public bool MoveCard(long cardId, long targetColumnId) + { + using var command = CreateCommand( + "UPDATE cards SET column_id = $columnId WHERE id = $id AND column_id <> $columnId;"); + command.Parameters.AddWithValue("$columnId", targetColumnId); + command.Parameters.AddWithValue("$id", cardId); + bool moved = command.ExecuteNonQuery() > 0; + if (moved) + { + TouchEdited(); + } + return moved; + } + + /// Deletes a card; the DDL cascade removes its tag links. Returns true when a row was deleted. + public bool DeleteCard(long cardId) => ExecuteDelete("DELETE FROM cards WHERE id = $id;", cardId); + + #endregion + + #region Tags + + /// Returns all tags ordered by id; unreferenced tags are included. + public IReadOnlyList GetTags() + { + using var command = CreateCommand( + "SELECT id, name, color, description FROM tags ORDER BY id ASC;"); + using var reader = command.ExecuteReader(); + + var tags = new List(); + while (reader.Read()) + { + tags.Add(ReadTag(reader)); + } + return tags; + } + + /// Returns every tag together with the number of cards using it, ordered by id. + public IReadOnlyList<(TagModel Tag, long UsageCount)> GetTagsWithUsage() + { + using var command = CreateCommand(""" + SELECT t.id, t.name, t.color, t.description, COUNT(ct.card_id) + FROM tags t + LEFT JOIN card_tags ct ON ct.tag_id = t.id + GROUP BY t.id, t.name, t.color, t.description + ORDER BY t.id ASC; + """); + using var reader = command.ExecuteReader(); + + var tags = new List<(TagModel Tag, long UsageCount)>(); + while (reader.Read()) + { + tags.Add((ReadTag(reader), reader.GetInt64(4))); + } + return tags; + } + + /// Creates a tag. + /// The name is empty/not unique, or the color is not #RRGGBB. + public TagModel AddTag(string name, string color, string description) + { + using var transaction = _connection.BeginTransaction(); + long id = ExecuteInsert(transaction, + "INSERT INTO tags (name, color, description) VALUES ($name, $color, $description);", + ("$name", name), ("$color", color), ("$description", description)); + transaction.Commit(); + + TouchEdited(); + return new TagModel(id, name, color, description); + } + + /// Rewrites name, color and description; a no-change call writes nothing. + /// The tag does not exist. + /// The new name is empty/not unique, or the color is not #RRGGBB. + public TagModel UpdateTag(long tagId, string name, string color, string description) + { + TagModel existing = GetTag(tagId) + ?? throw new InvalidOperationException($"Tag {tagId} does not exist."); + + // Unchanged name, color and description: no write. + if (existing.Name == name && existing.Color == color && existing.Description == description) + { + return existing; + } + + using var command = CreateCommand( + "UPDATE tags SET name = $name, color = $color, description = $description WHERE id = $id;"); + command.Parameters.AddWithValue("$name", name); + command.Parameters.AddWithValue("$color", color); + command.Parameters.AddWithValue("$description", description); + command.Parameters.AddWithValue("$id", tagId); + command.ExecuteNonQuery(); + + TouchEdited(); + return new TagModel(tagId, name, color, description); + } + + /// Deletes a tag; the DDL cascade removes its card links while the cards survive. Returns true when a row was deleted. + public bool DeleteTag(long tagId) => ExecuteDelete("DELETE FROM tags WHERE id = $id;", tagId); + + #endregion + + #region Helpers + + /// Returns the tag with the given id, or null. + private TagModel? GetTag(long tagId) + { + using var command = CreateCommand( + "SELECT id, name, color, description FROM tags WHERE id = $id;"); + command.Parameters.AddWithValue("$id", tagId); + using var reader = command.ExecuteReader(); + return reader.Read() ? ReadTag(reader) : null; + } + + /// Returns the tags currently assigned to a card, ordered by id. + private IReadOnlyList LoadTags(long cardId) + { + using var command = CreateCommand(""" + SELECT t.id, t.name, t.color, t.description + FROM tags t + JOIN card_tags ct ON ct.tag_id = t.id + WHERE ct.card_id = $cardId + ORDER BY t.id ASC; + """); + command.Parameters.AddWithValue("$cardId", cardId); + using var reader = command.ExecuteReader(); + + var tags = new List(); + while (reader.Read()) + { + tags.Add(ReadTag(reader)); + } + return tags; + } + + /// Reads a columns row from the reader cursor. + private static ColumnModel ReadColumn(SqliteDataReader reader) => + new(reader.GetInt64(0), reader.GetString(1), reader.GetString(2), reader.GetInt64(3), reader.GetInt64(4)); + + /// Reads a tags row from the reader cursor. + private static TagModel ReadTag(SqliteDataReader reader) => + new(reader.GetInt64(0), reader.GetString(1), reader.GetString(2), reader.GetString(3)); + + /// Creates a command bound to this connection. + private SqliteCommand CreateCommand(string sql, SqliteTransaction? transaction = null) + { + SqliteCommand command = _connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = sql; + return command; + } + + /// Executes a parameterized non-query statement inside a transaction. + private void ExecuteNonQuery(SqliteTransaction transaction, string sql, params (string Name, object? Value)[] parameters) + { + using SqliteCommand command = CreateCommand(sql, transaction); + foreach ((string name, object? value) in parameters) + { + command.Parameters.AddWithValue(name, value); + } + command.ExecuteNonQuery(); + } + + /// Inserts a row inside a transaction and returns the generated rowid. + private long ExecuteInsert(SqliteTransaction transaction, string sql, params (string Name, object? Value)[] parameters) + { + ExecuteNonQuery(transaction, sql, parameters); + using SqliteCommand rowId = CreateCommand("SELECT last_insert_rowid();", transaction); + return (long)rowId.ExecuteScalar()!; + } + + /// Deletes by id; refreshes last_edited_at only when a row was actually removed. + private bool ExecuteDelete(string sql, long id) + { + using SqliteCommand command = CreateCommand(sql); + command.Parameters.AddWithValue("$id", id); + if (command.ExecuteNonQuery() == 0) + { + return false; + } + TouchEdited(); + return true; + } + + /// Mirrors a successful data write into the global registry. + private void TouchEdited() => _appDatabase.TouchLastEdited(_folderPath); + + #endregion +} diff --git a/YKanBan/Storage/Workspace/WorkspaceSession.cs b/YKanBan/Storage/Workspace/WorkspaceSession.cs new file mode 100644 index 0000000..7f6eb4c --- /dev/null +++ b/YKanBan/Storage/Workspace/WorkspaceSession.cs @@ -0,0 +1,21 @@ +namespace YKanBan.Storage.Workspace; + +/// One open workspace: the exclusive lock plus its repository. +public sealed class WorkspaceSession : IDisposable +{ + /// Canonical workspace folder path. + public required string FolderPath { get; init; } + + /// The exclusive lock held while this session is open. + public required WorkspaceLock Lock { get; init; } + + /// The data access object for this workspace. + public required WorkspaceRepository Repository { get; init; } + + /// Disposes the repository, then releases the lock. + public void Dispose() + { + Repository.Dispose(); + Lock.Dispose(); + } +}