diff --git a/CHANGELOG.md b/CHANGELOG.md index caf84ed..6b36e23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to Forel are documented here. Format loosely follows ## [Unreleased] ### Added +- Copy one or more rules to another watched folder without recreating their conditions and actions. - Date conditions now support hours, with an "is not in the last" option for excluding recently added, created, or modified files. ### Fixed diff --git a/README.md b/README.md index eb4f258..2d3b779 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ File matching and processing happen **locally on your Mac**. Forel does not uplo - **Content matching & OCR** — Match text inside documents, images, and scanned PDFs using local extraction and on-device OCR. See [Content matching](#content-matching) for supported formats and limits. - **Folder watching** — Monitor folders in real time with native macOS FSEvents: up to 2 in Free, unlimited in Pro. - **Dry Run & Run Now** — Preview matching files and planned actions without changing files, then run rules manually on existing files when ready. +- **Copy rules** — Copy a rule to another watched folder, or select several with ⌘-click and choose **Copy rules to folder…**. Copies are independent and keep their action destinations. - **History & Undo** — Review action results and undo supported actions when it is safe to do so. - **Menu bar app** — Forel lives in your menu bar. Toggle individual rules on/off without opening the main window. - **Actions** — Move, copy, rename, tag, trash, delete, or run a custom script. @@ -250,7 +251,7 @@ History / Undo (SQLite) - [ ] Toggle extension hidden / visible - [ ] Move folder based on criteria - [ ] Scheduler — **Pro · Coming soon** -- [ ] Copy rules accros watching folders +- [x] Copy rules across watched folders - [ ] Compress actions - [ ] Sync actions — **Pro · Coming soon** - [ ] Dynamic destination folders with date tokens (e.g. `~/Screenshots/{year}/{month}`) diff --git a/Sources/ForelApp/AppModel.swift b/Sources/ForelApp/AppModel.swift index a50dc07..eb91275 100644 --- a/Sources/ForelApp/AppModel.swift +++ b/Sources/ForelApp/AppModel.swift @@ -587,6 +587,14 @@ final class AppModel: ObservableObject { } catch { showError(error); return false } } + func copyRules(_ ruleIDs: Set, fromFolderID: String, toFolderID: String) throws { + _ = try db.withLock { db in + try db.copyRules(ruleIDs, fromFolderID: fromFolderID, toFolderID: toFolderID, isPro: isPro) + } + selectedFolderId = toFolderID + reloadRules() + } + func deleteRule(_ rule: Rule) { db.withLock { db in try? db.deleteRule(rule.id) } clearRuleExpansionState(rule.id) diff --git a/Sources/ForelApp/Views/CopyRulesSheet.swift b/Sources/ForelApp/Views/CopyRulesSheet.swift new file mode 100644 index 0000000..68f60e8 --- /dev/null +++ b/Sources/ForelApp/Views/CopyRulesSheet.swift @@ -0,0 +1,89 @@ +import SwiftUI +import ForelCore + +struct CopyRulesSheet: View { + let ruleCount: Int + let sourceFolderID: String + let folders: [WatchedFolder] + let isPro: Bool + let onCopy: (String) throws -> Void + let onCancel: () -> Void + @State private var destinationFolderID = "" + @State private var errorMessage: String? + + private var destinations: [WatchedFolder] { + folders.filter { $0.id != sourceFolderID } + } + + private func isAllowed(_ folder: WatchedFolder) -> Bool { + FreePlan.allows(folderID: folder.id, folders: folders, isPro: isPro) + } + + private var canCopy: Bool { + destinations.contains { $0.id == destinationFolderID && isAllowed($0) } + } + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + ViewHeader( + title: ruleCount == 1 ? "Copy Rule" : "Copy \(ruleCount) Rules", + subtitle: "Choose another watched folder", + systemImage: "doc.on.doc" + ) + + if destinations.isEmpty { + Text("Add another watched folder to copy rules to it.") + .foregroundStyle(ForelTheme.secondaryText) + } else { + Picker("Destination", selection: $destinationFolderID) { + Text("Choose a folder").tag("") + ForEach(destinations, id: \.id) { folder in + Text(isAllowed(folder) ? folder.path : "\(folder.path) — Pro") + .tag(folder.id) + .disabled(!isAllowed(folder)) + } + } + .pickerStyle(.menu) + + if !destinations.contains(where: isAllowed) { + Text("Reorder your watched folders or activate Pro to use these destinations.") + .font(.callout) + .foregroundStyle(ForelTheme.secondaryText) + } + } + + Text("Copies are independent and keep their conditions, actions, and enabled state. Action destinations stay unchanged.") + .font(.callout) + .foregroundStyle(ForelTheme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + + if let errorMessage { + Text(errorMessage) + .font(.callout) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + } + + HStack { + Spacer() + Button("Cancel", action: onCancel) + .buttonStyle(SecondaryButtonStyle()) + .keyboardShortcut(.cancelAction) + Button("Copy") { + do { try onCopy(destinationFolderID) } + catch { errorMessage = error.localizedDescription } + } + .buttonStyle(PrimaryButtonStyle()) + .keyboardShortcut(.defaultAction) + .disabled(!canCopy) + } + } + .padding(22) + .frame(width: 520) + .background(ForelTheme.background) + .onAppear { + destinationFolderID = destinations.first(where: isAllowed)?.id ?? "" + } + } +} diff --git a/Sources/ForelApp/Views/RuleListView.swift b/Sources/ForelApp/Views/RuleListView.swift index a898ace..5c1293b 100644 --- a/Sources/ForelApp/Views/RuleListView.swift +++ b/Sources/ForelApp/Views/RuleListView.swift @@ -21,6 +21,8 @@ import ForelCore struct RuleListView: View { @EnvironmentObject var model: AppModel @State private var editingRule: Rule? + @State private var selectedRuleIDs: Set = [] + @State private var copyRequest: RuleCopyRequest? private var selectedFolder: WatchedFolder? { model.folders.first { $0.id == model.selectedFolderId } @@ -39,7 +41,21 @@ struct RuleListView: View { .sheet(item: $model.previewResult) { result in PreviewSheet(result: result) { model.previewResult = nil } } + .sheet(item: $copyRequest) { request in + CopyRulesSheet( + ruleCount: request.ruleIDs.count, + sourceFolderID: request.sourceFolderID, + folders: model.folders, isPro: model.isPro, + onCopy: { destination in + try model.copyRules(request.ruleIDs, fromFolderID: request.sourceFolderID, toFolderID: destination) + copyRequest = nil + }, + onCancel: { copyRequest = nil } + ) + } } + .onChange(of: model.selectedFolderId) { _, _ in selectedRuleIDs.removeAll() } + .onChange(of: model.rules.map(\.id)) { _, ids in selectedRuleIDs.formIntersection(ids) } } private var content: some View { @@ -64,6 +80,18 @@ struct RuleListView: View { if !model.rules.isEmpty { actionBar } + if !selectedRuleIDs.isEmpty { + HStack { + Text("\(selectedRuleIDs.count) rule\(selectedRuleIDs.count == 1 ? "" : "s") selected") + .font(.caption) + .foregroundStyle(ForelTheme.secondaryText) + Button("Copy rules to folder…") { requestCopy(selectedRuleIDs) } + .buttonStyle(SecondaryButtonStyle()) + Button("Deselect all") { selectedRuleIDs.removeAll() } + .buttonStyle(SecondaryButtonStyle()) + Spacer() + } + } if let runNowMessage = model.runNowMessageForSelectedFolder { runNowBanner(runNowMessage) @@ -77,7 +105,7 @@ struct RuleListView: View { Spacer(minLength: 0) } } else { - List { + List(selection: $selectedRuleIDs) { ForEach(Array(model.rules.enumerated()), id: \.element.id) { index, rule in VStack(alignment: .leading, spacing: 4) { if !model.selectedFolderIsAllowed || (!model.isPro && FreePlan.requiredFeature(for: rule) != nil) { @@ -96,10 +124,14 @@ struct RuleListView: View { rule: rule, order: index + 1, isExpanded: model.isRuleExpanded(rule), + isSelected: selectedRuleIDs.contains(rule.id), onToggleExpanded: { model.toggleRuleExpanded(rule) }, - onEdit: { editingRule = rule } + onEdit: { editingRule = rule }, + onCopy: { requestCopy([rule.id]) } ) } + .tag(rule.id) + .background(RuleListSelectionAppearance()) .listRowBackground(Color.clear) .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 4, trailing: 0)) @@ -109,6 +141,10 @@ struct RuleListView: View { .listStyle(.plain) .scrollContentBackground(.hidden) .scrollIndicators(.never) + .contextMenu(forSelectionType: String.self) { ids in + Button("Copy rules to folder…") { requestCopy(ids) } + .disabled(ids.isEmpty) + } } } .animation(.easeInOut(duration: 0.25), value: model.runNowMessageForSelectedFolder) @@ -121,6 +157,11 @@ struct RuleListView: View { max(680, availableHeight - 64) } + private func requestCopy(_ ruleIDs: Set) { + guard let sourceFolderID = model.selectedFolderId, !ruleIDs.isEmpty else { return } + copyRequest = RuleCopyRequest(sourceFolderID: sourceFolderID, ruleIDs: ruleIDs) + } + private func moveRules(from source: IndexSet, to destination: Int) { var ids = model.rules.map(\.id) ids.move(fromOffsets: source, toOffset: destination) @@ -313,8 +354,10 @@ private struct RuleCard: View { let rule: Rule let order: Int let isExpanded: Bool + let isSelected: Bool let onToggleExpanded: () -> Void let onEdit: () -> Void + let onCopy: () -> Void var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -378,6 +421,11 @@ private struct RuleCard: View { Spacer(minLength: 8) + Button(action: onCopy) { + Image(systemName: "doc.on.doc") + } + .buttonStyle(IconButtonStyle()) + Button(action: onEdit) { Image(systemName: "pencil") } @@ -405,7 +453,10 @@ private struct RuleCard: View { .background(RoundedRectangle(cornerRadius: 12, style: .continuous).fill(ForelTheme.surface)) .overlay( RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder(isExpanded ? ForelTheme.accent.opacity(0.55) : ForelTheme.surfaceBorder) + .strokeBorder( + isSelected ? ForelTheme.accent : (isExpanded ? ForelTheme.accent.opacity(0.55) : ForelTheme.surfaceBorder), + lineWidth: isSelected ? 1.5 : 1 + ) ) .opacity(rule.enabled && model.selectedFolderIsAllowed ? 1 : 0.6) } @@ -424,6 +475,47 @@ private struct RuleCard: View { } } +/// Keep native List selection and keyboard handling without drawing a second, +/// rectangular selection background behind the rounded rule cards. +private struct RuleListSelectionAppearance: NSViewRepresentable { + func makeNSView(context: Context) -> SelectionAppearanceView { SelectionAppearanceView() } + + func updateNSView(_ view: SelectionAppearanceView, context: Context) { + view.removeNativeHighlight() + } + + final class SelectionAppearanceView: NSView { + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + removeNativeHighlight() + } + + override func viewDidMoveToSuperview() { + super.viewDidMoveToSuperview() + removeNativeHighlight() + } + + override func hitTest(_ point: NSPoint) -> NSView? { nil } + + func removeNativeHighlight() { + var ancestor = superview + while let view = ancestor { + if let table = view as? NSTableView { + table.selectionHighlightStyle = .none + return + } + ancestor = view.superview + } + } + } +} + +private struct RuleCopyRequest: Identifiable { + let id = UUID() + let sourceFolderID: String + let ruleIDs: Set +} + private struct RuleDetails: View { let rule: Rule diff --git a/Sources/ForelCore/Models/RuleCopyError.swift b/Sources/ForelCore/Models/RuleCopyError.swift new file mode 100644 index 0000000..a3f4db3 --- /dev/null +++ b/Sources/ForelCore/Models/RuleCopyError.swift @@ -0,0 +1,14 @@ +import Foundation + +public enum RuleCopyError: Error, LocalizedError { + case noSelection, sameFolder, missingFolder, missingRules + + public var errorDescription: String? { + switch self { + case .noSelection: "Select at least one rule to copy." + case .sameFolder: "Choose a different watched folder for the copies." + case .missingFolder: "A folder has been removed. Choose another watched folder." + case .missingRules: "Some selected rules no longer exist. Select the rules again." + } + } +} diff --git a/Sources/ForelCore/Persistence/Database.swift b/Sources/ForelCore/Persistence/Database.swift index d6bcca0..a588266 100644 --- a/Sources/ForelCore/Persistence/Database.swift +++ b/Sources/ForelCore/Persistence/Database.swift @@ -519,25 +519,64 @@ public final class Database: @unchecked Sendable { public func insertRule(_ rule: Rule) throws { let priority = try nextRulePriority(folderId: rule.folderId) try transaction { - let stmt = try statement( - """ - INSERT INTO rules (id, folder_id, name, enabled, condition_match, recursion_depth, priority, created_at) - VALUES (?1,?2,?3,?4,?5,?6,?7,?8) - """ - ) - stmt.bind(1, rule.id) - stmt.bind(2, rule.folderId) - stmt.bind(3, rule.name) - stmt.bind(4, bool: rule.enabled) - stmt.bind(5, rule.conditionMatch == .any ? "any" : "all") - stmt.bind(6, rule.recursionDepth ?? -1) - stmt.bind(7, priority) - stmt.bind(8, rule.createdAt) - try stmt.runToCompletion() + try insertRuleRows(rule, priority: priority) + } + } - for condition in rule.conditions { try insertCondition(condition) } - for action in rule.actions { try insertAction(action) } + private func insertRuleRows(_ rule: Rule, priority: Int64) throws { + let stmt = try statement( + """ + INSERT INTO rules (id, folder_id, name, enabled, condition_match, recursion_depth, priority, created_at) + VALUES (?1,?2,?3,?4,?5,?6,?7,?8) + """ + ) + stmt.bind(1, rule.id) + stmt.bind(2, rule.folderId) + stmt.bind(3, rule.name) + stmt.bind(4, bool: rule.enabled) + stmt.bind(5, rule.conditionMatch == .any ? "any" : "all") + stmt.bind(6, rule.recursionDepth ?? -1) + stmt.bind(7, priority) + stmt.bind(8, rule.createdAt) + try stmt.runToCompletion() + + for condition in rule.conditions { try insertCondition(condition) } + for action in rule.actions { try insertAction(action) } + } + + /// Copies the current saved rules in their source order, appending them to + /// the destination. New identities keep edits, deletion, and history separate. + public func copyRules(_ ruleIDs: Set, fromFolderID: String, toFolderID: String, isPro: Bool) throws -> [Rule] { + guard !ruleIDs.isEmpty else { throw RuleCopyError.noSelection } + guard fromFolderID != toFolderID else { throw RuleCopyError.sameFolder } + var copies: [Rule] = [] + try transaction { + let folders = try listFolders() + guard folders.contains(where: { $0.id == fromFolderID }), + folders.contains(where: { $0.id == toFolderID }) else { throw RuleCopyError.missingFolder } + guard FreePlan.allows(folderID: toFolderID, folders: folders, isPro: isPro) else { + throw FreePlanError.proFolder + } + let sources = try listRules(folderId: fromFolderID).filter { ruleIDs.contains($0.id) } + guard sources.count == ruleIDs.count else { throw RuleCopyError.missingRules } + for source in sources { + try FreePlan.validate(source, isPro: isPro) + var copy = Rule( + folderId: toFolderID, name: source.name, enabled: source.enabled, + conditionMatch: source.conditionMatch, recursionDepth: source.recursionDepth, + priority: try nextRulePriority(folderId: toFolderID) + ) + copy.conditions = source.conditions.map { + Condition(ruleId: copy.id, kind: $0.kind, operator: $0.operator, value: $0.value) + } + copy.actions = source.actions.map { + Action(ruleId: copy.id, kind: $0.kind, params: $0.params, position: $0.position) + } + try insertRuleRows(copy, priority: copy.priority) + copies.append(copy) + } } + return copies } public func updateRule(_ rule: Rule) throws { diff --git a/Tests/ForelCoreTests/RuleCopyingTests.swift b/Tests/ForelCoreTests/RuleCopyingTests.swift new file mode 100644 index 0000000..c363f58 --- /dev/null +++ b/Tests/ForelCoreTests/RuleCopyingTests.swift @@ -0,0 +1,177 @@ +import Foundation +import Testing +@testable import ForelCore + +@Suite struct RuleCopyingTests { + @Test func copiesKeepConfigurationAndOrderButHaveIndependentIdentities() throws { + let dir = TempDir() + let databasePath = dir.path + "/rules.db" + let source = WatchedFolder(path: dir.dir("Source")) + let destination = WatchedFolder(path: dir.dir("Destination"), enabled: false) + var copiedRules: [Rule] = [] + var originals: [Rule] = [] + do { + let db = try Database(path: databasePath) + try db.insertFolder(source) + try db.insertFolder(destination) + for index in 0..<3 { + var rule = makeRule( + folderId: source.id, name: "Rule \(index)", enabled: index != 2, + conditionMatch: .any, recursionDepth: index == 0 ? nil : 2 + ) + rule.conditions = [ + makeCondition(.extension_, .is, "txt", ruleId: rule.id), + makeCondition(.dateAdded, .olderThan, "2 hours", ruleId: rule.id), + ] + rule.actions = [ + makeAction(.rename, .object([ActionParam.pattern: .string("{name}-sorted")]), position: 1, ruleId: rule.id), + makeAction(.moveToFolder, .object([ + ActionParam.destination: .string(dir.path + "/Archive"), + ActionParam.onConflict: .string("rename"), + ]), position: 4, ruleId: rule.id), + ] + try db.insertRule(rule) + } + originals = try db.listRules(folderId: source.id) + try db.insertRule(makeRule(folderId: destination.id, name: originals[0].name)) + copiedRules = try db.copyRules( + [originals[2].id, originals[0].id], fromFolderID: source.id, toFolderID: destination.id, isPro: false + ) + let selected = [originals[0], originals[2]] + #expect(copiedRules.map(\.name) == selected.map(\.name)) + #expect(copiedRules.map(\.priority) == [1, 2]) + #expect(try db.listRules(folderId: destination.id).count == 3) + #expect(try db.listHistory().isEmpty) + for (original, copy) in zip(selected, copiedRules) { + #expect(copy.id != original.id) + #expect(copy.folderId == destination.id) + #expect(copy.enabled == original.enabled) + #expect(copy.conditionMatch == original.conditionMatch) + #expect(copy.recursionDepth == original.recursionDepth) + #expect(Set(copy.conditions.map(\.id)).isDisjoint(with: original.conditions.map(\.id))) + #expect(copy.conditions.allSatisfy { $0.ruleId == copy.id }) + #expect(copy.conditions.map(\.kind) == original.conditions.map(\.kind)) + #expect(copy.conditions.map(\.operator) == original.conditions.map(\.operator)) + #expect(copy.conditions.map(\.value) == original.conditions.map(\.value)) + #expect(Set(copy.actions.map(\.id)).isDisjoint(with: original.actions.map(\.id))) + #expect(copy.actions.allSatisfy { $0.ruleId == copy.id }) + #expect(copy.actions.map(\.kind) == original.actions.map(\.kind)) + #expect(copy.actions.map(\.params) == original.actions.map(\.params)) + #expect(copy.actions.map(\.position) == original.actions.map(\.position)) + } + #expect(try db.listRules(folderId: source.id) == originals) + } + + // Reopen the saved database, edit a copy and delete an original. + let reopened = try Database(path: databasePath) + #expect(try reopened.listRules(folderId: destination.id).dropFirst().map { $0 } == copiedRules) + var edited = copiedRules[0] + edited.name = "Changed copy" + edited.conditions[0].value = "pdf" + edited.actions[1].params = .object([ActionParam.destination: .string("/different/path")]) + try reopened.updateRule(edited) + #expect(try reopened.listRules(folderId: source.id) == originals) + try reopened.deleteRule(originals[0].id) + #expect(try reopened.listRules(folderId: destination.id).dropFirst().map { $0 } == [edited, copiedRules[1]]) + } + + @Test func freeRestrictionsAndFailuresNeverLeavePartialCopies() throws { + let dir = TempDir() + let db = try Database(path: ":memory:") + let folders = (0..<3).map { WatchedFolder(path: dir.dir("Folder\($0)")) } + for folder in folders { try db.insertFolder(folder) } + let normal = makeRule(folderId: folders[0].id, name: "Normal") + var paid = makeRule(folderId: folders[0].id, name: "Media import") + paid.actions = [makeAction(.importToLibrary, .object([ActionParam.libraryType: .string("photos")]), ruleId: paid.id)] + try db.insertRule(normal) + try db.insertRule(paid) + let selection: Set = [normal.id, paid.id] + + #expect(throws: FreePlanError.proFolder) { + try db.copyRules([normal.id], fromFolderID: folders[0].id, toFolderID: folders[2].id, isPro: false) + } + #expect(throws: FreePlanError.feature(.libraryImports)) { + try db.copyRules(selection, fromFolderID: folders[0].id, toFolderID: folders[1].id, isPro: false) + } + #expect(try db.listRules(folderId: folders[1].id).isEmpty) + #expect(throws: RuleCopyError.self) { + try db.copyRules([normal.id, "missing-rule"], fromFolderID: folders[0].id, toFolderID: folders[1].id, isPro: true) + } + #expect(throws: RuleCopyError.self) { + try db.copyRules([normal.id], fromFolderID: folders[0].id, toFolderID: "removed-folder", isPro: true) + } + #expect(throws: RuleCopyError.self) { + try db.copyRules([normal.id], fromFolderID: folders[0].id, toFolderID: folders[0].id, isPro: true) + } + #expect(throws: RuleCopyError.self) { + try db.copyRules([], fromFolderID: folders[0].id, toFolderID: folders[1].id, isPro: true) + } + #expect(try db.listRules(folderId: folders[1].id).isEmpty) + #expect(try db.listRules(folderId: folders[2].id).isEmpty) + + // Pro can copy enabled premium rules, including to the third folder. + let proCopies = try db.copyRules(selection, fromFolderID: folders[0].id, toFolderID: folders[2].id, isPro: true) + #expect(proCopies.count == 2) + #expect(proCopies.allSatisfy { $0.enabled }) + // Free may recover ordinary rules from a folder retained after Pro. + let recovered = try db.copyRules([proCopies[0].id], fromFolderID: folders[2].id, toFolderID: folders[1].id, isPro: false) + #expect(recovered.first?.name == normal.name) + paid.enabled = false + try db.updateRule(paid) + let disabledCopy = try db.copyRules([paid.id], fromFolderID: folders[0].id, toFolderID: folders[1].id, isPro: false) + #expect(disabledCopy.first?.enabled == false) + #expect(disabledCopy.first?.actions.first?.kind == .importToLibrary) + } + + @Test(arguments: ["preview", "manual", "watcher"]) + func copiedRulesRunInDestinationAcrossAllExecutionPaths(execution: String) throws { + let dir = TempDir() + let db = try Database(path: ":memory:") + let source = WatchedFolder(path: dir.dir("Source")) + let destination = WatchedFolder(path: dir.dir("Destination")) + _ = dir.dir("Destination/Nested") + let archive = dir.dir("Archive") + let sourceFile = dir.file("Source/report.txt") + let file = dir.file("Destination/Nested/report.txt") + for folder in [source, destination] { try db.insertFolder(folder) } + var rename = makeRule(folderId: source.id, name: "Rename", recursionDepth: 1) + rename.conditions = [makeCondition(.extension_, .is, "txt", ruleId: rename.id)] + rename.actions = [makeAction(.rename, .object([ActionParam.pattern: .string("processed-{name}")]), ruleId: rename.id)] + var move = makeRule(folderId: source.id, name: "Move", recursionDepth: 1) + move.conditions = [makeCondition(.extension_, .is, "txt", ruleId: move.id)] + move.actions = [makeAction(.moveToFolder, .object([ActionParam.destination: .string(archive)]), ruleId: move.id)] + try db.insertRule(rename) + try db.insertRule(move) + let copies = try db.copyRules([move.id, rename.id], fromFolderID: source.id, toFolderID: destination.id, isPro: false) + #expect(FileManager.default.fileExists(atPath: file)) + #expect(try db.listHistory().isEmpty) + let rules = try db.executionRules(folderID: destination.id, isPro: false) + let resultPath = archive + "/processed-report.txt" + if execution == "preview" { + let preview = try #require(RuleEngine.previewFile(path: file, depth: 1, rules: rules, isPro: false)) + #expect(preview.rules.map(\.ruleId) == copies.map(\.id)) + #expect(preview.rules.last?.actions.last?.targetPath == resultPath) + #expect(FileManager.default.fileExists(atPath: file)) + #expect(!FileManager.default.fileExists(atPath: resultPath)) + } else { + let history: [HistoryEntry] + if execution == "manual" { + history = RuleEngine.walkEntries(root: destination.path, maxDepth: 1).flatMap { entry in + RuleEngine.run(path: entry.path, depth: entry.depth, rules: rules, batchId: "manual", root: destination.path, isPro: false).history + } + } else { + let watcher = WatcherCoordinator(db: db, settleInterval: 0.01) + watcher.enqueue(event: .pathArrived(file)) + watcher.waitForPendingEvents() + history = try db.listHistory() + } + #expect(history.count == 2) + #expect(history.allSatisfy { $0.status == .applied }) + #expect(Set(history.compactMap(\.ruleId)) == Set(copies.map(\.id))) + #expect(FileManager.default.fileExists(atPath: resultPath)) + #expect(!FileManager.default.fileExists(atPath: file)) + } + #expect(FileManager.default.fileExists(atPath: sourceFile)) + #expect(try db.listRules(folderId: source.id).map(\.id) == [rename.id, move.id]) + } +}