diff --git a/Sources/MusicAssistantCore/Persistence/ProjectStore.swift b/Sources/MusicAssistantCore/Persistence/ProjectStore.swift index 2b4715f..9434341 100644 --- a/Sources/MusicAssistantCore/Persistence/ProjectStore.swift +++ b/Sources/MusicAssistantCore/Persistence/ProjectStore.swift @@ -1,6 +1,81 @@ -public protocol ProjectStore: Sendable { - associatedtype Project +import Foundation - func save(_ project: Project) async throws +public protocol ProjectStore: Sendable { + func create(_ project: SongProject) async throws + func save(_ project: SongProject) async throws + func open(id: String) async throws -> SongProject func loadProjectIDs() async throws -> [String] } + +public actor LocalSongProjectStore: ProjectStore { + private let directoryURL: URL + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + public init(directoryURL: URL) { + self.directoryURL = directoryURL + self.encoder = JSONEncoder() + self.decoder = JSONDecoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + } + + public func create(_ project: SongProject) async throws { + try ensureDirectoryExists() + let url = projectURL(for: project.id) + guard !FileManager.default.fileExists(atPath: url.path) else { + throw LocalSongProjectStoreError.projectAlreadyExists(project.id) + } + try saveEncoded(project, to: url) + } + + public func save(_ project: SongProject) async throws { + try ensureDirectoryExists() + try saveEncoded(project, to: projectURL(for: project.id)) + } + + public func open(id: String) async throws -> SongProject { + let url = projectURL(for: id) + guard FileManager.default.fileExists(atPath: url.path) else { + throw LocalSongProjectStoreError.projectNotFound(id) + } + let data = try Data(contentsOf: url) + return try decoder.decode(SongProject.self, from: data) + } + + public func loadProjectIDs() async throws -> [String] { + try ensureDirectoryExists() + let fileURLs = try FileManager.default.contentsOfDirectory( + at: directoryURL, + includingPropertiesForKeys: nil + ) + return fileURLs + .filter { $0.pathExtension == Self.fileExtension } + .map { $0.deletingPathExtension().lastPathComponent } + .sorted() + } + + private func ensureDirectoryExists() throws { + try FileManager.default.createDirectory( + at: directoryURL, + withIntermediateDirectories: true + ) + } + + private func saveEncoded(_ project: SongProject, to url: URL) throws { + let data = try encoder.encode(project) + try data.write(to: url, options: [.atomic]) + } + + private func projectURL(for id: String) -> URL { + directoryURL + .appendingPathComponent(id) + .appendingPathExtension(Self.fileExtension) + } + + private static let fileExtension = "json" +} + +public enum LocalSongProjectStoreError: Error, Equatable, Sendable { + case projectAlreadyExists(String) + case projectNotFound(String) +} diff --git a/Tests/MusicAssistantCoreTests/LocalSongProjectStoreTests.swift b/Tests/MusicAssistantCoreTests/LocalSongProjectStoreTests.swift new file mode 100644 index 0000000..f31cf27 --- /dev/null +++ b/Tests/MusicAssistantCoreTests/LocalSongProjectStoreTests.swift @@ -0,0 +1,75 @@ +import Foundation +import MusicAssistantCore +import XCTest + +final class LocalSongProjectStoreTests: XCTestCase { + private var temporaryDirectoryURL: URL! + + override func setUpWithError() throws { + temporaryDirectoryURL = FileManager.default.temporaryDirectory + .appendingPathComponent("music-assistant-store-tests") + .appendingPathComponent(UUID().uuidString) + } + + override func tearDownWithError() throws { + if let temporaryDirectoryURL { + try? FileManager.default.removeItem(at: temporaryDirectoryURL) + } + temporaryDirectoryURL = nil + } + + func testCreatesOpensSavesAndListsSongProjectsLocally() async throws { + let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL) + var project = SongProject( + id: "local-project", + title: "Local Project", + idea: "Persist this song locally", + genres: [GenreStyle(id: "pop", name: "Pop")] + ) + + try await store.create(project) + + let createdProjectIDs = try await store.loadProjectIDs() + let openedProject = try await store.open(id: project.id) + + XCTAssertEqual(createdProjectIDs, ["local-project"]) + XCTAssertEqual(openedProject, project) + + project.title = "Updated Local Project" + project.setBPMManually(100) + try await store.save(project) + + let updatedProject = try await store.open(id: project.id) + + XCTAssertEqual(updatedProject, project) + } + + func testCreateDoesNotOverwriteExistingProject() async throws { + let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL) + let project = SongProject( + id: "duplicate-project", + title: "Duplicate", + idea: "Do not overwrite" + ) + + try await store.create(project) + + do { + try await store.create(project) + XCTFail("Expected duplicate project creation to fail.") + } catch let error as LocalSongProjectStoreError { + XCTAssertEqual(error, .projectAlreadyExists(project.id)) + } + } + + func testOpenMissingProjectThrowsNotFound() async throws { + let store = LocalSongProjectStore(directoryURL: temporaryDirectoryURL) + + do { + _ = try await store.open(id: "missing") + XCTFail("Expected missing project open to fail.") + } catch let error as LocalSongProjectStoreError { + XCTAssertEqual(error, .projectNotFound("missing")) + } + } +} diff --git a/docs/TASKS.md b/docs/TASKS.md index 0af7183..903cf01 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -32,7 +32,7 @@ requirement is missing and blocks implementation, record it in ## Phase 2 --- Project Persistence & Versions -- [ ] Create new/open/save song projects locally. +- [x] Create new/open/save song projects locally. - [ ] Implement project list. - [ ] Implement immutable or snapshot-based version history. - [ ] Restore a previous version without destroying later versions.